Skip to content

Commit ca2a916

Browse files
Exercise late chat bodies and abort rejections through the real hook
1 parent 6c4b836 commit ca2a916

2 files changed

Lines changed: 119 additions & 24 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import type { ChatMessage } from '@pairux/shared-types';
4+
import { useChat } from './useChat';
5+
import { CHAT_REQUEST_TIMEOUT_MS } from '../lib/api/chat';
6+
7+
vi.mock('../lib/auth-session', () => ({
8+
getValidAccessToken: vi.fn().mockResolvedValue('test-token'),
9+
}));
10+
11+
describe('late responses through the actual chat transport and hook', () => {
12+
beforeEach(() => vi.useFakeTimers());
13+
afterEach(() => vi.useRealTimers());
14+
15+
it.each(['resolve', 'reject'] as const)(
16+
'does not commit or resend a timed-out message when its body later %ss',
17+
async (completion) => {
18+
const message = {
19+
id: 'committed-message',
20+
session_id: 'room',
21+
content: 'once',
22+
created_at: '2026-01-01T00:00:00.000Z',
23+
user_id: 'user',
24+
} as ChatMessage;
25+
let history: ChatMessage[] = [];
26+
let completeBody!: () => void;
27+
const posts = vi.fn();
28+
vi.mocked(fetch).mockImplementation((_url, options) => {
29+
if (options?.method === 'POST') {
30+
posts();
31+
return Promise.resolve({
32+
ok: true,
33+
json: () =>
34+
new Promise((resolve, reject) => {
35+
completeBody = () =>
36+
completion === 'resolve'
37+
? resolve({ data: message })
38+
: reject(new DOMException('Aborted', 'AbortError'));
39+
}),
40+
} as Response);
41+
}
42+
return Promise.resolve({
43+
ok: true,
44+
json: async () => ({ data: { messages: history, hasMore: false } }),
45+
} as Response);
46+
});
47+
const { result, unmount } = renderHook(() => useChat({ sessionId: 'room' }));
48+
await act(async () => {
49+
await vi.advanceTimersByTimeAsync(0);
50+
});
51+
expect(result.current.historyReady).toBe(true);
52+
let pending!: ReturnType<typeof result.current.sendMessage>;
53+
act(() => {
54+
pending = result.current.sendMessage('once');
55+
});
56+
await act(async () => {
57+
await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS);
58+
});
59+
expect(await pending).toEqual({ status: 'unknown' });
60+
expect(result.current.sending).toBe(false);
61+
await act(async () => {
62+
completeBody();
63+
await vi.advanceTimersByTimeAsync(0);
64+
});
65+
expect(result.current.messages).toEqual([]);
66+
expect(result.current.sending).toBe(false);
67+
expect(posts).toHaveBeenCalledTimes(1);
68+
// Only a later history read may reconcile an already committed server row.
69+
history = [message];
70+
await act(async () => {
71+
await vi.advanceTimersByTimeAsync(4000);
72+
});
73+
expect(result.current.messages).toEqual([message]);
74+
expect(posts).toHaveBeenCalledTimes(1);
75+
unmount();
76+
expect(vi.getTimerCount()).toBe(0);
77+
}
78+
);
79+
});

‎apps/mobile/src/lib/api/chat-delivery.test.ts‎

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -68,32 +68,45 @@ describe('bounded chat requests', () => {
6868
const controller = new AbortController();
6969
const pending = chatApi.send('room', 'cancelled draft', undefined, controller.signal);
7070
controller.abort();
71-
expect(await pending).toMatchObject({ failureKind: 'unknown' });
71+
await vi.advanceTimersByTimeAsync(0);
72+
expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' });
7273
finishAuth('late-token');
7374
await vi.advanceTimersByTimeAsync(1);
7475
expect(fetch).not.toHaveBeenCalled();
7576
expect(vi.getTimerCount()).toBe(0);
7677
});
7778

78-
it('keeps a timed-out send unknown when its body subsequently resolves', async () => {
79-
let finishBody!: (value: unknown) => void;
80-
vi.mocked(fetch).mockResolvedValue({
81-
ok: true,
82-
json: () =>
83-
new Promise((resolve) => {
84-
finishBody = resolve;
85-
}),
86-
} as Response);
87-
const pending = chatApi.send('room', 'once');
88-
await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS);
89-
const outcome = await pending;
90-
expect(outcome).toMatchObject({ failureKind: 'unknown' });
91-
finishBody({ data: { id: 'committed-message', session_id: 'room' } });
92-
await vi.advanceTimersByTimeAsync(1);
93-
expect(await pending).toBe(outcome);
94-
expect(fetch).toHaveBeenCalledTimes(1);
95-
expect(vi.getTimerCount()).toBe(0);
96-
});
79+
it.each(['fetch', 'body'] as const)(
80+
'handles the %s rejection produced by a deadline abort',
81+
async (stage) => {
82+
const rejected = vi.fn();
83+
vi.mocked(fetch).mockImplementation((_url, options) => {
84+
const waitForAbort = () =>
85+
new Promise<never>((_resolve, reject) => {
86+
options?.signal?.addEventListener(
87+
'abort',
88+
() => {
89+
rejected();
90+
reject(new DOMException('Aborted', 'AbortError'));
91+
},
92+
{ once: true }
93+
);
94+
});
95+
if (stage === 'fetch') return waitForAbort();
96+
const response = new Response(null, { status: 200 });
97+
vi.spyOn(response, 'json').mockImplementation(waitForAbort);
98+
return Promise.resolve(response);
99+
});
100+
const pending = chatApi.send('room', 'once');
101+
await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS);
102+
expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' });
103+
await vi.advanceTimersByTimeAsync(1);
104+
expect(rejected).toHaveBeenCalledTimes(1);
105+
expect(fetch).toHaveBeenCalledTimes(1);
106+
expect(vi.mocked(fetch).mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
107+
expect(vi.getTimerCount()).toBe(0);
108+
}
109+
);
97110

98111
it('removes cancellation listeners and deadlines after a successful send', async () => {
99112
const response = { data: { id: 'confirmed-message', session_id: 'room' } };
@@ -116,11 +129,14 @@ describe('bounded chat requests', () => {
116129
vi.mocked(fetch).mockImplementation(() => new Promise(() => {}));
117130
const pending = chatApi.getHistory('room', { limit: 25 });
118131
await vi.advanceTimersByTimeAsync(CHAT_REQUEST_TIMEOUT_MS);
119-
expect(await pending).toMatchObject({ failureKind: 'unknown' });
132+
expect(await pending).toEqual({ error: 'Chat request interrupted', failureKind: 'unknown' });
120133
expect(fetch).toHaveBeenCalledTimes(1);
121-
expect(vi.mocked(fetch).mock.calls[0]?.[0]).toContain(
122-
'/api/chat/history?sessionId=room&limit=25'
123-
);
134+
const request = vi.mocked(fetch).mock.calls[0]?.[0];
135+
if (!request) throw new Error('Expected a history request');
136+
const url = new URL(request instanceof Request ? request.url : request);
137+
expect(url.pathname).toBe('/api/chat/history');
138+
expect(url.searchParams.get('sessionId')).toBe('room');
139+
expect(url.searchParams.get('limit')).toBe('25');
124140
expect(vi.mocked(fetch).mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
125141
expect(vi.getTimerCount()).toBe(0);
126142
});

0 commit comments

Comments
 (0)