perf(app): reduce redundant data fetching

Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware.

Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
jjbrunton committed 2026-08-29 21:47:27 +01:00
1 parent 9957fa43f2
commit c7ee8a4851
81 files changed
+4456 -1400

No files matched your search

@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
useResponsesStore.getState = () => responses;
// Read lazily: the factory runs before this module's own consts are initialised.
const streak = { fetchStreak: () => mockFetchStreak() };
const useStreakStore = () => streak;
useStreakStore.getState = () => streak;
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
});
jest.mock("@/lib/skippedQuestions", () => ({
@@ -45,17 +51,19 @@ const questions = [
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
];
const futureResetAt = "2099-08-29T00:00:00.000Z";
const openLimit = {
responses_today: 4,
limit_value: 10,
remaining: 6,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: false,
};
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
error: { code: "daily_limit", message: "Daily response limit reached" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
});
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
responses_today: 10,
limit_value: 10,
remaining: 0,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: true,
});
});
@@ -0,0 +1,313 @@
import { act, renderHook } from '@testing-library/react-native';
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { useMessageStore } from '@/store/messageStore';
import { usePacksStore } from '@/store/packsStore';
import { useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const baseline: SyncSummary = {
server_time: '2026-08-27T12:00:00.000Z',
couple_id: 'couple',
profile_updated_at: '2026-08-01T00:00:00.000Z',
partner_id: 'partner',
partner_updated_at: '2026-08-01T00:00:00.000Z',
match_count: 3,
new_match_count: 0,
latest_match_at: '2026-08-20T00:00:00.000Z',
pending_yours: 1,
pending_theirs: 2,
unread_total: 0,
enabled_packs_fingerprint: 'digest-a',
match_state_fingerprint: 'state-a',
match_unread_fingerprint: 'unread-a',
streak_updated_at: '2026-08-01T00:00:00.000Z',
};
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
await flush();
};
/** Replaces every domain refresh the sync can dispatch with a counter. */
function stubRefreshers() {
const fetchUser = jest.fn(async () => undefined);
const fetchCouple = jest.fn(async () => undefined);
const fetchMatches = jest.fn(async () => undefined);
const fetchPendingQuestions = jest.fn(async () => undefined);
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
const fetchEnabledPacks = jest.fn(async () => undefined);
const refreshStreak = jest.fn(async () => undefined);
useAuthStore.setState({ fetchUser, fetchCouple } as never);
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
usePacksStore.setState({ fetchEnabledPacks } as never);
useStreakStore.setState({ refreshStreak } as never);
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
}
describe('useCoupleSync', () => {
let refreshers: ReturnType<typeof stubRefreshers>;
const originals = {
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
match: {
fetchMatches: useMatchStore.getState().fetchMatches,
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
},
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
useMessageStore.setState({ unreadCount: 0 });
// Every view starts loaded: this hook must refresh only what is on screen,
// and the "not loaded" case is asserted separately below.
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
refreshers = stubRefreshers();
});
afterEach(() => {
jest.useRealTimers();
useAuthStore.setState(originals.auth as never);
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
useStreakStore.setState(originals.streak as never);
});
const answer = (...summaries: SyncSummary[]) => {
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
mock.mockResolvedValue(summaries[summaries.length - 1]);
};
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
answer(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
// Three polls, three requests in total: the baseline poll must not trigger
// the refresh of five domains that the old five-second sweep performed.
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('applies the unread total from the summary without a second request', async () => {
answer(baseline, { ...baseline, unread_total: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(useMessageStore.getState().unreadCount).toBe(4);
});
it('refreshes only the domain whose marker moved', async () => {
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('routes each remaining marker to its own domain', async () => {
answer(baseline, {
...baseline,
pending_yours: 2,
pending_theirs: 3,
enabled_packs_fingerprint: 'digest-b',
partner_updated_at: '2026-08-29T00:00:00.000Z',
streak_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
// A partner profile change is a couple-level read; the whole user is only
// re-read when the user's own profile or couple membership moved.
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
// Counts and latest-created-at do not move for an in-place match_type/
// response_summary edit; only the state fingerprint does.
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
// One match is read while another receives a message: unread_total holds
// steady but the per-match fingerprint (and unread-first ordering) moves.
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('does not refresh enabled packs when that domain has never been opened', async () => {
usePacksStore.setState({ enabledPacksLoadedAt: null });
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
});
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
answer({ ...baseline, match_count: 3 });
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
await tick(); // establishes alice's baseline (match_count: 3)
// Switch accounts: bob's couple has a completely different match_count.
// Without a baseline reset this would look like a 12-count jump and
// trigger a refresh purely from comparing across accounts.
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
answer(baseline, {
...baseline,
profile_updated_at: '2026-08-29T00:00:00.000Z',
partner_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('leaves views the user has never opened unloaded', async () => {
useMatchStore.setState({ loadedAt: {} });
answer(baseline, {
...baseline,
match_count: 9,
latest_match_at: '2026-08-29T00:00:00.000Z',
pending_yours: 7,
pending_theirs: 8,
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
});
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
answer(baseline);
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
await flush();
await tick();
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
unmount();
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => {
mockForeground.value = false;
for (const listener of [...mockListeners]) listener(false);
});
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
});
it('discards a summary that lands after the account it belongs to was switched away', async () => {
// Alice's poll is still open when Bob signs in. Its answer describes a
// couple Bob is not in, so none of it may land: not his badge, not the
// baseline his next poll is diffed against, not a refresh decision.
let answerAlice: (summary: SyncSummary) => void = () => undefined;
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
expect(mock).toHaveBeenCalledTimes(1);
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
mock.mockResolvedValue(bobBaseline);
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Alice's request finally answers, long after she stopped being the user.
await act(async () => {
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
await Promise.resolve();
});
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Bob's baseline is still Bob's: a poll identical to his own first one has
// nothing to refresh. Had Alice's answer been recorded as the baseline,
// this would read as a 40-match jump and refresh the Matches view.
await tick();
expect(useMessageStore.getState().unreadCount).toBe(1);
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('never runs two summaries at once', async () => {
let release: (summary: SyncSummary) => void = () => undefined;
(appDataApi.syncSummary as jest.Mock).mockReset();
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => { release(baseline); await Promise.resolve(); });
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useDares } from "@/features/dares/hooks/useDares";
import { ApiError, apiClient } from "@/lib/apiClient";
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
@@ -90,3 +91,80 @@ describe("useDares", () => {
expect(result.current.error).toBe("offline");
});
});
describe("useDares polling cost", () => {
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
function mockCounting(active: () => SentDare[]) {
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
throw new Error(`unexpected path ${path}`);
});
return calls;
}
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(5_000); });
await flush();
};
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
let current: SentDare[] = [incoming];
const calls = mockCounting(() => current);
const { result } = renderHook(() => useDares({ isFocused: true }));
await flush();
expect(result.current.loading).toBe(false);
// The first pass is a full load: nothing is cached yet.
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
await tick();
await tick();
// Two quiet polls cost one request each, not four. The catalogue and the
// stats cannot move while the active set is byte-for-byte identical.
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
current = [{ ...incoming, status: "active" }];
await tick();
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
expect(result.current.active[0]?.status).toBe("active");
await tick();
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
});
it("issues nothing at all while the screen is not on top", async () => {
const calls = mockCounting(() => [incoming]);
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
({ isFocused }) => useDares({ isFocused }),
{ initialProps: { isFocused: false } },
);
await tick();
await tick();
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
rerender({ isFocused: true });
await flush();
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
});
it("never overlaps two polls", async () => {
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
return { dares: [], ...catalog, ...stats } as never;
});
renderHook(() => useDares({ isFocused: true }));
await flush();
await act(async () => { jest.advanceTimersByTime(20_000); });
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
});
});
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
});
});
describe('useDrawingSync focus gating', () => {
const handlers = {
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
};
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
const onLoadStrokes = jest.fn();
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
}), { initialProps: { isFocused: false } });
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
rerender({ isFocused: true });
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
rerender({ isFocused: false });
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
it('never polls over its own write in flight', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
let release: (state: unknown) => void = () => undefined;
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
}));
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
const pending = result.current.persistStrokes([stroke]);
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
// The write returns the authoritative state; reading over the top of it would
// only race a revision we are about to replace.
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
await act(async () => {
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
await pending;
});
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
id, match_id: 'match1', user_id, content: id,
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
} as any);
describe('useMessageSubscription API polling', () => {
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
messages,
removed_ids: options.removed ?? [],
server_time: options.server_time ?? 'cursor-2',
complete: false,
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
});
const listMessages = chatApi.listMessages as jest.Mock;
const markRead = chatApi.markRead as jest.Mock;
describe('useMessageSubscription incremental polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
listMessages.mockResolvedValue(snapshot([]));
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
it('fails closed without both a match and authenticated user', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
it('fails closed without both match and authenticated user', () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
);
expect(result.current.loading).toBe(false);
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
});
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValue(delta([]));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(1));
const cached = result.current.messages;
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(result.current.messages).toBe(cached);
expect(result.current.loading).toBe(false);
});
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
listMessages.mockResolvedValueOnce(snapshot([
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
]));
listMessages.mockResolvedValueOnce(delta([
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
], { removed: ['m2'] }));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
expect(result.current.messages[1]?.deleted_at).toBe('gone');
});
it('marks read only when the response holds an unread partner message', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
const { result } = mount();
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
expect(markRead).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(2);
});
it('issues no request at all while the chat is not focused', async () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
{ initialProps: { focused: false } },
);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).not.toHaveBeenCalled();
expect(markRead).not.toHaveBeenCalled();
rerender({ focused: true });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
rerender({ focused: false });
const callsAtBlur = listMessages.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
});
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
const { result } = mount({ onNewMessage });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [message('m1')] })
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.isFocusedRef.current = true; });
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
for (const expected of [2, 3, 4]) {
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
}
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('does not notify for the current user own newly polled message', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
expect(onNewMessage).not.toHaveBeenCalled();
it('reads partner typing state out of the message response', async () => {
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
const { result } = mount();
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
listMessages.mockRejectedValueOnce(new Error('forbidden'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
it('prevents overlapping polls and clears the interval on unmount', async () => {
let resolveFirst!: (value: { messages: any[] }) => void;
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(1_500); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ messages: [] }));
it('prevents overlapping polls and stops on unmount', async () => {
let resolveFirst!: (value: unknown) => void;
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = mount();
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(1_500); });
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirst(snapshot([])); });
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(1);
});
it('restarts immediately, and without a stale cursor, for a different match', async () => {
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
{ initialProps: { matchId: 'match1' } },
);
await waitFor(() => expect(result.current.messages).toHaveLength(1));
rerender({ matchId: 'match2' });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
});
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react-native';
import { usePolling } from '@/hooks/usePolling';
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const setForeground = async (value: boolean) => {
await act(async () => {
mockForeground.value = value;
for (const listener of [...mockListeners]) listener(value);
});
};
// The next run is scheduled once the previous one settles, so let pending
// promises land before and after moving the clock.
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async (ms: number) => {
await flush();
await act(async () => { jest.advanceTimersByTime(ms); });
await flush();
};
describe('usePolling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
});
afterEach(() => jest.useRealTimers());
it('runs once immediately and then on the interval', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('skips the leading run when asked to', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
expect(task).not.toHaveBeenCalled();
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
});
it('issues nothing while disabled and starts once enabled', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
initialProps: { enabled: false },
});
await tick(1_000);
expect(task).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(task).toHaveBeenCalledTimes(1);
});
it('stops while the app is backgrounded and catches up on return', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await setForeground(false);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(1);
await setForeground(true);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('never runs two tasks concurrently', async () => {
let release!: () => void;
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(500);
expect(task).toHaveBeenCalledTimes(1);
await act(async () => { release(); });
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
});
it('backs off after consecutive failures and recovers after a success', async () => {
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(399);
expect(task).toHaveBeenCalledTimes(2);
await tick(1);
expect(task).toHaveBeenCalledTimes(3);
task.mockImplementation(async () => undefined);
await tick(400);
expect(task).toHaveBeenCalledTimes(4);
await tick(100);
expect(task).toHaveBeenCalledTimes(5);
});
it('restarts immediately when the reset key changes', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
initialProps: { resetKey: 'a' },
});
expect(task).toHaveBeenCalledTimes(1);
rerender({ resetKey: 'b' });
expect(task).toHaveBeenCalledTimes(2);
await tick(50);
expect(task).toHaveBeenCalledTimes(2);
});
it('stops and unsubscribes on unmount', async () => {
const task = jest.fn(async () => undefined);
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
unmount();
expect(mockListeners.size).toBe(0);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(2);
});
});
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
it('stops polling and mirrors typing state supplied by the message poll', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
{ initialProps: { externalTyping: undefined as boolean | undefined } },
);
await act(async () => { await Promise.resolve(); });
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
rerender({ externalTyping: true });
expect(result.current.partnerTyping).toBe(true);
await act(async () => { jest.advanceTimersByTime(5_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
act(() => result.current.clearTypingIndicator());
expect(result.current.partnerTyping).toBe(false);
rerender({ externalTyping: false });
expect(result.current.partnerTyping).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { AppState } from 'react-native';
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
describe('appForeground', () => {
afterEach(() => jest.restoreAllMocks());
it('treats only background and inactive as away', () => {
expect(isForegroundState('active')).toBe(true);
expect(isForegroundState('background')).toBe(false);
expect(isForegroundState('inactive')).toBe(false);
// Unknown states — including whatever a test harness leaves behind — must
// fail towards running, never towards disabling recurring work forever.
expect(isForegroundState(undefined)).toBe(true);
expect(isForegroundState('extension')).toBe(true);
});
it('reads the current state and reports transitions until unsubscribed', () => {
let emit: (state: string) => void = () => undefined;
const remove = jest.fn();
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
emit = handler;
return { remove } as never;
}) as never);
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
const seen: boolean[] = [];
const unsubscribe = subscribeToForeground(value => seen.push(value));
emit('background');
emit('active');
expect(seen).toEqual([false, true]);
unsubscribe();
expect(remove).toHaveBeenCalled();
});
});
@@ -24,6 +24,18 @@ describe('chatApi', () => {
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
});
it('encodes the delta cursor and folded typing request into the message query', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
await chatApi.listMessages('match1', { typing: true });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
});
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
(apiClient.post as jest.Mock).mockResolvedValue({});
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
});
it('guards concurrent loads and exhausted pagination', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
useMatchStore.setState({ isLoading: false, hasMore: false });
// Overlap protection cannot key off the loading flags any more: a silent
// refresh raises none of them and still must not issue a second request.
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const inFlight = useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
release({ matches: [], totalCount: 0 });
await inFlight;
(apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false });
await useMatchStore.getState().fetchMatches(false);
expect(apiClient.get).not.toHaveBeenCalled();
});
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
it('toggles archived visibility and triggers an initial fetch only', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
useMatchStore.getState().toggleShowArchived();
await Promise.resolve();
// Let the floating fetch settle: its in-flight guard is module-scoped and
// is only released in a finally, so a half-run request would leak into
// the next test and silently suppress its archived fetch.
await jest.runOnlyPendingTimersAsync();
expect(useMatchStore.getState().showArchived).toBe(true);
expect(apiClient.get).toHaveBeenCalledTimes(1);
useMatchStore.getState().toggleShowArchived();
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
});
it('updates current view and delegates an empty view fetch', () => {
const real = useMatchStore.getState().fetchArchivedMatches;
const fetchArchivedMatches = jest.fn(async () => undefined);
useMatchStore.setState({ fetchArchivedMatches });
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
try {
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
} finally {
// Actions live in the same store as the data, and clearMatches does not
// reset them: leaving the stub in place would silence every later test's
// archived fetch.
useMatchStore.setState({ fetchArchivedMatches: real });
}
});
});
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
});
});
describe('view switching and cache freshness', () => {
/** Answers each of the four view endpoints with an empty payload. */
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
return { questions: [] };
});
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
emptyViews();
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
// First visit to each view loads it once.
for (const view of views) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
expect(useMatchStore.getState()).toMatchObject({
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
});
// Every subsequent switch is served from cache. An empty view is loaded,
// not unloaded: this is the bug where `array.length === 0` meant "never
// fetched" and made every visit pay for the same empty answer.
(apiClient.get as jest.Mock).mockClear();
for (const view of [...views, ...views].reverse()) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).not.toHaveBeenCalled();
});
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
// Mid-flight: the user still sees the rows they had, and neither the
// full-screen loader nor the pull-to-refresh spinner is showing.
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
await refreshing;
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [], totalCount: 0, hasMore: false });
await refreshing;
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
});
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const quiet = Promise.all([
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
]);
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
release({ questions: [] });
await quiet;
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchMatches(true);
// Simulates sign-out/account switch while the request is still in flight.
useMatchStore.getState().clearMatches();
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
await staleFetch;
expect(useMatchStore.getState().matches).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('does not let a stale seen-marker write over the next account\'s list', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAsSeen('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
const untouched = useMatchStore.getState().matches;
release();
await stale;
// Not merely "the same ids": the previous account's response must not
// rewrite the array at all, because the recount it derives is only true
// of the rows that account had loaded.
expect(useMatchStore.getState().matches).toBe(untouched);
expect(useMatchStore.getState().newMatchesCount).toBe(1);
});
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAllAsSeen();
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
release();
await stale;
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
expect(useMatchStore.getState().newMatchesCount).toBe(2);
});
it('does not roll a failed archive or unarchive back into the next account', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let fail: (error: Error) => void = () => undefined;
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
fail(new Error('forbidden'));
await staleArchive;
// The revert would otherwise put one couple's match into another's list.
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
expect(useMatchStore.getState().totalCount).toBe(1);
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
fail(new Error('forbidden'));
await staleUnarchive;
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
expect(useMatchStore.getState().totalCount).toBe(0);
});
it('does not carry a nudge cooldown across an account switch', async () => {
let release: (data: unknown) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleSend = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
// The 429 branch writes a cooldown too, and must be gated the same way.
let fail: (error: Error) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
const staleLimit = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
await staleLimit;
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
});
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
let release: (profile: unknown) => void = () => undefined;
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const stale = useMatchStore.getState().checkNudgeCooldown();
useMatchStore.getState().clearMatches();
const mine = new Date('2026-08-27T18:00:00Z');
useMatchStore.setState({ nudgeCooldownUntil: mine });
// The previous account not being in cooldown says nothing about this one.
release({ last_nudge_sent_at: null });
await stale;
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
});
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
useMatchStore.getState().clearMatches();
release({ questions: [pending('stale')] });
await staleFetch;
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
await useMatchStore.getState().fetchPendingQuestions();
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
});
});
it('clearMatches resets all user-scoped state', () => {
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
useMatchStore.getState().clearMatches();
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
});
it('marks partner messages read then reconciles global and per-match counts', async () => {
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(1);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
});
it('spends no request and touches no count when the read API cleared nothing', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(3);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
it('does not mark read without an authenticated user', async () => {
useAuthStore.setState({ user: null } as any);
await useMessageStore.getState().markMatchMessagesAsRead('m1');
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale unread total become the next account\'s badge', async () => {
let release: (value: unknown) => void = () => undefined;
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMessageStore.getState().fetchUnreadCount();
// Sign-out or leaving a couple, while the request is still open.
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
release({ total: 9, by_match: { m1: 9 } });
await staleFetch;
expect(useMessageStore.getState().unreadCount).toBe(2);
});
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
useMessageStore.setState({ unreadCount: 3 });
let release: (value: unknown) => void = () => undefined;
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
await staleRead;
// The badge is wrong whatever the match id; reusing 'm1' also makes the
// second half of the damage — the per-match count — visible.
expect(useMessageStore.getState().unreadCount).toBe(2);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
});
});
it('clears transient message state', () => {
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
useMessageStore.getState().clearLastMessage();
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
expect(getSpy).not.toHaveBeenCalled();
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
// Simulates sign-out/account switch while the request is still in flight.
usePacksStore.getState().clearPacks();
release({ enabledPackIds: ['stale-pack'] });
await staleFetch;
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
await usePacksStore.getState().fetchEnabledPacks();
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
});
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchPackProgress();
usePacksStore.getState().clearPacks();
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
await staleFetch;
expect(usePacksStore.getState().packProgress.size).toBe(0);
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
await usePacksStore.getState().fetchPackProgress();
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
});
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
usePacksStore.setState({ enabledPackIds: [] } as any);
let release: (value: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
release({ enabledPackIds: ['pack1'] });
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
let reject: (reason: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
reject(new Error('offline'));
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
// The rollback would have re-added pack1, which this couple never enabled.
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
});
});
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useResponsesStore.getState().fetchResponses(true);
// Simulates sign-out/account switch while the request is still in flight.
useResponsesStore.getState().clearResponses();
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
await staleFetch;
expect(useResponsesStore.getState().responses).toEqual([]);
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
});
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
// Question ids come from the shared catalogue, so the same id names a real
// row in both accounts' lists: a stale edit would land on one of them.
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
});
let resolve!: (value: unknown) => void;
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
useResponsesStore.getState().clearResponses();
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
useResponsesStore.setState({ responses: next });
resolve({ success: true, new_match: { id: 'm2' } });
// The caller still hears what the server did for the account that asked.
await expect(staleEdit).resolves.toMatchObject({ success: true });
expect(useResponsesStore.getState().responses).toBe(next);
expect(useResponsesStore.getState().responses[0]).toMatchObject({
id: 'bob', answer: 'no', has_match: false,
});
expect(fetchMatches).not.toHaveBeenCalled();
});
});
describe('state controls', () => {
it('sets and toggles grouping options', () => {
useResponsesStore.getState().setGroupBy('pack');
@@ -0,0 +1,58 @@
import { apiClient } from '@/lib/apiClient';
import { useAuthStore } from '@/store/authStore';
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
const apiGet = apiClient.get as jest.Mock;
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
return {
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
you_answered_today: true, partner_answered_today: false,
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
...overrides,
};
}
describe('streakStore account isolation (generation tokens)', () => {
beforeEach(() => {
jest.clearAllMocks();
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useStreakStore.getState().fetchStreak();
// Simulates sign-out/account switch while the request is still in flight.
useStreakStore.getState().clearStreak();
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
await staleFetch;
expect(useStreakStore.getState().streak).toBeNull();
expect(useStreakStore.getState().loadedAt).toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
expect(useStreakStore.getState().loadedAt).not.toBeNull();
});
it('refreshStreak silently reloads only an already-loaded streak', async () => {
await useStreakStore.getState().refreshStreak();
expect(apiGet).not.toHaveBeenCalled();
apiGet.mockResolvedValueOnce({ streak: streak() });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().loadedAt).not.toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
await useStreakStore.getState().refreshStreak();
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
});
});