import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { eventBus, publish, subscribe } from './event-bus'; import { useAppEvent, usePublishEvent } from './hooks'; // ─── Test-Local Event Augmentation ────────────────────────────── // // In a real app, these would live in a `.d.ts` file inside the app's // `src/types/` folder using `declare module '@repo/core-events'`. // // For the core package's own test suite, we augment the registry // directly here so the tests have concrete event types to work with // without polluting the core's shipped types. // declare module './events.registry' { interface AppEventRegistry { 'TEST:STOCK_UPDATE': StockUpdatePayload; 'TEST:PROFILE_UPDATED': ProfileUpdatedPayload; 'TEST:INITIALIZED': undefined; } } // ─── Test Payload Types ───────────────────────────────────────── interface StockUpdatePayload { id: string; price: number; change: number; volume: number; } interface ProfileUpdatedPayload { id: string; name: string; email: string; avatar: string; updatedAt: number; } // ─── Helpers ──────────────────────────────────────────────────── /** Clear all mitt handlers between tests to avoid cross-contamination. */ function clearAllHandlers() { eventBus.all.clear(); } // ─── Test Data ────────────────────────────────────────────────── const mockStockUpdate: StockUpdatePayload = { id: 'AAPL', price: 185.42, change: 1.23, volume: 50000, }; const mockProfile: ProfileUpdatedPayload = { id: 'user-1', name: 'Firman', email: 'firman@eigen.co.id', avatar: 'https://example.com/avatar.png', updatedAt: Date.now(), }; // ─── Core Event Bus Tests ─────────────────────────────────────── describe('Event Bus (Core)', () => { beforeEach(() => { clearAllHandlers(); }); afterEach(() => { clearAllHandlers(); }); it('publishes and subscribes to a typed event', () => { const handler = vi.fn(); subscribe('TEST:STOCK_UPDATE', handler); publish('TEST:STOCK_UPDATE', mockStockUpdate); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(mockStockUpdate); }); it('delivers events to multiple subscribers', () => { const handler1 = vi.fn(); const handler2 = vi.fn(); subscribe('TEST:PROFILE_UPDATED', handler1); subscribe('TEST:PROFILE_UPDATED', handler2); publish('TEST:PROFILE_UPDATED', mockProfile); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(1); }); it('does not deliver events to unrelated subscribers', () => { const stockHandler = vi.fn(); const profileHandler = vi.fn(); subscribe('TEST:STOCK_UPDATE', stockHandler); subscribe('TEST:PROFILE_UPDATED', profileHandler); publish('TEST:STOCK_UPDATE', mockStockUpdate); expect(stockHandler).toHaveBeenCalledTimes(1); expect(profileHandler).not.toHaveBeenCalled(); }); it('unsubscribes correctly via returned function', () => { const handler = vi.fn(); const unsub = subscribe('TEST:STOCK_UPDATE', handler); publish('TEST:STOCK_UPDATE', mockStockUpdate); expect(handler).toHaveBeenCalledTimes(1); // Unsubscribe unsub(); publish('TEST:STOCK_UPDATE', mockStockUpdate); // Should still be 1, not 2 expect(handler).toHaveBeenCalledTimes(1); }); it('handles events with undefined payloads', () => { const handler = vi.fn(); subscribe('TEST:INITIALIZED', handler); publish('TEST:INITIALIZED', undefined); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(undefined); }); it('handles rapid-fire events without loss', () => { const handler = vi.fn(); subscribe('TEST:STOCK_UPDATE', handler); for (let i = 0; i < 1000; i++) { publish('TEST:STOCK_UPDATE', { ...mockStockUpdate, id: `STOCK-${i}` }); } expect(handler).toHaveBeenCalledTimes(1000); }); }); // ─── React Hook Tests ─────────────────────────────────────────── describe('useAppEvent (React Hook)', () => { beforeEach(() => { clearAllHandlers(); }); afterEach(() => { clearAllHandlers(); }); it('subscribes on mount and receives events', () => { const handler = vi.fn(); renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler)); act(() => { publish('TEST:STOCK_UPDATE', mockStockUpdate); }); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(mockStockUpdate); }); it('unsubscribes on unmount — MEMORY LEAK PREVENTION', () => { const handler = vi.fn(); const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler)); // Event should be received while mounted act(() => { publish('TEST:STOCK_UPDATE', mockStockUpdate); }); expect(handler).toHaveBeenCalledTimes(1); // Unmount the component unmount(); // Event should NOT be received after unmount act(() => { publish('TEST:STOCK_UPDATE', mockStockUpdate); }); // Still 1, proving the handler was properly cleaned up expect(handler).toHaveBeenCalledTimes(1); }); it('does not leak handlers across mount/unmount cycles', () => { const handler = vi.fn(); // Mount and unmount 100 times for (let i = 0; i < 100; i++) { const { unmount } = renderHook(() => useAppEvent('TEST:STOCK_UPDATE', handler)); unmount(); } // After 100 cycles, emit one event act(() => { publish('TEST:STOCK_UPDATE', mockStockUpdate); }); // Should be 0 — all handlers should have been cleaned up expect(handler).toHaveBeenCalledTimes(0); // Verify the handler map is empty for this event type const handlers = eventBus.all.get('TEST:STOCK_UPDATE'); expect(!handlers || handlers.length === 0).toBe(true); }); it('always calls the latest handler (no stale closures)', () => { let capturedValue = ''; const { rerender } = renderHook( ({ value }: { value: string }) => useAppEvent('TEST:PROFILE_UPDATED', () => { capturedValue = value; }), { initialProps: { value: 'initial' } }, ); // Update the closure value rerender({ value: 'updated' }); act(() => { publish('TEST:PROFILE_UPDATED', mockProfile); }); // Should capture the LATEST value, not the stale 'initial' expect(capturedValue).toBe('updated'); }); it('does not re-subscribe when handler reference changes', () => { // We spy on eventBus.on to count subscription calls const onSpy = vi.spyOn(eventBus, 'on'); const offSpy = vi.spyOn(eventBus, 'off'); const { rerender } = renderHook( ({ handler }: { handler: () => void }) => useAppEvent('TEST:INITIALIZED', handler), { initialProps: { handler: vi.fn() } }, ); const initialOnCount = onSpy.mock.calls.length; const initialOffCount = offSpy.mock.calls.length; // Re-render with a NEW handler function reference rerender({ handler: vi.fn() }); // on/off should NOT have been called again (ref-based pattern) expect(onSpy.mock.calls.length).toBe(initialOnCount); expect(offSpy.mock.calls.length).toBe(initialOffCount); onSpy.mockRestore(); offSpy.mockRestore(); }); }); describe('usePublishEvent (React Hook)', () => { beforeEach(() => { clearAllHandlers(); }); afterEach(() => { clearAllHandlers(); }); it('returns a working publish function', () => { const handler = vi.fn(); subscribe('TEST:PROFILE_UPDATED', handler); const { result } = renderHook(() => usePublishEvent()); act(() => { result.current('TEST:PROFILE_UPDATED', mockProfile); }); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(mockProfile); }); it('returns a referentially stable function across re-renders', () => { const { result, rerender } = renderHook(() => usePublishEvent()); const firstRef = result.current; rerender(); rerender(); rerender(); expect(result.current).toBe(firstRef); }); });