mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18: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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { accountOperationsApi } from '../../lib/accountOperationsApi';
|
||||
import { apiClient } from '../../lib/apiClient';
|
||||
|
||||
jest.mock('../../lib/apiClient', () => ({
|
||||
apiClient: {
|
||||
delete: jest.fn(),
|
||||
post: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('accountOperationsApi', () => {
|
||||
it('routes destructive and subscription operations through the standalone API', async () => {
|
||||
await accountOperationsApi.deleteRelationship();
|
||||
await accountOperationsApi.resetProgress();
|
||||
await accountOperationsApi.deleteAccount();
|
||||
await accountOperationsApi.syncSubscription();
|
||||
|
||||
expect(apiClient.delete).toHaveBeenNthCalledWith(1, '/v1/couple/data');
|
||||
expect(apiClient.delete).toHaveBeenNthCalledWith(2, '/v1/couple/progress');
|
||||
expect(apiClient.delete).toHaveBeenNthCalledWith(3, '/v1/me');
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/v1/me/subscription/sync');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiError, apiRequest } from "@/lib/apiClient";
|
||||
import { authClient } from "@/lib/authClient";
|
||||
|
||||
function response(status: number, body?: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
text: jest.fn(async () => body === undefined ? "" : JSON.stringify(body)),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("apiClient", () => {
|
||||
const fetchMock = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = fetchMock;
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
|
||||
data: { session: { access_token: "hosted-auth-token" } },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the current Supabase access token", async () => {
|
||||
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
|
||||
await apiRequest("/v1/me");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3003/v1/me",
|
||||
expect.objectContaining({ headers: expect.any(Headers) }),
|
||||
);
|
||||
const headers = fetchMock.mock.calls[0][1].headers as Headers;
|
||||
expect(headers.get("Authorization")).toBe("Bearer hosted-auth-token");
|
||||
});
|
||||
|
||||
it("fails closed when there is no authenticated session", async () => {
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
await expect(apiRequest("/v1/me")).rejects.toMatchObject({ status: 401 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves API status and error details", async () => {
|
||||
fetchMock.mockResolvedValue(response(403, {
|
||||
error: { message: "Couple access denied", code: "FORBIDDEN" },
|
||||
}));
|
||||
|
||||
await expect(apiRequest("/v1/me")).rejects.toEqual(
|
||||
expect.objectContaining<ApiError>({
|
||||
name: "ApiError",
|
||||
message: "Couple access denied",
|
||||
status: 403,
|
||||
details: { error: { message: "Couple access denied", code: "FORBIDDEN" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { appDataApi } from '@/lib/appDataApi';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
|
||||
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn(), post: jest.fn(), patch: jest.fn(), put: jest.fn() } }));
|
||||
|
||||
describe('appDataApi', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
it('routes pack and match reads through the authenticated Node API', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({});
|
||||
await appDataApi.packQuestions('pack id'); await appDataApi.packTeaser('pack id'); await appDataApi.matchContext('match id');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/packs/pack%20id/questions');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/packs/pack%20id/teaser');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match%20id/context');
|
||||
});
|
||||
it('persists media view and drawing state without accepting a couple ID', async () => {
|
||||
(apiClient.patch as jest.Mock).mockResolvedValue({}); (apiClient.put as jest.Mock).mockResolvedValue({});
|
||||
await appDataApi.markMediaViewed('m1', null); await appDataApi.putLiveDraw([], 7);
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/v1/messages/m1/media-viewed', { expires_at: null });
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/v1/live-draw', { strokes: [], base_revision: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { chatApi } from '@/lib/chatApi';
|
||||
|
||||
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() } }));
|
||||
|
||||
describe('chatApi', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('uses match-scoped message and typing routes', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [] });
|
||||
(apiClient.post as jest.Mock).mockResolvedValue({ message: { id: 'message' } });
|
||||
(apiClient.put as jest.Mock).mockResolvedValue({ typing: true });
|
||||
|
||||
await chatApi.listMessages('match/id');
|
||||
await chatApi.sendText('match/id', 'hello');
|
||||
await chatApi.markRead('match/id');
|
||||
await chatApi.setTyping('match/id');
|
||||
await chatApi.getTyping('match/id');
|
||||
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match%2Fid/messages');
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/v1/matches/match%2Fid/messages', { content: 'hello' });
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/read');
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/v1/matches/match%2Fid/typing');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
|
||||
});
|
||||
|
||||
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
|
||||
(apiClient.post as jest.Mock).mockResolvedValue({});
|
||||
(apiClient.delete as jest.Mock).mockResolvedValue({ deleted: true });
|
||||
|
||||
await chatApi.unread();
|
||||
await chatApi.markDelivered('message/id');
|
||||
await chatApi.deleteForSelf('message/id');
|
||||
await chatApi.deleteForEveryone('message/id');
|
||||
await chatApi.report('message/id', 'harassment');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/v1/chat/unread');
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/v1/messages/message%2Fid/delivered');
|
||||
expect(apiClient.delete).toHaveBeenNthCalledWith(1, '/v1/messages/message%2Fid?scope=self');
|
||||
expect(apiClient.delete).toHaveBeenNthCalledWith(2, '/v1/messages/message%2Fid?scope=everyone');
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/v1/messages/message%2Fid/reports', { reason: 'harassment' });
|
||||
});
|
||||
|
||||
it('propagates API authorization and tenancy errors', async () => {
|
||||
const forbidden = new Error('match not found');
|
||||
(apiClient.get as jest.Mock).mockRejectedValue(forbidden);
|
||||
await expect(chatApi.listMessages('foreign-match')).rejects.toBe(forbidden);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { apiRequest } from "../../lib/apiClient";
|
||||
import { coupleApi } from "../../lib/coupleApi";
|
||||
|
||||
jest.mock("../../lib/apiClient", () => ({ apiRequest: jest.fn() }));
|
||||
|
||||
const request = apiRequest as jest.Mock;
|
||||
|
||||
describe("coupleApi", () => {
|
||||
beforeEach(() => request.mockResolvedValue({ success: true }));
|
||||
|
||||
it("uses the authenticated standalone API for the complete pairing lifecycle", async () => {
|
||||
await coupleApi.getState();
|
||||
await coupleApi.create();
|
||||
await coupleApi.join("ABCD2345");
|
||||
await coupleApi.cancel();
|
||||
|
||||
expect(request.mock.calls).toEqual([
|
||||
["/v1/couple"],
|
||||
["/v1/couple", { method: "POST", body: {} }],
|
||||
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
|
||||
["/v1/couple", { method: "DELETE" }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { mediaId, getMediaUrl } from '@/lib/mediaApi';
|
||||
|
||||
describe('mediaApi references', () => {
|
||||
it('extracts only UUID media references', () => {
|
||||
expect(mediaId('media:22222222-2222-4222-8222-222222222222')).toBe('22222222-2222-4222-8222-222222222222');
|
||||
expect(mediaId('../../private/file')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves legacy HTTPS media URLs usable during migration', async () => {
|
||||
const url = 'https://legacy.example/media/photo.jpg';
|
||||
await expect(getMediaUrl(url)).resolves.toMatchObject({ url });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { apiClient } from '../../lib/apiClient';
|
||||
import { profileSettingsApi } from '../../lib/profileSettingsApi';
|
||||
|
||||
jest.mock('../../lib/apiClient', () => ({
|
||||
apiClient: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('profileSettingsApi', () => {
|
||||
it('routes profile, activity, preference, and feedback operations through the standalone API', async () => {
|
||||
await profileSettingsApi.updateProfile({ name: 'Alice' });
|
||||
await profileSettingsApi.updateLastActive();
|
||||
await profileSettingsApi.getNotificationPreferences();
|
||||
await profileSettingsApi.updateNotificationPreference('messages_enabled', false);
|
||||
await profileSettingsApi.submitFeedback({ type: 'bug', title: 'Title', description: 'Details' });
|
||||
|
||||
expect(apiClient.patch).toHaveBeenNthCalledWith(1, '/v1/me/profile', { name: 'Alice' });
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/v1/me/activity');
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/v1/me/notification-preferences');
|
||||
expect(apiClient.patch).toHaveBeenNthCalledWith(2, '/v1/me/notification-preferences', {
|
||||
key: 'messages_enabled', value: false,
|
||||
});
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/v1/feedback', {
|
||||
type: 'bug', title: 'Title', description: 'Details',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,35 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { usePacksStore } from '@/store/packsStore';
|
||||
import { useMessageStore } from '@/store/messageStore';
|
||||
import { useSubscriptionStore } from '@/store/subscriptionStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||
import { authClient } from "@/lib/authClient";
|
||||
import { coupleApi } from "@/lib/coupleApi";
|
||||
import { profileSettingsApi } from "@/lib/profileSettingsApi";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useMatchStore } from "@/store/matchStore";
|
||||
import { useMessageStore } from "@/store/messageStore";
|
||||
import { usePacksStore } from "@/store/packsStore";
|
||||
import { useSubscriptionStore } from "@/store/subscriptionStore";
|
||||
import type { Profile } from "@/types";
|
||||
|
||||
describe('authStore', () => {
|
||||
const profile = {
|
||||
id: "me",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
avatar_url: null,
|
||||
push_token: null,
|
||||
is_premium: false,
|
||||
couple_id: null,
|
||||
gender: null,
|
||||
show_explicit_content: false,
|
||||
max_intensity: 3,
|
||||
hide_nsfw: true,
|
||||
onboarding_completed: false,
|
||||
onboarding_version: 0,
|
||||
created_at: "2026-08-27T00:00:00.000Z",
|
||||
updated_at: "2026-08-27T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("authStore", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
couple: null,
|
||||
@@ -17,69 +38,93 @@ describe('authStore', () => {
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
} as any);
|
||||
|
||||
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
||||
usePacksStore.setState({ enabledPackIds: ['p1'] } as any);
|
||||
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'msg1' }, activeMatchId: 'match1' } as any);
|
||||
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
|
||||
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
|
||||
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: "msg1" }, activeMatchId: "match1" } as any);
|
||||
useSubscriptionStore.setState({ subscription: { isProUser: true } } as any);
|
||||
});
|
||||
|
||||
it('sets unauthenticated when no session exists', async () => {
|
||||
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({ data: { session: null } });
|
||||
it("sets unauthenticated when no hosted auth session exists", async () => {
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({ data: { session: null } });
|
||||
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.isAnonymous).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets isAnonymous from auth user', async () => {
|
||||
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||
data: { session: { user: { id: 'me' } } },
|
||||
it("bootstraps and reads the profile through GET /v1/me", async () => {
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||
data: { session: { user: { id: "me", is_anonymous: true } } },
|
||||
});
|
||||
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile });
|
||||
|
||||
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
|
||||
data: { user: { id: 'me', is_anonymous: true } },
|
||||
error: null,
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
expect(getSpy).toHaveBeenCalledWith("/v1/me");
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: profile,
|
||||
isAuthenticated: true,
|
||||
isAnonymous: true,
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
const profileQuery: any = {
|
||||
select: jest.fn(() => profileQuery),
|
||||
eq: jest.fn(() => profileQuery),
|
||||
maybeSingle: jest.fn(async () => ({ data: { id: 'me', couple_id: null } })),
|
||||
it("reads couple and partner state through the standalone API", async () => {
|
||||
const pairedProfile = { ...profile, couple_id: "couple-1" };
|
||||
const couple = {
|
||||
id: "couple-1",
|
||||
invite_code: "ABCD2345",
|
||||
created_at: "2026-08-27T00:00:00.000Z",
|
||||
};
|
||||
const partner: Profile = {
|
||||
...profile,
|
||||
id: "partner",
|
||||
name: "Partner",
|
||||
couple_id: "couple-1",
|
||||
max_intensity: 3,
|
||||
};
|
||||
useAuthStore.setState({ user: pairedProfile } as any);
|
||||
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner });
|
||||
|
||||
(supabase.from as jest.Mock).mockReturnValue(profileQuery);
|
||||
await useAuthStore.getState().fetchCouple();
|
||||
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
const state = useAuthStore.getState();
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.isAnonymous).toBe(true);
|
||||
expect(stateSpy).toHaveBeenCalledWith();
|
||||
expect(useAuthStore.getState()).toMatchObject({ couple, partner });
|
||||
});
|
||||
|
||||
it('clears stores and signs out when session invalid', async () => {
|
||||
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||
data: { session: { user: { id: 'me' } } },
|
||||
});
|
||||
it("updates activity through the standalone API", async () => {
|
||||
useAuthStore.setState({ user: profile } as any);
|
||||
const activitySpy = jest.spyOn(profileSettingsApi, "updateLastActive").mockResolvedValueOnce();
|
||||
|
||||
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
|
||||
data: { user: null },
|
||||
error: new Error('invalid'),
|
||||
});
|
||||
await useAuthStore.getState().updateLastActive();
|
||||
|
||||
(supabase.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
|
||||
expect(activitySpy).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
|
||||
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||
data: { session: { user: { id: "me" } } },
|
||||
});
|
||||
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
|
||||
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
|
||||
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
expect(useAuthStore.getState().user).toBeNull();
|
||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||
|
||||
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||
expect(useAuthStore.getState()).toMatchObject({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
});
|
||||
expect(useMatchStore.getState().matches).toEqual([]);
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
|
||||
expect(authClient.auth.signOut).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,485 +1,233 @@
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { appDataApi } from '@/lib/appDataApi';
|
||||
import { ApiError, apiClient } from '@/lib/apiClient';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
|
||||
function createThenableQuery(result: any) {
|
||||
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),
|
||||
range: jest.fn(() => query),
|
||||
update: jest.fn(() => query),
|
||||
insert: jest.fn(() => query),
|
||||
delete: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
jest.mock('@/lib/apiClient', () => {
|
||||
const actual = jest.requireActual('@/lib/apiClient');
|
||||
return { ...actual, apiClient: { get: jest.fn(), patch: jest.fn(), put: jest.fn() } };
|
||||
});
|
||||
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { sendNudge: jest.fn(), nudgeStatus: jest.fn() } }));
|
||||
|
||||
describe('matchStore', () => {
|
||||
const match = (id: string, is_new = true, unreadCount = 0) => ({
|
||||
id, couple_id: 'couple', question_id: `q-${id}`, match_type: 'yes_yes', is_new,
|
||||
created_at: '2026-01-01T00:00:00.000Z', unreadCount,
|
||||
} as any);
|
||||
const pending = (id: string) => ({ id, question: { id: `q-${id}` }, partnerAnsweredAt: 'now' } as any);
|
||||
|
||||
describe('matchStore API behavior', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useMatchStore.setState({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
error: null,
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set<string>(),
|
||||
showArchived: false,
|
||||
isLoadingArchived: false,
|
||||
});
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-08-27T12:00:00Z'));
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple' } } as any);
|
||||
useMatchStore.getState().clearMatches();
|
||||
(apiClient.patch as jest.Mock).mockResolvedValue({ success: true });
|
||||
(apiClient.put as jest.Mock).mockResolvedValue({ success: true });
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
describe('fetchMatches', () => {
|
||||
it('sorts matches with unread messages first', async () => {
|
||||
const matches = [
|
||||
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
|
||||
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
|
||||
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
|
||||
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
const countQuery = createThenableQuery({ count: 2 });
|
||||
const matchesQuery = createThenableQuery({ data: matches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery) // archived matches
|
||||
.mockReturnValueOnce(countQuery) // count
|
||||
.mockReturnValueOnce(matchesQuery) // matches
|
||||
.mockReturnValueOnce(responsesQuery) // responses
|
||||
.mockReturnValueOnce(unreadQuery); // messages
|
||||
|
||||
it('loads the first API page and retains server ordering and counts', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [match('unread', false, 2), match('new')], totalCount: 2 });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].id).toBe('a');
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/v1/matches?page=0&limit=20');
|
||||
expect(useMatchStore.getState()).toMatchObject({ totalCount: 2, newMatchesCount: 1, page: 1, hasMore: false, isLoading: false });
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['unread', 'new']);
|
||||
});
|
||||
|
||||
it('sets empty state when user has no couple', async () => {
|
||||
it('fails closed for an unpaired user', async () => {
|
||||
useMatchStore.setState({ matches: [match('old')], newMatchesCount: 1 });
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toEqual([]);
|
||||
expect(state.newMatchesCount).toBe(0);
|
||||
expect(state.totalCount).toBe(0);
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
expect(useMatchStore.getState()).toMatchObject({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
|
||||
});
|
||||
|
||||
it('prevents concurrent loading', async () => {
|
||||
it('guards concurrent loads and exhausted pagination', async () => {
|
||||
useMatchStore.setState({ isLoading: true });
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters out archived matches', async () => {
|
||||
const matches = [
|
||||
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: false },
|
||||
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
|
||||
const archivesQuery = createThenableQuery({ data: [{ match_id: 'a' }] });
|
||||
const countQuery = createThenableQuery({ count: 2 });
|
||||
const matchesQuery = createThenableQuery({ data: matches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: [], error: null });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(unreadQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.matches[0].id).toBe('b');
|
||||
// Total count should subtract archived
|
||||
expect(state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles empty matches correctly', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
const matchesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(matchesQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toEqual([]);
|
||||
expect(state.hasMore).toBe(false);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('handles fetch error gracefully', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
// Use createThenableQuery with error data to simulate error response
|
||||
const matchesQuery = createThenableQuery({ data: null, error: { message: 'Network error' } });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(matchesQuery);
|
||||
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.error).toBe('Failed to load matches');
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('paginates matches correctly', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'existing' }] as any,
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
const newMatches = [
|
||||
{ id: 'c', question_id: 'q3', created_at: '2024-01-01T00:00:00.000Z', is_new: false },
|
||||
];
|
||||
|
||||
const matchesQuery = createThenableQuery({ data: newMatches });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
const unreadQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(unreadQuery);
|
||||
|
||||
useMatchStore.setState({ isLoading: false, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(false);
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(2);
|
||||
expect(state.page).toBe(2);
|
||||
it('appends and deduplicates later pages', async () => {
|
||||
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
||||
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: page, totalCount: 25 });
|
||||
await useMatchStore.getState().fetchMatches(false);
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/v1/matches?page=1&limit=20');
|
||||
expect(useMatchStore.getState()).toMatchObject({ page: 2, hasMore: true, isLoadingMore: false, totalCount: 25 });
|
||||
expect(new Set(useMatchStore.getState().matches.map(item => item.id)).size).toBe(20);
|
||||
});
|
||||
|
||||
it('sets an empty terminal state on an empty refresh', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState()).toMatchObject({ matches: [], totalCount: 0, hasMore: false, isLoading: false });
|
||||
});
|
||||
|
||||
it('records API errors and releases loading guards', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
(apiClient.get as jest.Mock).mockRejectedValue(new Error('network'));
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState()).toMatchObject({ error: 'Failed to load matches', isLoading: false, isLoadingMore: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsSeen', () => {
|
||||
it('marks a match as seen and updates count', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [
|
||||
{ id: 'm1', is_new: true } as any,
|
||||
{ id: 'm2', is_new: true } as any,
|
||||
],
|
||||
newMatchesCount: 2,
|
||||
});
|
||||
|
||||
const updateQuery = createThenableQuery({});
|
||||
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
|
||||
|
||||
await useMatchStore.getState().markAsSeen('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].is_new).toBe(false);
|
||||
expect(state.matches[1].is_new).toBe(true);
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
expect(updateQuery.update).toHaveBeenCalledWith({ is_new: false });
|
||||
});
|
||||
it('marks one or all new matches seen and skips an empty batch', async () => {
|
||||
useMatchStore.setState({ matches: [match('m1'), match('m2'), match('old', false)], newMatchesCount: 2 });
|
||||
await useMatchStore.getState().markAsSeen('m1');
|
||||
expect(apiClient.patch).toHaveBeenLastCalledWith('/v1/matches/seen', { ids: ['m1'] });
|
||||
expect(useMatchStore.getState().newMatchesCount).toBe(1);
|
||||
await useMatchStore.getState().markAllAsSeen();
|
||||
expect(apiClient.patch).toHaveBeenLastCalledWith('/v1/matches/seen', { ids: ['m2'] });
|
||||
expect(useMatchStore.getState().newMatchesCount).toBe(0);
|
||||
await useMatchStore.getState().markAllAsSeen();
|
||||
expect(apiClient.patch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('markAllAsSeen', () => {
|
||||
it('marks all matches as seen', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [
|
||||
{ id: 'm1', is_new: true } as any,
|
||||
{ id: 'm2', is_new: true } as any,
|
||||
],
|
||||
newMatchesCount: 2,
|
||||
});
|
||||
|
||||
const updateQuery = createThenableQuery({});
|
||||
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
|
||||
|
||||
await useMatchStore.getState().markAllAsSeen();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches.every(m => !m.is_new)).toBe(true);
|
||||
expect(state.newMatchesCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing when no new matches', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1', is_new: false } as any],
|
||||
newMatchesCount: 0,
|
||||
});
|
||||
|
||||
await useMatchStore.getState().markAllAsSeen();
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
it('adds matches and clamps unread counts at zero', () => {
|
||||
useMatchStore.setState({ matches: [match('m1', false, 1)], totalCount: null, newMatchesCount: 0 });
|
||||
useMatchStore.getState().addMatch(match('m2'));
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['m2', 'm1']);
|
||||
expect(useMatchStore.getState()).toMatchObject({ totalCount: 1, newMatchesCount: 1 });
|
||||
expect(useMatchStore.getState().matches[1]?.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
describe('addMatch', () => {
|
||||
it('adds a new match to the beginning', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' } as any],
|
||||
newMatchesCount: 0,
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
useMatchStore.getState().addMatch({ id: 'm2', is_new: true } as any);
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches[0].id).toBe('m2');
|
||||
expect(state.matches).toHaveLength(2);
|
||||
expect(state.newMatchesCount).toBe(1);
|
||||
expect(state.totalCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMatchUnreadCount', () => {
|
||||
it('increments unread count', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1', unreadCount: 1 } as any],
|
||||
});
|
||||
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', 2);
|
||||
|
||||
expect(useMatchStore.getState().matches[0].unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
it('decrements unread count but not below zero', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1', unreadCount: 1 } as any],
|
||||
});
|
||||
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
|
||||
|
||||
expect(useMatchStore.getState().matches[0].unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing unreadCount', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' } as any],
|
||||
});
|
||||
|
||||
useMatchStore.getState().updateMatchUnreadCount('m1', 1);
|
||||
|
||||
expect(useMatchStore.getState().matches[0].unreadCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearMatches', () => {
|
||||
it('resets all state to initial values', () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' }] as any,
|
||||
newMatchesCount: 5,
|
||||
totalCount: 10,
|
||||
isLoading: true,
|
||||
error: 'some error',
|
||||
page: 3,
|
||||
hasMore: false,
|
||||
isLoadingMore: true,
|
||||
archivedMatches: [{ id: 'a1' }] as any,
|
||||
archivedMatchIds: new Set(['a1']),
|
||||
showArchived: true,
|
||||
isLoadingArchived: true,
|
||||
});
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toEqual([]);
|
||||
expect(state.newMatchesCount).toBe(0);
|
||||
expect(state.totalCount).toBeNull();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
expect(state.page).toBe(0);
|
||||
expect(state.hasMore).toBe(true);
|
||||
expect(state.isLoadingMore).toBe(false);
|
||||
expect(state.archivedMatches).toEqual([]);
|
||||
expect(state.archivedMatchIds.size).toBe(0);
|
||||
expect(state.showArchived).toBe(false);
|
||||
expect(state.isLoadingArchived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveMatch', () => {
|
||||
it('archives a match with optimistic update', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [
|
||||
{ id: 'm1' } as any,
|
||||
{ id: 'm2' } as any,
|
||||
],
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set(),
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
const insertQuery = createThenableQuery({ error: null });
|
||||
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
|
||||
|
||||
describe('archives', () => {
|
||||
it('archives and unarchives optimistically through token-scoped routes', async () => {
|
||||
useMatchStore.setState({ matches: [match('m1'), match('m2')], archivedMatches: [], archivedMatchIds: new Set(), totalCount: 2 });
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.matches[0].id).toBe('m2');
|
||||
expect(state.archivedMatches).toHaveLength(1);
|
||||
expect(state.archivedMatches[0].id).toBe('m1');
|
||||
expect(state.archivedMatchIds.has('m1')).toBe(true);
|
||||
expect(state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does nothing if user not authenticated', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reverts on error', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [{ id: 'm1' } as any],
|
||||
archivedMatches: [],
|
||||
archivedMatchIds: new Set(),
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const insertQuery = createThenableQuery({ error: new Error('DB error') });
|
||||
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
|
||||
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
// Should revert
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.archivedMatchIds.has('m1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveMatch', () => {
|
||||
it('unarchives a match with optimistic update', async () => {
|
||||
useMatchStore.setState({
|
||||
matches: [],
|
||||
archivedMatches: [{ id: 'm1' } as any],
|
||||
archivedMatchIds: new Set(['m1']),
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
const deleteQuery = createThenableQuery({ error: null });
|
||||
(supabase.from as jest.Mock).mockReturnValue(deleteQuery);
|
||||
|
||||
expect(apiClient.put).toHaveBeenLastCalledWith('/v1/matches/m1/archive', { archived: true });
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['m2']);
|
||||
expect(useMatchStore.getState().archivedMatchIds.has('m1')).toBe(true);
|
||||
await useMatchStore.getState().unarchiveMatch('m1');
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.matches).toHaveLength(1);
|
||||
expect(state.matches[0].id).toBe('m1');
|
||||
expect(state.archivedMatches).toHaveLength(0);
|
||||
expect(state.archivedMatchIds.has('m1')).toBe(false);
|
||||
expect(state.totalCount).toBe(1);
|
||||
expect(apiClient.put).toHaveBeenLastCalledWith('/v1/matches/m1/archive', { archived: false });
|
||||
expect(useMatchStore.getState()).toMatchObject({ totalCount: 2, archivedMatches: [] });
|
||||
});
|
||||
|
||||
it('does nothing if user not authenticated', async () => {
|
||||
it('does not mutate or call the API without an authenticated user', async () => {
|
||||
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')] });
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
await useMatchStore.getState().unarchiveMatch('m1');
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
await useMatchStore.getState().unarchiveMatch('m2');
|
||||
expect(apiClient.put).not.toHaveBeenCalled();
|
||||
expect(useMatchStore.getState().matches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchArchivedMatches', () => {
|
||||
it('fetches archived matches', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [{ match_id: 'm1' }] });
|
||||
const matchesQuery = createThenableQuery({
|
||||
data: [{ id: 'm1', question_id: 'q1', created_at: '2024-01-01' }],
|
||||
});
|
||||
const unreadQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(archivesQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(unreadQuery);
|
||||
it('reverts archive and unarchive mutations on API errors', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
(apiClient.put as jest.Mock).mockRejectedValue(new Error('forbidden'));
|
||||
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [], archivedMatchIds: new Set(), totalCount: 1 });
|
||||
await useMatchStore.getState().archiveMatch('m1');
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['m1']);
|
||||
expect(useMatchStore.getState().totalCount).toBe(1);
|
||||
useMatchStore.setState({ matches: [], archivedMatches: [match('m2')], archivedMatchIds: new Set(['m2']), totalCount: 0 });
|
||||
await useMatchStore.getState().unarchiveMatch('m2');
|
||||
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['m2']);
|
||||
expect(useMatchStore.getState().totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('loads archived matches, handles empty responses, and guards missing couple state', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('m1')], totalCount: 1 });
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.archivedMatches).toHaveLength(1);
|
||||
expect(state.archivedMatches[0].id).toBe('m1');
|
||||
expect(state.isLoadingArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if no couple', async () => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/v1/matches?archived=true&limit=100');
|
||||
expect(useMatchStore.getState().archivedMatchIds.has('m1')).toBe(true);
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [], totalCount: 0 });
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
expect(useMatchStore.getState()).toMatchObject({ archivedMatches: [], isLoadingArchived: false });
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles empty archives', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
|
||||
|
||||
await useMatchStore.getState().fetchArchivedMatches();
|
||||
|
||||
const state = useMatchStore.getState();
|
||||
expect(state.archivedMatches).toEqual([]);
|
||||
expect(state.isLoadingArchived).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleShowArchived', () => {
|
||||
it('toggles showArchived state', () => {
|
||||
expect(useMatchStore.getState().showArchived).toBe(false);
|
||||
|
||||
it('toggles archived visibility and triggers an initial fetch only', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
|
||||
await Promise.resolve();
|
||||
expect(useMatchStore.getState().showArchived).toBe(true);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
|
||||
expect(useMatchStore.getState().showArchived).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches archived matches when toggling to show', async () => {
|
||||
const archivesQuery = createThenableQuery({ data: [] });
|
||||
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
|
||||
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
|
||||
// Should trigger fetch
|
||||
expect(supabase.from).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMatchArchived', () => {
|
||||
it('returns true for archived matches', () => {
|
||||
useMatchStore.setState({
|
||||
archivedMatchIds: new Set(['m1', 'm2']),
|
||||
});
|
||||
|
||||
expect(useMatchStore.getState().isMatchArchived('m1')).toBe(true);
|
||||
expect(useMatchStore.getState().isMatchArchived('m2')).toBe(true);
|
||||
expect(useMatchStore.getState().isMatchArchived('m3')).toBe(false);
|
||||
describe('turn views', () => {
|
||||
it('loads partner and own pending questions and clears them when unpaired', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('partner')] }).mockResolvedValueOnce({ questions: [pending('mine')] });
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
await useMatchStore.getState().fetchTheirTurnQuestions();
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/questions/pending?direction=partner');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/questions/pending?direction=mine');
|
||||
expect(useMatchStore.getState()).toMatchObject({ pendingQuestions: [pending('partner')], theirTurnQuestions: [pending('mine')] });
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
await useMatchStore.getState().fetchTheirTurnQuestions();
|
||||
expect(useMatchStore.getState()).toMatchObject({ pendingQuestions: [], theirTurnQuestions: [] });
|
||||
});
|
||||
|
||||
it('releases pending loading guards on errors', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
(apiClient.get as jest.Mock).mockRejectedValue(new Error('network'));
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
await useMatchStore.getState().fetchTheirTurnQuestions();
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
|
||||
});
|
||||
|
||||
it('updates current view and delegates an empty view fetch', () => {
|
||||
const fetchArchivedMatches = jest.fn(async () => undefined);
|
||||
useMatchStore.setState({ fetchArchivedMatches });
|
||||
useMatchStore.getState().setCurrentView('archived');
|
||||
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
||||
expect(fetchArchivedMatches).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('nudges', () => {
|
||||
it('sends a nudge and uses the server cooldown', async () => {
|
||||
(appDataApi.sendNudge as jest.Mock).mockResolvedValue({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
|
||||
await expect(useMatchStore.getState().sendNudge()).resolves.toEqual({ success: true, notificationSent: true });
|
||||
expect(useMatchStore.getState()).toMatchObject({ isNudging: false, nudgeCooldownUntil: new Date('2026-08-28T00:00:00Z') });
|
||||
});
|
||||
|
||||
it('does not send during an active request or cooldown', async () => {
|
||||
useMatchStore.setState({ isNudging: true });
|
||||
await expect(useMatchStore.getState().sendNudge()).resolves.toEqual({ success: false, notificationSent: false });
|
||||
useMatchStore.setState({ isNudging: false, nudgeCooldownUntil: new Date('2026-08-27T13:00:00Z') });
|
||||
await useMatchStore.getState().sendNudge();
|
||||
expect(appDataApi.sendNudge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a 429 cooldown and recovers from generic errors', async () => {
|
||||
(appDataApi.sendNudge as jest.Mock).mockRejectedValueOnce(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
|
||||
await expect(useMatchStore.getState().sendNudge()).resolves.toEqual({ success: false, notificationSent: false });
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(new Date('2026-08-27T12:01:00Z'));
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
useMatchStore.setState({ nudgeCooldownUntil: null });
|
||||
(appDataApi.sendNudge as jest.Mock).mockRejectedValueOnce(new Error('network'));
|
||||
await useMatchStore.getState().sendNudge();
|
||||
expect(useMatchStore.getState().isNudging).toBe(false);
|
||||
});
|
||||
|
||||
it('derives, clears, and guards persisted cooldown status', async () => {
|
||||
(appDataApi.nudgeStatus as jest.Mock).mockResolvedValueOnce({ last_nudge_sent_at: '2026-08-27T06:00:00Z' });
|
||||
await useMatchStore.getState().checkNudgeCooldown();
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(new Date('2026-08-27T18:00:00Z'));
|
||||
(appDataApi.nudgeStatus as jest.Mock).mockResolvedValueOnce({ last_nudge_sent_at: null });
|
||||
await useMatchStore.getState().checkNudgeCooldown();
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
await useMatchStore.getState().checkNudgeCooldown();
|
||||
expect(appDataApi.nudgeStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('clearMatches resets all user-scoped state', () => {
|
||||
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
|
||||
useMatchStore.getState().clearMatches();
|
||||
expect(useMatchStore.getState()).toMatchObject({ matches: [], archivedMatches: [], pendingQuestions: [], theirTurnQuestions: [], currentView: 'pending', showArchived: false, isNudging: false, page: 0, hasMore: true });
|
||||
});
|
||||
});
|
||||
@@ -1,59 +1,79 @@
|
||||
import { useMessageStore } from '@/store/messageStore';
|
||||
import { chatApi } from '@/lib/chatApi';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { useMessageStore } from '@/store/messageStore';
|
||||
|
||||
function createThenableQuery(result: any) {
|
||||
const query: any = {
|
||||
select: jest.fn(() => query),
|
||||
neq: jest.fn(() => query),
|
||||
is: jest.fn(() => query),
|
||||
eq: jest.fn(() => query),
|
||||
update: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
jest.mock('@/lib/chatApi', () => ({ chatApi: { unread: jest.fn(), markRead: jest.fn() } }));
|
||||
|
||||
describe('messageStore', () => {
|
||||
describe('messageStore API state', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useMessageStore.setState({ unreadCount: 0, lastMessage: null, activeMatchId: null } as any);
|
||||
useMessageStore.getState().clearMessages();
|
||||
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 3 } as any] });
|
||||
useAuthStore.setState({ user: { id: 'me' } } as any);
|
||||
});
|
||||
|
||||
it('fetchUnreadCount sets unreadCount from Supabase count', async () => {
|
||||
const countQuery = createThenableQuery({ count: 5 });
|
||||
(supabase.from as jest.Mock).mockReturnValueOnce(countQuery);
|
||||
|
||||
it('fails closed and clears unread state without an authenticated user', async () => {
|
||||
useMessageStore.setState({ unreadCount: 7 });
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
await useMessageStore.getState().fetchUnreadCount();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches the authenticated API unread aggregate', async () => {
|
||||
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 5, by_match: { m1: 5 } });
|
||||
await useMessageStore.getState().fetchUnreadCount();
|
||||
expect(chatApi.unread).toHaveBeenCalledTimes(1);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(5);
|
||||
});
|
||||
|
||||
it('addMessage increments unreadCount when not in active chat', () => {
|
||||
useMessageStore.getState().addMessage({ id: 'x', user_id: 'partner', match_id: 'm1' } as any);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
it('propagates unread errors without replacing the last confirmed count', async () => {
|
||||
useMessageStore.setState({ unreadCount: 4 });
|
||||
const error = new Error('unauthorized');
|
||||
(chatApi.unread as jest.Mock).mockRejectedValue(error);
|
||||
await expect(useMessageStore.getState().fetchUnreadCount()).rejects.toBe(error);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(4);
|
||||
});
|
||||
|
||||
it('addMessage does not increment when in active chat', () => {
|
||||
it('notifies only for a partner message outside the active match', () => {
|
||||
useMessageStore.getState().addMessage({ id: 'mine', user_id: 'me', match_id: 'm1' } as any);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||
useMessageStore.getState().setActiveMatchId('m1');
|
||||
useMessageStore.getState().addMessage({ id: 'x', user_id: 'partner', match_id: 'm1' } as any);
|
||||
useMessageStore.getState().addMessage({ id: 'active', user_id: 'partner', match_id: 'm1' } as any);
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||
useMessageStore.getState().setActiveMatchId('m2');
|
||||
useMessageStore.getState().addMessage({ id: 'new', user_id: 'partner', match_id: 'm1' } as any);
|
||||
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
|
||||
});
|
||||
|
||||
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
|
||||
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
|
||||
const updateQuery = createThenableQuery({});
|
||||
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
|
||||
.mockReturnValueOnce(updateQuery) // update messages
|
||||
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
|
||||
|
||||
it('marks partner messages read then reconciles global and per-match counts', async () => {
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
|
||||
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
expect(updateQuery.update).toHaveBeenCalled();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
||||
it('does not mark read without an authenticated user', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
expect(chatApi.markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not decrement local counts when the read API fails', async () => {
|
||||
const error = new Error('foreign match');
|
||||
(chatApi.markRead as jest.Mock).mockRejectedValue(error);
|
||||
await expect(useMessageStore.getState().markMatchMessagesAsRead('m1')).rejects.toBe(error);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
it('clears transient message state', () => {
|
||||
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
|
||||
useMessageStore.getState().clearLastMessage();
|
||||
expect(useMessageStore.getState().lastMessage).toBeNull();
|
||||
useMessageStore.getState().clearMessages();
|
||||
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 0, lastMessage: null, activeMatchId: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { profileSettingsApi } from '../../lib/profileSettingsApi';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useNotificationPreferencesStore } from '../../store/notificationPreferencesStore';
|
||||
|
||||
jest.mock('../../lib/profileSettingsApi', () => ({
|
||||
profileSettingsApi: {
|
||||
getNotificationPreferences: jest.fn(),
|
||||
updateNotificationPreference: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const preferences = {
|
||||
user_id: 'user-1',
|
||||
matches_enabled: true,
|
||||
messages_enabled: true,
|
||||
partner_activity_enabled: true,
|
||||
nudges_enabled: true,
|
||||
pack_changes_enabled: true,
|
||||
new_packs_enabled: true,
|
||||
streak_milestones_enabled: true,
|
||||
weekly_summary_enabled: true,
|
||||
unpaired_reminders_enabled: true,
|
||||
catchup_reminders_enabled: true,
|
||||
created_at: '2026-08-27T10:00:00.000Z',
|
||||
updated_at: '2026-08-27T10:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('notificationPreferencesStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAuthStore.setState({ user: { id: 'user-1' } } as any);
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
});
|
||||
|
||||
it('loads server defaults and persists an authenticated preference update', async () => {
|
||||
(profileSettingsApi.getNotificationPreferences as jest.Mock).mockResolvedValue(preferences);
|
||||
(profileSettingsApi.updateNotificationPreference as jest.Mock)
|
||||
.mockResolvedValue({ ...preferences, messages_enabled: false });
|
||||
|
||||
await useNotificationPreferencesStore.getState().fetchPreferences();
|
||||
await useNotificationPreferencesStore.getState().updatePreference('messages_enabled', false);
|
||||
|
||||
expect(profileSettingsApi.getNotificationPreferences).toHaveBeenCalledWith();
|
||||
expect(profileSettingsApi.updateNotificationPreference).toHaveBeenCalledWith('messages_enabled', false);
|
||||
expect(useNotificationPreferencesStore.getState().preferences?.messages_enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,67 @@
|
||||
import { usePacksStore } from '@/store/packsStore';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { usePacksStore } from '@/store/packsStore';
|
||||
|
||||
function createThenableQuery(result: any) {
|
||||
const query: any = {
|
||||
select: jest.fn(() => query),
|
||||
eq: jest.fn(() => query),
|
||||
or: jest.fn(() => query),
|
||||
order: jest.fn(() => query),
|
||||
upsert: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
describe('packsStore', () => {
|
||||
describe('packsStore standalone API integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], packProgress: new Map(), isLoading: false } as any);
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: false } } as any);
|
||||
jest.restoreAllMocks();
|
||||
usePacksStore.setState({
|
||||
packs: [], categories: [], enabledPackIds: [], packProgress: new Map(),
|
||||
isLoading: false, showAllIntensities: false,
|
||||
} as any);
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
|
||||
});
|
||||
|
||||
it('fetches categories/packs and filters by hide_nsfw', async () => {
|
||||
// Set hide_nsfw to true - should filter intensity to max 2
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
|
||||
|
||||
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
|
||||
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
|
||||
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(categoriesQuery)
|
||||
.mockReturnValueOnce(packsQuery)
|
||||
.mockReturnValueOnce(enabledQuery)
|
||||
.mockReturnValueOnce(responsesQuery);
|
||||
it('loads the server-filtered catalog, couple selection, and user progress', async () => {
|
||||
const getSpy = jest.spyOn(apiClient, 'get')
|
||||
.mockResolvedValueOnce({
|
||||
categories: [{ id: 'cat1', is_public: true }],
|
||||
packs: [{ id: 'pack1', questions: [{ count: 3 }] }],
|
||||
})
|
||||
.mockResolvedValueOnce({ enabledPackIds: ['pack1'] })
|
||||
.mockResolvedValueOnce({
|
||||
progress: [{ packId: 'pack1', totalQuestions: 3, answeredQuestions: 1 }],
|
||||
});
|
||||
|
||||
await usePacksStore.getState().fetchPacks();
|
||||
|
||||
// When hide_nsfw=true, max_intensity=2, so or filter is called
|
||||
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
|
||||
// When hide_nsfw=true, explicit packs are filtered out
|
||||
expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false);
|
||||
|
||||
const state = usePacksStore.getState();
|
||||
expect(state.categories).toEqual([{ id: 'cat1' }]);
|
||||
expect(state.packs).toEqual([{ id: 'pack1', is_explicit: false }]);
|
||||
expect(state.enabledPackIds).toEqual(['pack1']);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(getSpy).toHaveBeenNthCalledWith(1, '/v1/packs?showAllIntensities=false');
|
||||
expect(getSpy).toHaveBeenNthCalledWith(2, '/v1/me/enabled-packs');
|
||||
expect(getSpy).toHaveBeenNthCalledWith(3, '/v1/me/pack-progress');
|
||||
expect(usePacksStore.getState()).toMatchObject({ enabledPackIds: ['pack1'], isLoading: false });
|
||||
expect(usePacksStore.getState().packProgress.get('pack1')).toEqual({
|
||||
totalQuestions: 3, answeredQuestions: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('togglePack performs optimistic update and returns success on upsert', async () => {
|
||||
it('sends an authenticated toggle and accepts the authoritative enabled list', async () => {
|
||||
usePacksStore.setState({ enabledPackIds: [] } as any);
|
||||
const putSpy = jest.spyOn(apiClient, 'put').mockResolvedValueOnce({ enabledPackIds: ['pack1'] });
|
||||
|
||||
const upsertQuery = createThenableQuery({ error: null });
|
||||
(supabase.from as jest.Mock).mockReturnValueOnce(upsertQuery);
|
||||
await expect(usePacksStore.getState().togglePack('pack1')).resolves.toEqual({ success: true });
|
||||
|
||||
const result = await usePacksStore.getState().togglePack('pack1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(putSpy).toHaveBeenCalledWith('/v1/me/enabled-packs/pack1', { enabled: true });
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['pack1']);
|
||||
});
|
||||
|
||||
it('rolls back an optimistic toggle when the API rejects it', async () => {
|
||||
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
|
||||
jest.spyOn(apiClient, 'put').mockRejectedValueOnce(new Error('offline'));
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await expect(usePacksStore.getState().togglePack('pack1')).resolves.toEqual({
|
||||
success: false, reason: 'error',
|
||||
});
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['pack1']);
|
||||
});
|
||||
|
||||
it('does not issue couple requests when the user has no couple', async () => {
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
const getSpy = jest.spyOn(apiClient, 'get');
|
||||
|
||||
await usePacksStore.getState().fetchEnabledPacks();
|
||||
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,499 +1,332 @@
|
||||
import { useResponsesStore, groupResponses, ResponseWithQuestion } from '@/store/responsesStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
|
||||
// Mock authErrorHandler
|
||||
jest.mock('@/lib/authErrorHandler', () => ({
|
||||
invokeWithAuthRetry: jest.fn(),
|
||||
}));
|
||||
|
||||
import { invokeWithAuthRetry } from '@/lib/authErrorHandler';
|
||||
|
||||
function createThenableQuery(result: any) {
|
||||
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),
|
||||
range: jest.fn(() => query),
|
||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
describe('responsesStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useResponsesStore.setState({
|
||||
responses: [],
|
||||
isLoading: false,
|
||||
isLoadingMore: false,
|
||||
groupBy: 'date',
|
||||
dateSortOrder: 'newest',
|
||||
hasMore: true,
|
||||
page: 0,
|
||||
totalCount: null,
|
||||
});
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
|
||||
});
|
||||
|
||||
describe('fetchResponses', () => {
|
||||
it('fetches responses with questions and packs on refresh', async () => {
|
||||
const responses = [
|
||||
{
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
partner_text: null,
|
||||
intensity: 2,
|
||||
pack_id: 'p1',
|
||||
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const countQuery = createThenableQuery({ count: 1 });
|
||||
const responsesQuery = createThenableQuery({ data: responses });
|
||||
const matchesQuery = createThenableQuery({ data: [{ id: 'm1', question_id: 'q1' }] });
|
||||
const partnerQuery = createThenableQuery({ data: [{ question_id: 'q1' }] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(partnerQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses).toHaveLength(1);
|
||||
expect(state.responses[0].has_match).toBe(true);
|
||||
expect(state.responses[0].match_id).toBe('m1');
|
||||
expect(state.responses[0].partner_answered).toBe(true);
|
||||
expect(state.totalCount).toBe(1);
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if user is not authenticated', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if couple_id is missing', async () => {
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents concurrent loading', async () => {
|
||||
useResponsesStore.setState({ isLoading: true });
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(supabase.from).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty responses correctly', async () => {
|
||||
const countQuery = createThenableQuery({ count: 0 });
|
||||
const responsesQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(responsesQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses).toEqual([]);
|
||||
expect(state.hasMore).toBe(false);
|
||||
expect(state.totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('paginates responses correctly', async () => {
|
||||
// Set up initial state with some responses
|
||||
useResponsesStore.setState({
|
||||
page: 1,
|
||||
hasMore: true,
|
||||
responses: [{ id: 'r0' }] as any,
|
||||
});
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responsesQuery = createThenableQuery({ data: responses });
|
||||
const matchesQuery = createThenableQuery({ data: [] });
|
||||
const partnerQuery = createThenableQuery({ data: [] });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(responsesQuery)
|
||||
.mockReturnValueOnce(matchesQuery)
|
||||
.mockReturnValueOnce(partnerQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
// Should append to existing responses
|
||||
expect(state.responses).toHaveLength(2);
|
||||
expect(state.page).toBe(2);
|
||||
});
|
||||
|
||||
it('handles fetch errors gracefully', async () => {
|
||||
const countQuery = createThenableQuery({ count: 1 });
|
||||
const responsesQuery = createThenableQuery({ data: null, error: new Error('Network error') });
|
||||
|
||||
(supabase.from as jest.Mock)
|
||||
.mockReturnValueOnce(countQuery)
|
||||
.mockReturnValueOnce(responsesQuery);
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.isLoadingMore).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateResponse', () => {
|
||||
it('updates response and refreshes matches on success', async () => {
|
||||
const fetchMatchesMock = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'maybe', has_match: false } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: true, new_match: { id: 'm1' } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.new_match).toEqual({ id: 'm1' });
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses[0].answer).toBe('yes');
|
||||
expect(state.responses[0].has_match).toBe(true);
|
||||
expect(state.responses[0].match_id).toBe('m1');
|
||||
expect(fetchMatchesMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles match deletion correctly', async () => {
|
||||
const fetchMatchesMock = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'yes', has_match: true, match_id: 'm1' } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: true, match_deleted: true },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'no', true);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.match_deleted).toBe(true);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses[0].answer).toBe('no');
|
||||
expect(state.responses[0].has_match).toBe(false);
|
||||
expect(state.responses[0].match_id).toBeUndefined();
|
||||
expect(fetchMatchesMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns requires_confirmation without updating local state', async () => {
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'yes', has_match: true } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: false, requires_confirmation: true, message_count: 5 },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
|
||||
|
||||
expect(result.requires_confirmation).toBe(true);
|
||||
expect(result.message_count).toBe(5);
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
// Should not update local state
|
||||
expect(state.responses[0].answer).toBe('yes');
|
||||
});
|
||||
|
||||
it('handles errors from edge function', async () => {
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: null,
|
||||
error: { message: 'Server error' },
|
||||
});
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Server error');
|
||||
});
|
||||
|
||||
it('refreshes matches when match_type_updated', async () => {
|
||||
const fetchMatchesMock = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [
|
||||
{ question_id: 'q1', answer: 'maybe', has_match: true, match_id: 'm1' } as any,
|
||||
],
|
||||
});
|
||||
|
||||
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
|
||||
data: { success: true, match_type_updated: true },
|
||||
error: null,
|
||||
});
|
||||
|
||||
await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(fetchMatchesMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGroupBy', () => {
|
||||
it('updates groupBy option', () => {
|
||||
useResponsesStore.getState().setGroupBy('pack');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('pack');
|
||||
|
||||
useResponsesStore.getState().setGroupBy('answer');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('answer');
|
||||
|
||||
useResponsesStore.getState().setGroupBy('date');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDateSortOrder', () => {
|
||||
it('updates date sort order', () => {
|
||||
useResponsesStore.getState().setDateSortOrder('oldest');
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
|
||||
|
||||
useResponsesStore.getState().setDateSortOrder('newest');
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDateSortOrder', () => {
|
||||
it('toggles between newest and oldest', () => {
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
|
||||
useResponsesStore.getState().toggleDateSortOrder();
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
|
||||
|
||||
useResponsesStore.getState().toggleDateSortOrder();
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearResponses', () => {
|
||||
it('resets store to initial state', () => {
|
||||
useResponsesStore.setState({
|
||||
responses: [{ id: 'r1' } as any],
|
||||
isLoading: true,
|
||||
groupBy: 'pack',
|
||||
dateSortOrder: 'oldest',
|
||||
page: 5,
|
||||
hasMore: false,
|
||||
isLoadingMore: true,
|
||||
totalCount: 10,
|
||||
});
|
||||
|
||||
useResponsesStore.getState().clearResponses();
|
||||
|
||||
const state = useResponsesStore.getState();
|
||||
expect(state.responses).toEqual([]);
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.groupBy).toBe('date');
|
||||
expect(state.dateSortOrder).toBe('newest');
|
||||
expect(state.page).toBe(0);
|
||||
expect(state.hasMore).toBe(true);
|
||||
expect(state.isLoadingMore).toBe(false);
|
||||
expect(state.totalCount).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupResponses', () => {
|
||||
const mockResponses: ResponseWithQuestion[] = [
|
||||
{
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1',
|
||||
text: 'Question 1',
|
||||
partner_text: null,
|
||||
intensity: 2,
|
||||
pack_id: 'p1',
|
||||
created_at: '',
|
||||
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
|
||||
},
|
||||
has_match: true,
|
||||
partner_answered: true,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
question_id: 'q2',
|
||||
answer: 'maybe',
|
||||
created_at: '2024-01-15T14:00:00.000Z',
|
||||
question: {
|
||||
id: 'q2',
|
||||
text: 'Question 2',
|
||||
partner_text: null,
|
||||
intensity: 3,
|
||||
pack_id: 'p2',
|
||||
created_at: '',
|
||||
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
|
||||
},
|
||||
has_match: false,
|
||||
partner_answered: false,
|
||||
},
|
||||
{
|
||||
id: 'r3',
|
||||
question_id: 'q3',
|
||||
answer: 'no',
|
||||
created_at: '2024-01-14T10:00:00.000Z',
|
||||
question: {
|
||||
id: 'q3',
|
||||
text: 'Question 3',
|
||||
partner_text: null,
|
||||
intensity: 1,
|
||||
pack_id: 'p1',
|
||||
created_at: '',
|
||||
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
|
||||
},
|
||||
has_match: false,
|
||||
partner_answered: true,
|
||||
},
|
||||
{
|
||||
id: 'r4',
|
||||
question_id: 'q4',
|
||||
answer: 'yes',
|
||||
created_at: '2024-01-14T14:00:00.000Z',
|
||||
question: {
|
||||
id: 'q4',
|
||||
text: 'Question 4',
|
||||
partner_text: null,
|
||||
intensity: 2,
|
||||
pack_id: 'p2',
|
||||
created_at: '',
|
||||
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
|
||||
},
|
||||
has_match: true,
|
||||
partner_answered: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('groupBy pack', () => {
|
||||
it('groups responses by pack name', () => {
|
||||
const grouped = groupResponses(mockResponses, 'pack');
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
|
||||
const packA = grouped.find(g => g.title === 'Pack A');
|
||||
const packB = grouped.find(g => g.title === 'Pack B');
|
||||
|
||||
expect(packA?.data).toHaveLength(2);
|
||||
expect(packB?.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBy answer', () => {
|
||||
it('groups responses by answer type in order: yes, maybe, no', () => {
|
||||
const grouped = groupResponses(mockResponses, 'answer');
|
||||
|
||||
expect(grouped).toHaveLength(3);
|
||||
expect(grouped[0].title).toBe('Yes');
|
||||
expect(grouped[0].data).toHaveLength(2);
|
||||
expect(grouped[1].title).toBe('Maybe');
|
||||
expect(grouped[1].data).toHaveLength(1);
|
||||
expect(grouped[2].title).toBe('No');
|
||||
expect(grouped[2].data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes empty answer groups', () => {
|
||||
const yesOnlyResponses = mockResponses.filter(r => r.answer === 'yes');
|
||||
const grouped = groupResponses(yesOnlyResponses, 'answer');
|
||||
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].title).toBe('Yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBy date', () => {
|
||||
it('groups responses by date with newest first', () => {
|
||||
const grouped = groupResponses(mockResponses, 'date', 'newest');
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
// January 15 should be first (newer)
|
||||
expect(grouped[0].title).toContain('January 15');
|
||||
expect(grouped[0].data).toHaveLength(2);
|
||||
expect(grouped[1].title).toContain('January 14');
|
||||
expect(grouped[1].data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('groups responses by date with oldest first', () => {
|
||||
const grouped = groupResponses(mockResponses, 'date', 'oldest');
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
// January 14 should be first (older)
|
||||
expect(grouped[0].title).toContain('January 14');
|
||||
expect(grouped[1].title).toContain('January 15');
|
||||
});
|
||||
|
||||
it('sorts responses within each date group', () => {
|
||||
const grouped = groupResponses(mockResponses, 'date', 'newest');
|
||||
|
||||
// Within January 15, the response at 14:00 should come first (newest)
|
||||
const jan15Group = grouped[0];
|
||||
expect(new Date(jan15Group.data[0].created_at).getTime())
|
||||
.toBeGreaterThan(new Date(jan15Group.data[1].created_at).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('default grouping', () => {
|
||||
it('returns all responses in a single group for unknown groupBy', () => {
|
||||
// @ts-expect-error Testing unknown groupBy value
|
||||
const grouped = groupResponses(mockResponses, 'unknown');
|
||||
|
||||
expect(grouped).toHaveLength(1);
|
||||
expect(grouped[0].title).toBe('All Responses');
|
||||
expect(grouped[0].data).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { groupResponses, type ResponseWithQuestion, useResponsesStore } from '@/store/responsesStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
|
||||
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn(), patch: jest.fn() } }));
|
||||
|
||||
const apiGet = apiClient.get as jest.Mock;
|
||||
const apiPatch = apiClient.patch as jest.Mock;
|
||||
|
||||
function response(overrides: Partial<ResponseWithQuestion> = {}): ResponseWithQuestion {
|
||||
return {
|
||||
id: 'r1',
|
||||
question_id: 'q1',
|
||||
answer: 'yes',
|
||||
response_data: null,
|
||||
created_at: '2024-01-15T10:00:00.000Z',
|
||||
question: {
|
||||
id: 'q1', text: 'Question 1', partner_text: null, intensity: 2,
|
||||
pack_id: 'p1', created_at: '',
|
||||
pack: { id: 'p1', name: 'Pack A', icon: 'heart' },
|
||||
},
|
||||
has_match: true,
|
||||
match_id: 'm1',
|
||||
partner_answered: true,
|
||||
...overrides,
|
||||
} as ResponseWithQuestion;
|
||||
}
|
||||
|
||||
function resetStore(): void {
|
||||
useResponsesStore.setState({
|
||||
responses: [], isLoading: false, isLoadingMore: false, groupBy: 'date',
|
||||
dateSortOrder: 'newest', hasMore: true, page: 0, totalCount: null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('responsesStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetStore();
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
|
||||
});
|
||||
|
||||
describe('fetchResponses', () => {
|
||||
it('fetches server-composed response, question, match, and partner state on refresh', async () => {
|
||||
const row = response();
|
||||
apiGet.mockResolvedValue({ responses: [row], totalCount: 1 });
|
||||
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
expect(apiGet).toHaveBeenCalledWith('/v1/me/responses?page=0&limit=20');
|
||||
expect(useResponsesStore.getState()).toMatchObject({
|
||||
responses: [row], totalCount: 1, page: 1, isLoading: false,
|
||||
isLoadingMore: false, hasMore: false,
|
||||
});
|
||||
expect(useResponsesStore.getState().responses[0]).toMatchObject({
|
||||
has_match: true, match_id: 'm1', partner_answered: true,
|
||||
question: { pack: { name: 'Pack A' } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no authenticated user', null],
|
||||
['no couple', { id: 'me', couple_id: null }],
|
||||
])('does not call the API with %s', async (_label, user) => {
|
||||
useAuthStore.setState({ user } as any);
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prevents a second request while refresh loading is active', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiGet.mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const first = useResponsesStore.getState().fetchResponses(true);
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(apiGet).toHaveBeenCalledTimes(1);
|
||||
resolve({ responses: [], totalCount: 0 });
|
||||
await first;
|
||||
});
|
||||
|
||||
it('prevents load-more overlap but permits an explicit refresh', async () => {
|
||||
useResponsesStore.setState({ isLoadingMore: true });
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
|
||||
apiGet.mockResolvedValue({ responses: [], totalCount: 0 });
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(apiGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not request another page after exhaustion', async () => {
|
||||
useResponsesStore.setState({ hasMore: false });
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stale rows and captures total count for an empty refresh', async () => {
|
||||
useResponsesStore.setState({ responses: [response()], totalCount: 4 });
|
||||
apiGet.mockResolvedValue({ responses: [], totalCount: 0 });
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(useResponsesStore.getState()).toMatchObject({
|
||||
responses: [], totalCount: 0, hasMore: false, isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks load-more exhausted without clearing existing responses', async () => {
|
||||
const existing = response();
|
||||
useResponsesStore.setState({ responses: [existing], page: 1, totalCount: 1 });
|
||||
apiGet.mockResolvedValue({ responses: [], totalCount: 1 });
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
expect(useResponsesStore.getState()).toMatchObject({
|
||||
responses: [existing], page: 1, totalCount: 1,
|
||||
hasMore: false, isLoadingMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('appends the requested page and preserves refresh total count', async () => {
|
||||
const existing = response();
|
||||
const next = response({ id: 'r2', question_id: 'q2' });
|
||||
useResponsesStore.setState({ responses: [existing], page: 1, totalCount: 21 });
|
||||
apiGet.mockResolvedValue({ responses: [next], totalCount: 21 });
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
expect(apiGet).toHaveBeenCalledWith('/v1/me/responses?page=1&limit=20');
|
||||
expect(useResponsesStore.getState()).toMatchObject({
|
||||
responses: [existing, next], page: 2, totalCount: 21,
|
||||
hasMore: false, isLoadingMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps hasMore true for a full page', async () => {
|
||||
apiGet.mockResolvedValue({
|
||||
responses: Array.from({ length: 20 }, (_, index) => response({ id: `r${index}` })),
|
||||
totalCount: 40,
|
||||
});
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(useResponsesStore.getState().hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('releases refresh loading after an API failure without destroying existing data', async () => {
|
||||
const existing = response();
|
||||
useResponsesStore.setState({ responses: [existing], totalCount: 1 });
|
||||
apiGet.mockRejectedValue(new Error('Network error'));
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(useResponsesStore.getState()).toMatchObject({
|
||||
responses: [existing], isLoading: false, isLoadingMore: false, totalCount: 1,
|
||||
});
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Error in fetchResponses:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('releases load-more loading after an API failure', async () => {
|
||||
useResponsesStore.setState({ page: 1 });
|
||||
apiGet.mockRejectedValue(new Error('Network error'));
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await useResponsesStore.getState().fetchResponses(false);
|
||||
expect(useResponsesStore.getState()).toMatchObject({ isLoading: false, isLoadingMore: false, page: 1 });
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateResponse', () => {
|
||||
it('creates a local match and refreshes the match store', async () => {
|
||||
const fetchMatches = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches } as any);
|
||||
useResponsesStore.setState({ responses: [response({ answer: 'maybe', has_match: false, match_id: undefined })] });
|
||||
apiPatch.mockResolvedValue({ success: true, new_match: { id: 'm2' } });
|
||||
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
expect(apiPatch).toHaveBeenCalledWith('/v1/responses/q1', {
|
||||
new_answer: 'yes', confirm_delete_match: false, response_data: undefined,
|
||||
});
|
||||
expect(result).toMatchObject({ success: true, new_match: { id: 'm2' } });
|
||||
expect(useResponsesStore.getState().responses[0]).toMatchObject({
|
||||
answer: 'yes', has_match: true, match_id: 'm2',
|
||||
});
|
||||
expect(fetchMatches).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deletes a confirmed match and clears response data for a no answer', async () => {
|
||||
const fetchMatches = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches } as any);
|
||||
useResponsesStore.setState({ responses: [response({ response_data: { type: 'text_answer', text: 'old' } })] });
|
||||
apiPatch.mockResolvedValue({ success: true, match_deleted: true });
|
||||
|
||||
await useResponsesStore.getState().updateResponse('q1', 'no', true);
|
||||
|
||||
expect(useResponsesStore.getState().responses[0]).toMatchObject({
|
||||
answer: 'no', response_data: null, has_match: false,
|
||||
});
|
||||
expect(useResponsesStore.getState().responses[0].match_id).toBeUndefined();
|
||||
expect(fetchMatches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns confirmation details without mutating local state', async () => {
|
||||
const original = response();
|
||||
useResponsesStore.setState({ responses: [original] });
|
||||
apiPatch.mockResolvedValue({ success: false, requires_confirmation: true, match_id: 'm1', message_count: 5 });
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
|
||||
expect(result).toMatchObject({ requires_confirmation: true, message_count: 5 });
|
||||
expect(useResponsesStore.getState().responses[0]).toEqual(original);
|
||||
});
|
||||
|
||||
it('preserves existing response data when omitted', async () => {
|
||||
const data = { type: 'text_answer', text: 'existing' } as const;
|
||||
useResponsesStore.setState({ responses: [response({ response_data: data })] });
|
||||
apiPatch.mockResolvedValue({ success: true });
|
||||
await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
expect(useResponsesStore.getState().responses[0].response_data).toEqual(data);
|
||||
});
|
||||
|
||||
it('sets replacement response data when supplied', async () => {
|
||||
const replacement = { type: 'text_answer', text: 'new' } as const;
|
||||
useResponsesStore.setState({ responses: [response()] });
|
||||
apiPatch.mockResolvedValue({ success: true });
|
||||
await useResponsesStore.getState().updateResponse('q1', 'yes', false, replacement);
|
||||
expect(apiPatch).toHaveBeenCalledWith('/v1/responses/q1', expect.objectContaining({ response_data: replacement }));
|
||||
expect(useResponsesStore.getState().responses[0].response_data).toEqual(replacement);
|
||||
});
|
||||
|
||||
it('accepts an explicit null response-data update', async () => {
|
||||
useResponsesStore.setState({ responses: [response({ response_data: { type: 'text_answer', text: 'old' } })] });
|
||||
apiPatch.mockResolvedValue({ success: true });
|
||||
await useResponsesStore.getState().updateResponse('q1', 'yes', false, null);
|
||||
expect(useResponsesStore.getState().responses[0].response_data).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes matches when only the match type changed', async () => {
|
||||
const fetchMatches = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches } as any);
|
||||
useResponsesStore.setState({ responses: [response({ answer: 'maybe' })] });
|
||||
apiPatch.mockResolvedValue({ success: true, match_type_updated: true });
|
||||
await useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
expect(fetchMatches).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not refresh matches for a plain successful response edit', async () => {
|
||||
const fetchMatches = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches } as any);
|
||||
useResponsesStore.setState({ responses: [response()] });
|
||||
apiPatch.mockResolvedValue({ success: true });
|
||||
await useResponsesStore.getState().updateResponse('q1', 'maybe');
|
||||
expect(fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an Error message and leaves state unchanged on API rejection', async () => {
|
||||
const original = response();
|
||||
useResponsesStore.setState({ responses: [original] });
|
||||
apiPatch.mockRejectedValue(new Error('Server error'));
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
|
||||
expect(result).toEqual({ success: false, error: 'Server error' });
|
||||
expect(useResponsesStore.getState().responses[0]).toEqual(original);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('normalizes non-Error rejection values', async () => {
|
||||
apiPatch.mockRejectedValue('failed');
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
await expect(useResponsesStore.getState().updateResponse('q1', 'yes'))
|
||||
.resolves.toEqual({ success: false, error: 'Failed to update response' });
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state controls', () => {
|
||||
it('sets and toggles grouping options', () => {
|
||||
useResponsesStore.getState().setGroupBy('pack');
|
||||
expect(useResponsesStore.getState().groupBy).toBe('pack');
|
||||
useResponsesStore.getState().setDateSortOrder('oldest');
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
|
||||
useResponsesStore.getState().toggleDateSortOrder();
|
||||
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
|
||||
});
|
||||
|
||||
it('clears all response-scoped state', () => {
|
||||
useResponsesStore.setState({
|
||||
responses: [response()], isLoading: true, isLoadingMore: true,
|
||||
groupBy: 'pack', dateSortOrder: 'oldest', page: 5,
|
||||
hasMore: false, totalCount: 10,
|
||||
});
|
||||
useResponsesStore.getState().clearResponses();
|
||||
expect(useResponsesStore.getState()).toMatchObject({
|
||||
responses: [], isLoading: false, isLoadingMore: false,
|
||||
groupBy: 'date', dateSortOrder: 'newest', page: 0,
|
||||
hasMore: true, totalCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupResponses', () => {
|
||||
const rows: ResponseWithQuestion[] = [
|
||||
response(),
|
||||
response({ id: 'r2', question_id: 'q2', answer: 'maybe', created_at: '2024-01-15T14:00:00.000Z', question: { ...response().question, id: 'q2', pack_id: 'p2', pack: { id: 'p2', name: 'Pack B', icon: 'moon' } } }),
|
||||
response({ id: 'r3', question_id: 'q3', answer: 'no', created_at: '2024-01-14T10:00:00.000Z' }),
|
||||
response({ id: 'r4', question_id: 'q4', answer: 'yes', created_at: '2024-01-14T14:00:00.000Z', question: { ...response().question, id: 'q4', pack_id: 'p2', pack: { id: 'p2', name: 'Pack B', icon: 'moon' } } }),
|
||||
];
|
||||
|
||||
it('groups by pack while preserving rows', () => {
|
||||
const grouped = groupResponses(rows, 'pack');
|
||||
expect(grouped.map((group) => [group.title, group.data.length])).toEqual([['Pack A', 2], ['Pack B', 2]]);
|
||||
});
|
||||
|
||||
it('orders non-empty answer groups yes, maybe, no', () => {
|
||||
expect(groupResponses(rows, 'answer').map((group) => [group.title, group.data.length]))
|
||||
.toEqual([['Yes', 2], ['Maybe', 1], ['No', 1]]);
|
||||
expect(groupResponses(rows.filter((row) => row.answer === 'yes'), 'answer').map((group) => group.title))
|
||||
.toEqual(['Yes']);
|
||||
});
|
||||
|
||||
it('groups dates newest-first and sorts within each day', () => {
|
||||
const grouped = groupResponses(rows, 'date', 'newest');
|
||||
expect(grouped[0].title).toContain('January 15');
|
||||
expect(grouped[1].title).toContain('January 14');
|
||||
expect(grouped[0].data.map((row) => row.id)).toEqual(['r2', 'r1']);
|
||||
});
|
||||
|
||||
it('groups dates oldest-first and sorts within each day', () => {
|
||||
const grouped = groupResponses(rows, 'date', 'oldest');
|
||||
expect(grouped[0].title).toContain('January 14');
|
||||
expect(grouped[1].title).toContain('January 15');
|
||||
expect(grouped[0].data.map((row) => row.id)).toEqual(['r3', 'r4']);
|
||||
});
|
||||
|
||||
it('falls back to one section for an unknown grouping option', () => {
|
||||
// @ts-expect-error deliberately verifies defensive fallback behavior
|
||||
expect(groupResponses(rows, 'unknown')).toEqual([{ title: 'All Responses', data: rows }]);
|
||||
});
|
||||
});
|
||||
@@ -27,9 +27,13 @@ jest.mock('../../lib/revenuecat', () => ({
|
||||
|
||||
import { useSubscriptionStore } from '@/store/subscriptionStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { accountOperationsApi } from '@/lib/accountOperationsApi';
|
||||
import revenueCatService from '../../lib/revenuecat';
|
||||
|
||||
jest.mock('@/lib/accountOperationsApi', () => ({
|
||||
accountOperationsApi: { syncSubscription: jest.fn(async () => ({ success: true, is_premium: true })) },
|
||||
}));
|
||||
|
||||
describe('subscriptionStore', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -167,9 +171,34 @@ describe('subscriptionStore', () => {
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
expect(accountOperationsApi.syncSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a completed purchase successful when server reconciliation fails', async () => {
|
||||
const mockPackage = { identifier: 'monthly' };
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: true,
|
||||
activeSubscription: 'monthly',
|
||||
expirationDate: '2025-01-01',
|
||||
willRenew: true,
|
||||
});
|
||||
(accountOperationsApi.syncSubscription as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Sync unavailable')
|
||||
);
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(true);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Error syncing subscription after purchase:',
|
||||
expect.any(Error)
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles cancelled purchase', async () => {
|
||||
const mockPackage = { identifier: 'monthly' };
|
||||
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
|
||||
@@ -215,6 +244,8 @@ describe('subscriptionStore', () => {
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
expect(accountOperationsApi.syncSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false when no purchases to restore', async () => {
|
||||
@@ -237,7 +268,7 @@ describe('subscriptionStore', () => {
|
||||
});
|
||||
|
||||
describe('refreshSubscriptionStatus', () => {
|
||||
it('refreshes subscription status', async () => {
|
||||
it('refreshes and syncs a changed subscription status through the standalone API', async () => {
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: true,
|
||||
activeSubscription: 'monthly',
|
||||
@@ -249,6 +280,17 @@ describe('subscriptionStore', () => {
|
||||
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
expect(accountOperationsApi.syncSubscription).toHaveBeenCalled();
|
||||
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips refresh until RevenueCat is initialized', async () => {
|
||||
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(false);
|
||||
|
||||
await useSubscriptionStore.getState().refreshSubscriptionStatus();
|
||||
|
||||
expect(revenueCatService.getCustomerInfo).not.toHaveBeenCalled();
|
||||
expect(accountOperationsApi.syncSubscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles refresh errors gracefully', async () => {
|
||||
@@ -261,6 +303,32 @@ describe('subscriptionStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer info updates', () => {
|
||||
it('syncs the server when RevenueCat reports a changed entitlement', async () => {
|
||||
let listener: ((customerInfo: unknown) => void) | undefined;
|
||||
(revenueCatService.addCustomerInfoUpdateListener as jest.Mock).mockImplementationOnce((callback) => {
|
||||
listener = callback;
|
||||
return () => {};
|
||||
});
|
||||
|
||||
await useSubscriptionStore.getState().initializeRevenueCat('user1');
|
||||
jest.clearAllMocks();
|
||||
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
|
||||
isProUser: true,
|
||||
activeSubscription: 'monthly',
|
||||
expirationDate: '2025-01-01',
|
||||
willRenew: true,
|
||||
});
|
||||
|
||||
listener?.({ entitlements: { active: {} } });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(accountOperationsApi.syncSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSubscription', () => {
|
||||
it('resets state and logs out', () => {
|
||||
useSubscriptionStore.setState({
|
||||
|
||||
Reference in new issue
Block a user