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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user