feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

@@ -0,0 +1,49 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useDrawingSync } from '@/features/live-draw/hooks/useDrawingSync';
import { appDataApi } from '@/lib/appDataApi';
import { ApiError } from '@/lib/apiClient';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { getLiveDraw: jest.fn(), putLiveDraw: jest.fn() } }));
jest.mock('@/features/live-draw/utils/widgetBridge', () => ({ updateWidget: jest.fn() }));
const userId = '11111111-1111-4111-8111-111111111111';
const stroke = { id: 's1', userId, points: [{ x: 0.2, y: 0.3 }], color: '#fff', width: 2, timestamp: 1, isEraser: false };
describe('useDrawingSync API polling', () => {
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('loads, polls by revision, and persists complete drawing state', async () => {
(appDataApi.getLiveDraw as jest.Mock)
.mockResolvedValueOnce({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId })
.mockResolvedValueOnce({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
(appDataApi.putLiveDraw as jest.Mock).mockResolvedValue({ strokes: [stroke], revision: 3, updated_at: 'three', updated_by: userId });
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(),
onStrokeEnd: jest.fn(), onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(), onLoadStrokes,
}));
await waitFor(() => expect(onLoadStrokes).toHaveBeenCalledWith([]));
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
await waitFor(() => expect(onLoadStrokes).toHaveBeenCalledWith([stroke]));
await act(async () => result.current.persistStrokes([stroke]));
expect(appDataApi.putLiveDraw).toHaveBeenCalledWith([stroke], 2);
});
it('merges a concurrent partner stroke and retries from the returned revision', async () => {
const partnerStroke = { ...stroke, id: 'partner', userId: '22222222-2222-4222-8222-222222222222' };
const mine = { ...stroke, id: 'mine' };
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [stroke], revision: 1, updated_at: 'one', updated_by: userId });
(appDataApi.putLiveDraw as jest.Mock)
.mockRejectedValueOnce(new ApiError('conflict', 409, { current_state: { strokes: [stroke, partnerStroke], revision: 2, updated_at: 'two', updated_by: partnerStroke.userId } }))
.mockResolvedValueOnce({ strokes: [stroke, partnerStroke, mine], revision: 3, updated_at: 'three', updated_by: userId });
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(),
onStrokeEnd: jest.fn(), onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(), onLoadStrokes,
}));
await waitFor(() => expect(onLoadStrokes).toHaveBeenCalledWith([stroke]));
await act(async () => result.current.persistStrokes([stroke, mine]));
expect(appDataApi.putLiveDraw).toHaveBeenNthCalledWith(1, [stroke, mine], 1);
expect(appDataApi.putLiveDraw).toHaveBeenNthCalledWith(2, [stroke, partnerStroke, mine], 2);
expect(onLoadStrokes).toHaveBeenLastCalledWith([stroke, partnerStroke, mine]);
});
});
@@ -1,197 +1,68 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useFeatureInterest } from '@/hooks/useFeatureInterest';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
maybeSingle: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('useFeatureInterest', () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: 'user1' } } as any);
});
it('fetches initial interest status as true when interest exists', async () => {
const query = createThenableQuery({ data: { id: 'interest1' } });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
expect(query.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(query.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('fetches initial interest status as false when no interest exists', async () => {
const query = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
});
it('returns isAuthenticated false when user is null', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isInterested).toBe(false);
expect(supabase.from).not.toHaveBeenCalled();
});
it('adds interest when toggling from not interested', async () => {
// Initial query returns no interest
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(insertQuery); // Insert on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(true);
expect(insertQuery.insert).toHaveBeenCalledWith({
user_id: 'user1',
feature_name: 'dares',
});
});
it('removes interest when toggling from interested', async () => {
// Initial query returns existing interest
const initialQuery = createThenableQuery({ data: { id: 'interest1' } });
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(deleteQuery); // Delete on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(false);
expect(deleteQuery.delete).toHaveBeenCalled();
expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(deleteQuery.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('reverts optimistic update on error', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: new Error('Insert failed') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
// Should revert to original state after error
expect(result.current.isInterested).toBe(false);
});
it('does not toggle when user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
// Should not make any API calls
expect(supabase.from).not.toHaveBeenCalled();
});
it('isToggling is false when not toggling', async () => {
const initialQuery = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValueOnce(initialQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isToggling).toBe(false);
});
it('isToggling is false after toggle completes', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isToggling).toBe(false);
});
});
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useFeatureInterest } from "@/hooks/useFeatureInterest";
import { apiClient } from "@/lib/apiClient";
import { useAuthStore } from "@/store/authStore";
describe("useFeatureInterest", () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: "user1" } } as any);
});
it("reads interest state from the API", async () => {
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "live draw", interested: true });
const { result } = renderHook(() => useFeatureInterest("live draw"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(getSpy).toHaveBeenCalledWith("/v1/me/feature-interests/live%20draw");
expect(result.current.isInterested).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
});
it("does not call the API without a product user", async () => {
useAuthStore.setState({ user: null } as any);
const getSpy = jest.spyOn(apiClient, "get");
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(false);
expect(getSpy).not.toHaveBeenCalled();
});
it("adds interest through PUT", async () => {
jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "dares", interested: false });
const putSpy = jest.spyOn(apiClient, "put").mockResolvedValueOnce({ feature: "dares", interested: true });
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => result.current.toggleInterest());
expect(putSpy).toHaveBeenCalledWith("/v1/me/feature-interests/dares");
expect(result.current.isInterested).toBe(true);
expect(result.current.isToggling).toBe(false);
});
it("removes interest through DELETE", async () => {
jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "dares", interested: true });
const deleteSpy = jest.spyOn(apiClient, "delete").mockResolvedValueOnce({ feature: "dares", interested: false });
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => result.current.toggleInterest());
expect(deleteSpy).toHaveBeenCalledWith("/v1/me/feature-interests/dares");
expect(result.current.isInterested).toBe(false);
});
it("reverts its optimistic state when the API fails", async () => {
jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "dares", interested: false });
jest.spyOn(apiClient, "put").mockRejectedValueOnce(new Error("API unavailable"));
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => result.current.toggleInterest());
expect(result.current.isInterested).toBe(false);
expect(result.current.isToggling).toBe(false);
});
});
@@ -1,7 +1,9 @@
import { Alert } from 'react-native';
import { renderHook, act } from '@testing-library/react-native';
import { useMediaUpload } from '@/features/chat/hooks/useMediaUpload';
import { supabase } from '@/lib/supabase';
import { uploadMedia } from '@/lib/mediaApi';
jest.mock('@/lib/mediaApi', () => ({ uploadMedia: jest.fn() }));
describe('useMediaUpload', () => {
beforeEach(() => {
@@ -13,20 +15,8 @@ describe('useMediaUpload', () => {
jest.restoreAllMocks();
});
it('uploads image and inserts message record', async () => {
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
jest.spyOn(Math, 'random').mockReturnValue(0.123456);
const upload: any = jest.fn(async () => ({ error: null }));
(supabase.storage.from as jest.Mock).mockReturnValue({ upload });
const insert: any = jest.fn(async () => ({ error: null }));
(supabase.from as jest.Mock).mockImplementation((table: string) => {
if (table === 'messages') {
return { insert };
}
return {};
});
it('uploads image and creates its message through the API', async () => {
(uploadMedia as jest.Mock).mockResolvedValue({ reference: 'media:11111111-1111-4111-8111-111111111111' });
const { result } = renderHook(() => useMediaUpload('match1', 'me'));
@@ -34,18 +24,8 @@ describe('useMediaUpload', () => {
await result.current.uploadMedia('file://photo.jpg', 'image');
});
expect(upload).toHaveBeenCalledTimes(1);
const [path, body, options] = (upload.mock.calls as any[])[0] as any[];
expect(path).toMatch(/^match1\/1700000000000_/);
expect(options).toEqual({ contentType: 'image/jpeg', upsert: false });
expect(body).toBeInstanceOf(ArrayBuffer);
expect(insert).toHaveBeenCalledTimes(1);
const insertPayload = (insert.mock.calls as any[])[0][0] as any;
expect(insertPayload.match_id).toBe('match1');
expect(insertPayload.user_id).toBe('me');
expect(insertPayload.media_type).toBe('image');
expect(insertPayload.media_path).toBe(path);
expect(uploadMedia).toHaveBeenCalledWith('file://photo.jpg', {
kind: 'chat', mimeType: 'image/jpeg', matchId: 'match1',
});
});
});
@@ -0,0 +1,56 @@
import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native';
import * as Haptics from 'expo-haptics';
import { useMessageActions } from '@/features/chat/hooks/useMessageActions';
import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { deleteForSelf: jest.fn(), deleteForEveryone: jest.fn() } }));
describe('useMessageActions', () => {
beforeEach(() => {
jest.clearAllMocks();
(chatApi.deleteForSelf as jest.Mock).mockResolvedValue({ deleted: true });
(chatApi.deleteForEveryone as jest.Mock).mockResolvedValue({ deleted: true });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
});
it('guards self deletion without an authenticated user', async () => {
const { result } = renderHook(() => useMessageActions({ userId: undefined }));
await act(async () => result.current.deleteForSelf('message'));
expect(chatApi.deleteForSelf).not.toHaveBeenCalled();
});
it('delegates both deletion scopes to authenticated API routes', async () => {
const { result } = renderHook(() => useMessageActions({ userId: 'me' }));
await act(async () => result.current.deleteForSelf('message'));
await act(async () => result.current.deleteForEveryone('message'));
expect(chatApi.deleteForSelf).toHaveBeenCalledWith('message');
expect(chatApi.deleteForEveryone).toHaveBeenCalledWith('message');
});
it('converts deletion API failures into user-visible errors', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(chatApi.deleteForSelf as jest.Mock).mockRejectedValue(new Error('forbidden'));
const { result } = renderHook(() => useMessageActions({ userId: 'me' }));
await act(async () => result.current.deleteForSelf('message'));
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to delete message');
});
it('offers report for a partner message and ignores deleted messages', () => {
const onReport = jest.fn();
const actionSheet = jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation((_options, callback) => callback(2));
const haptic = jest.spyOn(Haptics, 'impactAsync').mockResolvedValue();
const { result } = renderHook(() => useMessageActions({ userId: 'me', onReport }));
if (Platform.OS === 'ios') {
act(() => result.current.showDeleteOptions({ id: 'message', deleted_at: null } as any, false));
expect(actionSheet).toHaveBeenCalled();
expect(onReport).toHaveBeenCalledWith(expect.objectContaining({ id: 'message' }));
}
jest.clearAllMocks();
act(() => result.current.showDeleteOptions({ id: 'deleted', deleted_at: 'now' } as any, false));
expect(haptic).not.toHaveBeenCalled();
expect(onReport).not.toHaveBeenCalled();
});
});
@@ -1,160 +1,90 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useMessageSubscription } from '@/hooks/useMessageSubscription';
import { supabase } from '@/lib/supabase';
import { chatApi } from '@/lib/chatApi';
type ThenableResult = { data?: any; error?: any; count?: any };
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
function createThenableQuery(result: ThenableResult) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
update: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
return query;
}
describe('useMessageSubscription', () => {
describe('useMessageSubscription API polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
afterEach(() => {
jest.useRealTimers();
});
it('fetches initial messages and marks partner messages read', async () => {
const partnerMessage = {
id: 'm1',
match_id: 'match1',
user_id: 'partner',
created_at: '2024-01-01T00:00:00.000Z',
delivered_at: null,
read_at: null,
};
const fetchQuery = createThenableQuery({ data: [partnerMessage] });
const updateQuery = createThenableQuery({ data: null });
const deletionsQuery = createThenableQuery({ data: [] });
const fromMock = jest.fn((table: string) => {
if (table === 'message_deletions') return deletionsQuery;
if (table === 'messages') {
// If it's the first call to messages, it's fetch, otherwise update.
// But simplified for the test logic:
return fetchQuery;
}
return createThenableQuery({ data: null });
});
// Use mockReturnValueOnce to handle the sequence:
// 1. message_deletions (select)
// 2. messages (select)
// 3. messages (update)
const fromMockSequence = jest.fn()
.mockReturnValueOnce(deletionsQuery) // message_deletions select
.mockReturnValueOnce(fetchQuery) // messages select
.mockReturnValueOnce(updateQuery); // messages update
(supabase as any).from = fromMockSequence;
const channelMock: any = {
on: jest.fn(() => channelMock),
subscribe: jest.fn(() => channelMock),
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useMessageSubscription({ matchId: 'match1', userId: 'me' })
it('fails closed without both a match and authenticated user', async () => {
const { result, 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();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fromMockSequence).toHaveBeenCalledWith('messages');
expect(updateQuery.update).toHaveBeenCalledWith(
expect.objectContaining({
delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
read_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
})
);
expect(updateQuery.in).toHaveBeenCalledWith('id', ['m1']);
expect(result.current.messages[0].read_at).toMatch(/^2024-01-01T00:00:00\./);
expect(result.current.messages[0].delivered_at).toMatch(/^2024-01-01T00:00:00\./);
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('prepends inserted message and marks it read when focused', async () => {
const fetchQuery = createThenableQuery({ data: [] });
const updateQuery = createThenableQuery({ data: null });
const fromMock = jest.fn((table: string) => {
if (table === 'messages') return updateQuery;
return createThenableQuery({ data: null });
});
// First call should be fetch.
(supabase as any).from = jest
.fn()
.mockReturnValueOnce(fetchQuery)
.mockImplementation(fromMock);
let insertHandler: ((payload: any) => Promise<void> | void) | null = null;
const channelMock: any = {
on: jest.fn((_type: string, filter: any, cb: any) => {
if (filter?.event === 'INSERT') {
insertHandler = cb;
}
return channelMock;
}),
subscribe: jest.fn(() => channelMock),
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useMessageSubscription({ matchId: 'match1', userId: 'me' })
);
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');
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.isFocusedRef.current = true;
});
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();
});
const newMessage = {
id: 'm2',
match_id: 'match1',
user_id: 'partner',
created_at: '2024-01-01T00:00:01.000Z',
delivered_at: null,
read_at: null,
};
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' }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
expect(insertHandler).toBeTruthy();
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: [] }));
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
});
await act(async () => {
await insertHandler?.({ new: newMessage });
});
expect(updateQuery.update).toHaveBeenCalledWith(
expect.objectContaining({
delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
read_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
})
);
expect(result.current.messages[0].id).toBe('m2');
expect(result.current.messages[0].read_at).toMatch(/^2024-01-01T00:00:00\./);
expect(result.current.messages[0].delivered_at).toMatch(/^2024-01-01T00:00:00\./);
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
});
});
@@ -1,78 +1,79 @@
import { renderHook, act } from '@testing-library/react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useTypingIndicator } from '@/hooks/useTypingIndicator';
import { supabase } from '@/lib/supabase';
import { chatApi } from '@/lib/chatApi';
describe('useTypingIndicator', () => {
jest.mock('@/lib/chatApi', () => ({ chatApi: { getTyping: jest.fn(), setTyping: jest.fn() } }));
describe('useTypingIndicator API polling', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T12:00:00Z'));
(chatApi.getTyping as jest.Mock).mockResolvedValue({ typing: false, expires_at: null });
(chatApi.setTyping as jest.Mock).mockResolvedValue({ typing: true });
});
afterEach(() => jest.useRealTimers());
it('derives the match id and polls partner typing state', async () => {
(chatApi.getTyping as jest.Mock).mockResolvedValueOnce({ typing: true, expires_at: '2026-08-27T12:00:04Z' });
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me' }));
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
expect(chatApi.getTyping).toHaveBeenCalledWith('match1');
});
afterEach(() => {
jest.useRealTimers();
it('clears typing when the server state expires on a later poll', async () => {
(chatApi.getTyping as jest.Mock)
.mockResolvedValueOnce({ typing: true, expires_at: 'soon' })
.mockResolvedValueOnce({ typing: false, expires_at: null });
const { result } = renderHook(() => useTypingIndicator({ channelName: 'unused', matchId: 'match1', userId: 'me', pollInterval: 500 }));
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('toggles partnerTyping on broadcast and auto-clears', async () => {
let broadcastHandler: ((payload: any) => void) | null = null;
const channelMock: any = {
on: jest.fn((_type: string, _filter: any, cb: any) => {
broadcastHandler = cb;
return channelMock;
}),
subscribe: jest.fn(() => channelMock),
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useTypingIndicator({
channelName: 'typing:test',
userId: 'me',
typingTimeout: 3000,
})
);
expect(result.current.partnerTyping).toBe(false);
expect(broadcastHandler).toBeTruthy();
act(() => {
broadcastHandler?.({ payload: { userId: 'partner' } });
});
expect(result.current.partnerTyping).toBe(true);
act(() => {
jest.advanceTimersByTime(3000);
});
it('fails closed on polling errors', async () => {
(chatApi.getTyping as jest.Mock).mockRejectedValueOnce(new Error('network'));
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me' }));
await act(async () => { await Promise.resolve(); });
expect(result.current.partnerTyping).toBe(false);
});
it('sends typing broadcast for current user', () => {
const send = jest.fn();
const channelMock: any = {
on: jest.fn(() => channelMock),
subscribe: jest.fn(() => channelMock),
send,
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useTypingIndicator({ channelName: 'typing:test', userId: 'me' })
it('does not poll without a user or while unfocused and clears existing state on blur', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { focused: boolean; userId: string | undefined }>(
({ focused, userId }) => useTypingIndicator({ channelName: 'chat:match1', userId, isFocused: focused }),
{ initialProps: { focused: true, userId: 'me' as string | undefined } },
);
await act(async () => { await Promise.resolve(); });
act(() => result.current.clearTypingIndicator());
rerender({ focused: false, userId: 'me' });
expect(result.current.partnerTyping).toBe(false);
const callsAtBlur = (chatApi.getTyping as jest.Mock).mock.calls.length;
act(() => { jest.advanceTimersByTime(3_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsAtBlur);
rerender({ focused: true, userId: undefined });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsAtBlur);
});
act(() => {
result.current.sendTypingEvent();
});
it('throttles writes and allows another event after the interval', () => {
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', throttleInterval: 2_000 }));
act(() => { result.current.sendTypingEvent(); result.current.sendTypingEvent(); });
expect(chatApi.setTyping).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(2_000); result.current.sendTypingEvent(); });
expect(chatApi.setTyping).toHaveBeenCalledTimes(2);
});
expect(send).toHaveBeenCalledWith({
type: 'broadcast',
event: 'typing',
payload: { userId: 'me' },
});
it('does not write typing state without an authenticated user', () => {
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: undefined }));
act(() => result.current.sendTypingEvent());
expect(chatApi.setTyping).not.toHaveBeenCalled();
});
it('clears polling on unmount', async () => {
const { unmount } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500 }));
await act(async () => { await Promise.resolve(); });
unmount();
const calls = (chatApi.getTyping as jest.Mock).mock.calls.length;
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
});