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