Add feature opt-ins, dashboard view, and tests

Introduce feature_interests schema + realtime publication and surface opt-in trends in the admin dashboard. Add Jest test infrastructure/coverage for the mobile app and update push-all to run mobile checks before pushing.

Co-authored-by: Claude Code <claude@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Code committed 2026-01-02 11:36:51 +00:00
1 parent 1452519c76
commit f7aa6ed8dd
29 files changed
+5207 -68

No files matched your search

@@ -0,0 +1,20 @@
import React from 'react';
import { DecorativeSeparator } from '@/components/ui/DecorativeSeparator';
import { render } from '@/test/test-utils';
describe('DecorativeSeparator', () => {
it('renders rose variant', () => {
const { toJSON } = render(<DecorativeSeparator variant="rose" />);
expect(toJSON()).toMatchSnapshot();
});
it('renders gold variant', () => {
const { toJSON } = render(<DecorativeSeparator variant="gold" />);
expect(toJSON()).toMatchSnapshot();
});
it('renders muted variant', () => {
const { toJSON } = render(<DecorativeSeparator variant="muted" />);
expect(toJSON()).toMatchSnapshot();
});
});
@@ -0,0 +1,103 @@
import React from 'react';
import { InputBar } from '@/features/chat/components/InputBar';
import { fireEvent, render } from '@/test/test-utils';
describe('InputBar', () => {
it('calls onChangeText when typing', () => {
const onChangeText = jest.fn();
const { getByPlaceholderText } = render(
<InputBar
inputText=""
uploading={false}
onChangeText={onChangeText}
onSend={jest.fn()}
onPickMedia={jest.fn()}
onTakePhoto={jest.fn()}
onRecordVideo={jest.fn()}
/>
);
fireEvent.changeText(getByPlaceholderText('Type a message...'), 'hello');
expect(onChangeText).toHaveBeenCalledWith('hello');
});
it('disables send when input is empty', () => {
const onSend = jest.fn();
const { getByTestId } = render(
<InputBar
inputText=""
uploading={false}
onChangeText={jest.fn()}
onSend={onSend}
onPickMedia={jest.fn()}
onTakePhoto={jest.fn()}
onRecordVideo={jest.fn()}
/>
);
fireEvent.press(getByTestId('chat-input-send'));
expect(onSend).not.toHaveBeenCalled();
});
it('calls onSend when input has content', () => {
const onSend = jest.fn();
const { getByTestId, rerender } = render(
<InputBar
inputText=""
uploading={false}
onChangeText={jest.fn()}
onSend={onSend}
onPickMedia={jest.fn()}
onTakePhoto={jest.fn()}
onRecordVideo={jest.fn()}
/>
);
rerender(
<InputBar
inputText="hi"
uploading={false}
onChangeText={jest.fn()}
onSend={onSend}
onPickMedia={jest.fn()}
onTakePhoto={jest.fn()}
onRecordVideo={jest.fn()}
/>
);
fireEvent.press(getByTestId('chat-input-send'));
expect(onSend).toHaveBeenCalledTimes(1);
});
it('calls media actions from menu buttons', () => {
const onPickMedia = jest.fn();
const onTakePhoto = jest.fn();
const onRecordVideo = jest.fn();
const { getByTestId } = render(
<InputBar
inputText="hello"
uploading={false}
onChangeText={jest.fn()}
onSend={jest.fn()}
onPickMedia={onPickMedia}
onTakePhoto={onTakePhoto}
onRecordVideo={onRecordVideo}
/>
);
fireEvent.press(getByTestId('chat-input-toggle-media'));
fireEvent.press(getByTestId('chat-input-take-photo'));
expect(onTakePhoto).toHaveBeenCalledTimes(1);
fireEvent.press(getByTestId('chat-input-record-video'));
expect(onRecordVideo).toHaveBeenCalledTimes(1);
fireEvent.press(getByTestId('chat-input-pick-media'));
expect(onPickMedia).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,26 @@
import React from 'react';
import { Text } from 'react-native';
import { MessageBubble } from '@/features/chat/components/MessageBubble';
import { render } from '@/test/test-utils';
describe('MessageBubble', () => {
it('renders children for my message', () => {
const { getByText } = render(
<MessageBubble isMe index={0}>
<Text>hello</Text>
</MessageBubble>
);
expect(getByText('hello')).toBeTruthy();
});
it('renders children for their message', () => {
const { getByText } = render(
<MessageBubble isMe={false} index={0}>
<Text>hi</Text>
</MessageBubble>
);
expect(getByText('hi')).toBeTruthy();
});
});
@@ -0,0 +1,29 @@
import React from 'react';
import { ReadReceipt, getReceiptStatus } from '@/components/ui/ReadReceipt';
import { render } from '@/test/test-utils';
describe('ReadReceipt', () => {
it('derives receipt status from timestamps', () => {
expect(getReceiptStatus(null, null)).toBe('sent');
expect(getReceiptStatus('2020-01-01T00:00:00.000Z', null)).toBe('delivered');
expect(getReceiptStatus('2020-01-01T00:00:00.000Z', '2020-01-01T00:00:00.000Z')).toBe('read');
expect(getReceiptStatus(null, '2020-01-01T00:00:00.000Z')).toBe('read');
});
it('renders correct icon name for sent', () => {
const { getByText } = render(<ReadReceipt status="sent" />);
expect(getByText('checkmark')).toBeTruthy();
});
it('renders correct icon name for delivered', () => {
const { getByText } = render(<ReadReceipt status="delivered" />);
const icon = getByText('checkmark-done');
expect(icon.props.color).toBeDefined();
});
it('renders correct icon name for read', () => {
const { getByText } = render(<ReadReceipt status="read" readColor="gold" />);
const icon = getByText('checkmark-done');
expect(icon.props.color).toBe('gold');
});
});
@@ -0,0 +1,199 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DecorativeSeparator renders gold variant 1`] = `
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "center",
},
{
"marginVertical": 24,
"width": 140,
},
undefined,
]
}
>
<View
style={
[
{
"flex": 1,
"height": 1,
},
{
"backgroundColor": "rgba(212, 175, 55, 0.3)",
},
]
}
/>
<View
style={
[
{
"height": 6,
"marginHorizontal": 8,
"opacity": 0.7,
"transform": [
{
"rotate": "45deg",
},
],
"width": 6,
},
{
"backgroundColor": "#D4AF37",
},
]
}
/>
<View
style={
[
{
"flex": 1,
"height": 1,
},
{
"backgroundColor": "rgba(212, 175, 55, 0.3)",
},
]
}
/>
</View>
`;
exports[`DecorativeSeparator renders muted variant 1`] = `
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "center",
},
{
"marginVertical": 24,
"width": 140,
},
undefined,
]
}
>
<View
style={
[
{
"flex": 1,
"height": 1,
},
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
},
]
}
/>
<View
style={
[
{
"height": 6,
"marginHorizontal": 8,
"opacity": 0.7,
"transform": [
{
"rotate": "45deg",
},
],
"width": 6,
},
{
"backgroundColor": "rgba(255, 255, 255, 0.3)",
},
]
}
/>
<View
style={
[
{
"flex": 1,
"height": 1,
},
{
"backgroundColor": "rgba(255, 255, 255, 0.1)",
},
]
}
/>
</View>
`;
exports[`DecorativeSeparator renders rose variant 1`] = `
<View
style={
[
{
"alignItems": "center",
"flexDirection": "row",
"justifyContent": "center",
},
{
"marginVertical": 24,
"width": 140,
},
undefined,
]
}
>
<View
style={
[
{
"flex": 1,
"height": 1,
},
{
"backgroundColor": "rgba(232, 164, 174, 0.3)",
},
]
}
/>
<View
style={
[
{
"height": 6,
"marginHorizontal": 8,
"opacity": 0.7,
"transform": [
{
"rotate": "45deg",
},
],
"width": 6,
},
{
"backgroundColor": "#E8A4AE",
},
]
}
/>
<View
style={
[
{
"flex": 1,
"height": 1,
},
{
"backgroundColor": "rgba(232, 164, 174, 0.3)",
},
]
}
/>
</View>
`;
@@ -0,0 +1,24 @@
import { renderHook } from '@testing-library/react-native';
import { useAmbientOrbAnimation } from '@/hooks/useAmbientOrbAnimation';
describe('useAmbientOrbAnimation', () => {
it('returns two animated styles', () => {
const { result } = renderHook(() => useAmbientOrbAnimation());
expect(result.current.orbStyle1).toBeTruthy();
expect(result.current.orbStyle2).toBeTruthy();
});
it('accepts configuration overrides', () => {
const { result } = renderHook(() =>
useAmbientOrbAnimation({
opacityRange1: [0.1, 0.2],
opacityRange2: [0.2, 0.3],
driftDistance: 10,
scaleRange: [1, 1.05],
})
);
expect(result.current.orbStyle1).toBeTruthy();
expect(result.current.orbStyle2).toBeTruthy();
});
});
@@ -0,0 +1,8 @@
import { useChatMessages } from '@/features/chat/hooks';
import { useMessageSubscription } from '@/hooks';
describe('useChatMessages', () => {
it('re-exports useMessageSubscription', () => {
expect(useChatMessages).toBe(useMessageSubscription);
});
});
@@ -0,0 +1,96 @@
import { Alert } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { renderHook, act } from '@testing-library/react-native';
import { useMediaPicker } from '@/hooks/useMediaPicker';
describe('useMediaPicker', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns null when user cancels library picker', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
assets: [],
});
const { result } = renderHook(() => useMediaPicker());
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
expect(picked).toBeNull();
});
it('maps selected image to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://image.jpg',
type: 'image',
width: 100,
height: 200,
}],
});
const { result } = renderHook(() => useMediaPicker());
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
expect(picked).toEqual({
uri: 'file://image.jpg',
mediaType: 'image',
width: 100,
height: 200,
duration: undefined,
});
});
it('maps selected video to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://video.mp4',
type: 'video',
width: 640,
height: 480,
duration: 1234,
}],
});
const { result } = renderHook(() => useMediaPicker());
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
expect(picked).toEqual({
uri: 'file://video.mp4',
mediaType: 'video',
width: 640,
height: 480,
duration: 1234,
});
});
it('alerts and returns false when camera permission denied', async () => {
const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useMediaPicker());
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
});
expect(ok).toBe(false);
expect(alertSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,51 @@
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';
describe('useMediaUpload', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
});
afterEach(() => {
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 {};
});
const { result } = renderHook(() => useMediaUpload('match1', 'me'));
await act(async () => {
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);
});
});
@@ -0,0 +1,145 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useMessageSubscription } from '@/hooks/useMessageSubscription';
import { supabase } from '@/lib/supabase';
type ThenableResult = { data?: any; error?: any; count?: any };
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),
};
return query;
}
describe('useMessageSubscription', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
});
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 fromMock = jest
.fn()
.mockReturnValueOnce(fetchQuery)
.mockReturnValueOnce(updateQuery);
(supabase as any).from = fromMock;
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' })
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fromMock).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\./);
});
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' })
);
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => {
result.current.isFocusedRef.current = true;
});
const newMessage = {
id: 'm2',
match_id: 'match1',
user_id: 'partner',
created_at: '2024-01-01T00:00:01.000Z',
delivered_at: null,
read_at: null,
};
expect(insertHandler).toBeTruthy();
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\./);
});
});
@@ -0,0 +1,78 @@
import { renderHook, act } from '@testing-library/react-native';
import { useTypingIndicator } from '@/hooks/useTypingIndicator';
import { supabase } from '@/lib/supabase';
describe('useTypingIndicator', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
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);
});
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' })
);
act(() => {
result.current.sendTypingEvent();
});
expect(send).toHaveBeenCalledWith({
type: 'broadcast',
event: 'typing',
payload: { userId: 'me' },
});
});
});
@@ -0,0 +1,100 @@
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';
describe('authStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: 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);
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 } });
await useAuthStore.getState().fetchUser();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isLoading).toBe(false);
});
it('clears stores and signs out when session invalid', async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: 'me' } } },
});
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
data: { user: null },
error: new Error('invalid'),
});
(supabase.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
await useAuthStore.getState().fetchUser();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useMatchStore.getState().matches).toEqual([]);
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(useMessageStore.getState().activeMatchId).toBeNull();
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(false);
expect(supabase.auth.signOut).toHaveBeenCalled();
});
it('fetches couple and partner when couple_id exists', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1' } } as any);
const couplesQuery: any = {
select: jest.fn(() => couplesQuery),
eq: jest.fn(() => couplesQuery),
maybeSingle: jest.fn(async () => ({ data: { id: 'c1' } })),
};
const partnerQuery: any = {
select: jest.fn(() => partnerQuery),
eq: jest.fn(() => partnerQuery),
neq: jest.fn(() => partnerQuery),
maybeSingle: jest.fn(async () => ({ data: { id: 'partner' } })),
};
(supabase.from as jest.Mock).mockImplementation((table: string) => {
if (table === 'couples') return couplesQuery;
if (table === 'profiles') return partnerQuery;
return couplesQuery;
});
await useAuthStore.getState().fetchCouple();
expect(useAuthStore.getState().couple).toEqual({ id: 'c1' });
expect(useAuthStore.getState().partner).toEqual({ id: 'partner' });
});
it('signOut clears local state even if Supabase fails', async () => {
(supabase.auth.signOut as jest.Mock).mockRejectedValueOnce(new Error('network'));
await useAuthStore.getState().signOut();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
@@ -0,0 +1,48 @@
import { useMatchStore } from '@/store/matchStore';
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),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('matchStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({ matches: [], newMatchesCount: 0 } as any);
useAuthStore.setState({ user: { id: 'me' } } as any);
});
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 matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
await useMatchStore.getState().fetchMatches();
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
});
});
@@ -0,0 +1,57 @@
import { useMessageStore } from '@/store/messageStore';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
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;
}
describe('messageStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMessageStore.setState({ unreadCount: 0, lastMessage: null, activeMatchId: null } 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);
await useMessageStore.getState().fetchUnreadCount();
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('addMessage does not increment when in active chat', () => {
useMessageStore.getState().setActiveMatchId('m1');
useMessageStore.getState().addMessage({ id: 'x', user_id: 'partner', match_id: 'm1' } as any);
expect(useMessageStore.getState().unreadCount).toBe(0);
});
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
const updateQuery = createThenableQuery({});
const countQuery = createThenableQuery({ count: 0 });
(supabase.from as jest.Mock)
.mockReturnValueOnce(updateQuery)
.mockReturnValueOnce(countQuery);
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(updateQuery.update).toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(0);
});
});
@@ -0,0 +1,56 @@
import { usePacksStore } from '@/store/packsStore';
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),
order: jest.fn(() => query),
upsert: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('packsStore', () => {
beforeEach(() => {
jest.clearAllMocks();
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], isLoading: false } as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', show_explicit_content: false } } as any);
});
it('fetches categories/packs and filters explicit when preference disabled', async () => {
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(categoriesQuery)
.mockReturnValueOnce(packsQuery)
.mockReturnValueOnce(enabledQuery);
await usePacksStore.getState().fetchPacks();
// Ensure explicit filter applied
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);
});
it('togglePack performs optimistic update and returns success on upsert', async () => {
usePacksStore.setState({ enabledPackIds: [] } as any);
const upsertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValueOnce(upsertQuery);
const result = await usePacksStore.getState().togglePack('pack1');
expect(result.success).toBe(true);
expect(usePacksStore.getState().enabledPackIds).toEqual(['pack1']);
});
});
@@ -0,0 +1,63 @@
jest.mock('../../lib/revenuecat', () => {
const service = {
isAvailable: jest.fn(() => true),
isInitialized: jest.fn(() => true),
initialize: jest.fn(async () => {}),
login: jest.fn(async () => null),
logout: jest.fn(async () => {}),
addCustomerInfoUpdateListener: jest.fn(() => null),
getOfferingsDebug: jest.fn(async () => ({
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
availableOfferings: ['default'],
error: null,
})),
purchasePackage: jest.fn(async () => ({ entitlements: { active: {} } })),
restorePurchases: jest.fn(async () => ({ entitlements: { active: {} } })),
getCustomerInfo: jest.fn(async () => ({ entitlements: { active: {} } })),
parseCustomerInfo: jest.fn(() => ({ isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false })),
};
return {
__esModule: true,
default: service,
revenueCatService: service,
SubscriptionState: {},
PurchasesPackage: {},
PurchasesOffering: {},
};
});
import { useSubscriptionStore } from '@/store/subscriptionStore';
import { useAuthStore } from '@/store/authStore';
describe('subscriptionStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useSubscriptionStore.setState({
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
offerings: null,
isLoadingOfferings: false,
isPurchasing: false,
error: null,
} as any);
useAuthStore.setState({ user: { id: 'me', is_premium: false } } as any);
});
it('fetchOfferings stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeTruthy();
expect(state.isLoadingOfferings).toBe(false);
expect(state.error).toBeNull();
});
it('clearSubscription resets state', () => {
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
useSubscriptionStore.getState().clearSubscription();
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeNull();
expect(state.subscription.isProUser).toBe(false);
});
});