mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user