import { describe, it, expect, vi } from 'vitest'; import { renderHook, waitFor, act } from '@testing-library/react'; import type { ReactNode } from 'react'; import { MessagingProvider } from '../provider'; import { MockAdapter } from '../adapters/mock'; import { useMessages } from './use-messages'; import type { MessagingAdapter } from '../adapter'; import type { Message } from '../types'; const wrap = (adapter: MessagingAdapter) => function Wrapper({ children }: { children: ReactNode }) { return {children}; }; describe('useMessages', () => { it('loads history for the thread', async () => { const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?'); }); // REGRESSION: the CRM inferred actor identity by scanning for a sent message, so // before you had spoken in a thread EVERY message rendered as not-yours. it('marks ownership correctly before the user has sent anything', async () => { const adapter = new MockAdapter(); await adapter.send('th_mock_1', 'an earlier message of mine'); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await waitFor(() => expect(result.current.messages).toHaveLength(2)); // Never sent anything via the hook — ownership still resolves from currentActorId(). expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia expect(result.current.messages[1]!.mine).toBe(true); // from me }); it('appends an optimistic message immediately on send', async () => { const adapter = new MockAdapter(); let release!: () => void; vi.spyOn(adapter, 'send').mockImplementation( () => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }), ); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { void result.current.send('hi'); }); await waitFor(() => expect(result.current.messages).toHaveLength(2)); expect(result.current.messages[1]!.pending).toBe(true); expect(result.current.messages[1]!.mine).toBe(true); await act(async () => { release(); }); await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy()); }); it('rolls back the optimistic message and reports error when send fails', async () => { const adapter = new MockAdapter(); vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline')); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await expect(result.current.send('doomed')).rejects.toThrow('offline'); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false); expect(result.current.error).toBe('offline'); }); it('does not duplicate a message when the transport echoes it back', async () => { const adapter = new MockAdapter(); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await waitFor(() => expect(result.current.loading).toBe(false)); // MockAdapter.send emits a 'message' event AND resolves with the same message. await act(async () => { await result.current.send('echo once'); }); expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1); }); it('collects typing user ids from subscribe events', async () => { const adapter = new MockAdapter(); let emit!: (userId: string) => void; vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { emit = (userId) => cb({ kind: 'typing', userId }); return () => {}; }); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => emit('pp_sofia')); expect(result.current.typingUserIds).toEqual(['pp_sofia']); }); it('unsubscribes on unmount', async () => { const adapter = new MockAdapter(); const off = vi.fn(); vi.spyOn(adapter, 'subscribe').mockReturnValue(off); const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await waitFor(() => expect(result.current.loading).toBe(false)); unmount(); expect(off).toHaveBeenCalled(); }); it('keeps a live message that arrives before history resolves', async () => { const adapter = new MockAdapter(); let resolveHistory!: (msgs: Message[]) => void; vi.spyOn(adapter, 'history').mockImplementation( () => new Promise((res) => { resolveHistory = res; }), ); let emit!: (m: Message) => void; vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { emit = (m) => cb({ kind: 'message', message: m }); return () => {}; }); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); // A live message arrives while history() is still pending. act(() => emit({ id: 'live_1', actorId: 'pp_sofia', text: 'ping before history', at: '2026-07-17T10:00:00.000Z' })); // History resolves afterwards with an older message. await act(async () => { resolveHistory([{ id: 'hist_1', actorId: 'pp_sofia', text: 'older', at: '2026-07-17T09:00:00.000Z' }]); }); const texts = result.current.messages.map((m) => m.text); expect(texts).toContain('older'); expect(texts).toContain('ping before history'); // must NOT be clobbered by history load }); it('clears a typing indicator after its TTL elapses', async () => { vi.useFakeTimers(); try { const adapter = new MockAdapter(); let emit!: (userId: string) => void; vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { emit = (userId) => cb({ kind: 'typing', userId }); return () => {}; }); const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // flush history microtask act(() => emit('pp_sofia')); expect(result.current.typingUserIds).toEqual(['pp_sofia']); await act(async () => { await vi.advanceTimersByTimeAsync(3600); }); expect(result.current.typingUserIds).toEqual([]); } finally { vi.useRealTimers(); } }); });