Add missing tests and address AGENTS.md gaps

This commit is contained in:
Jack Kingsman
2026-02-23 20:26:57 -08:00
parent b9de3b7dd7
commit 5d7a313c53
9 changed files with 462 additions and 65 deletions
@@ -319,6 +319,10 @@ export function useConversationMessages(
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// Reset loadingOlder — the previous conversation's in-flight older-message
// fetch is irrelevant now (its stale-check will discard the response).
setLoadingOlder(false);
// Clear state for new conversation
if (!activeConversation || activeConversation.type === 'raw') {
setMessages([]);
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import * as messageCache from '../messageCache';
import { useConversationMessages } from '../hooks/useConversationMessages';
@@ -124,3 +124,98 @@ describe('useConversationMessages ACK ordering', () => {
expect(result.current.messages[0].paths).toEqual(highAckPaths);
});
});
describe('useConversationMessages conversation switch', () => {
beforeEach(() => {
mockGetMessages.mockReset();
messageCache.clear();
});
it('resets loadingOlder when switching conversations mid-fetch', async () => {
const convA: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
const convB: Conversation = { type: 'contact', id: 'conv_b', name: 'Contact B' };
// Conv A initial fetch: return 200 messages so hasOlderMessages = true
const fullPage = Array.from({ length: 200 }, (_, i) =>
createMessage({
id: i + 1,
conversation_key: 'conv_a',
text: `msg-${i}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000000 + i,
})
);
mockGetMessages.mockResolvedValueOnce(fullPage);
const { result, rerender } = renderHook(
({ conv }: { conv: Conversation }) => useConversationMessages(conv),
{ initialProps: { conv: convA } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.hasOlderMessages).toBe(true);
expect(result.current.messages).toHaveLength(200);
// Start fetching older messages — use a deferred promise so it stays in-flight
const olderDeferred = createDeferred<Message[]>();
mockGetMessages.mockReturnValueOnce(olderDeferred.promise);
act(() => {
result.current.fetchOlderMessages();
});
expect(result.current.loadingOlder).toBe(true);
// Switch to conv B while older-messages fetch is still pending
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
rerender({ conv: convB });
// loadingOlder must reset immediately — no phantom spinner in conv B
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].conversation_key).toBe('conv_b');
// Resolve the stale older-messages fetch — should not affect conv B's state
olderDeferred.resolve([
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
]);
// Give the stale response time to be processed (it should be discarded)
await new Promise((r) => setTimeout(r, 50));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].conversation_key).toBe('conv_b');
});
it('aborts in-flight fetch when switching conversations', async () => {
const convA: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
const convB: Conversation = { type: 'contact', id: 'conv_b', name: 'Contact B' };
// Conv A: never resolves (simulates slow network)
mockGetMessages.mockReturnValueOnce(new Promise(() => {}));
const { result, rerender } = renderHook(
({ conv }: { conv: Conversation }) => useConversationMessages(conv),
{ initialProps: { conv: convA } }
);
// Should be loading
expect(result.current.messagesLoading).toBe(true);
// Verify the API was called with an AbortSignal
const firstCallSignal = (mockGetMessages as Mock).mock.calls[0]?.[1];
expect(firstCallSignal).toBeInstanceOf(AbortSignal);
// Switch to conv B
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 1, conversation_key: 'conv_b' })]);
rerender({ conv: convB });
// The signal from conv A's fetch should have been aborted
expect(firstCallSignal.aborted).toBe(true);
// Conv B should load normally
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].conversation_key).toBe('conv_b');
});
});