99f9ac84fb
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.7 KiB
TypeScript
161 lines
6.7 KiB
TypeScript
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 <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
|
|
};
|
|
|
|
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<Message[]>((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();
|
|
}
|
|
});
|
|
});
|