mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
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:
1 parent
1452519c76
commit
f7aa6ed8dd
29 files changed
+5207
-68
No files matched your search
@@ -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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user