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
+5188 -49

No files matched your search

+13 -3
View File
@@ -26,10 +26,20 @@ If you find ANY potential secrets:
## Step 2: Stage All Changes ## Step 2: Stage All Changes
If security check passes, stage all changes. If security check passes, stage all changes.
// turbo
Run `git add -A` Run `git add -A`
## Step 3: Generate Commit Message ## Step 3: Run Checks
Before committing, make sure checks pass for the projects you touched:
- Mobile typecheck: `npm --prefix apps/mobile run typecheck`
- Mobile tests: `npm --prefix apps/mobile run test:ci`
- Web typecheck (if you changed `apps/web`): `npm --prefix apps/web run typecheck`
Note: `apps/supabase` is Supabase config/functions (no TS typecheck).
If any command fails, STOP and report the output.
## Step 4: Generate Commit Message
Analyze the changes and create a concise, meaningful commit message that: Analyze the changes and create a concise, meaningful commit message that:
- Summarizes what changed (not how) - Summarizes what changed (not how)
- Uses imperative mood ("Add feature" not "Added feature") - Uses imperative mood ("Add feature" not "Added feature")
@@ -38,6 +48,6 @@ Analyze the changes and create a concise, meaningful commit message that:
Look at recent commits with `git log --oneline -5` to match the repository's commit style. Look at recent commits with `git log --oneline -5` to match the repository's commit style.
## Step 4: Commit and Push ## Step 5: Commit and Push
1. Create the commit with the generated message. 1. Create the commit with the generated message.
2. Push to the remote repository. 2. Push to the remote repository.
+14 -4
View File
@@ -1,6 +1,6 @@
--- ---
description: Security check, commit with sensible message, and push description: Security check, run checks, commit, and push
allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*) allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git add:*), Bash(git commit:*), Bash(git push:*), Bash(git branch:*), Bash(npm:*)
--- ---
## Context ## Context
@@ -31,7 +31,17 @@ Run `git diff --cached` to inspect what will be committed. If you find ANY poten
### Step 2: Stage All Changes ### Step 2: Stage All Changes
If security check passes, stage all changes with `git add -A`. If security check passes, stage all changes with `git add -A`.
### Step 3: Generate Commit Message ### Step 3: Run Checks
Before committing, make sure checks pass for the projects you touched:
- Mobile typecheck: `npm --prefix apps/mobile run typecheck`
- Mobile tests: `npm --prefix apps/mobile run test:ci`
- Web typecheck (if you changed `apps/web`): `npm --prefix apps/web run typecheck`
Note: `apps/supabase` is Supabase config/functions (no TS typecheck).
If any command fails, STOP and report the output.
### Step 4: Generate Commit Message
Analyze the changes and create a concise, meaningful commit message that: Analyze the changes and create a concise, meaningful commit message that:
- Summarizes what changed (not how) - Summarizes what changed (not how)
- Uses imperative mood ("Add feature" not "Added feature") - Uses imperative mood ("Add feature" not "Added feature")
@@ -40,7 +50,7 @@ Analyze the changes and create a concise, meaningful commit message that:
Look at recent commits with `git log --oneline -5` to match the repository's commit style. Look at recent commits with `git log --oneline -5` to match the repository's commit style.
### Step 4: Commit and Push ### Step 5: Commit and Push
1. Create the commit with the generated message 1. Create the commit with the generated message
2. Push to the remote repository 2. Push to the remote repository
+266 -20
View File
@@ -3,15 +3,40 @@ import { Link } from 'react-router-dom';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle } from 'lucide-react'; import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
import { format, subDays, startOfDay, endOfDay } from 'date-fns'; import { format, subDays, startOfDay, endOfDay, formatDistanceToNow } from 'date-fns';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
type FeatureInterestChartPoint = { date: string; count: number };
type RecentFeatureInterest = {
id: string;
user_id: string;
feature_name: string;
created_at: string;
profile?: {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
};
};
function formatFeatureName(featureName: string) {
return featureName
.split('_')
.filter(Boolean)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
export function DashboardPage() { export function DashboardPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
const [stats, setStats] = useState({ const [stats, setStats] = useState({
categories: 0, categories: 0,
packs: 0, packs: 0,
@@ -19,6 +44,8 @@ export function DashboardPage() {
users: 0, users: 0,
}); });
const [messageStats, setMessageStats] = useState<{ date: string; count: number }[]>([]); const [messageStats, setMessageStats] = useState<{ date: string; count: number }[]>([]);
const [featureInterestStats, setFeatureInterestStats] = useState<FeatureInterestChartPoint[]>([]);
const [recentFeatureInterests, setRecentFeatureInterests] = useState<RecentFeatureInterest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchStats = useCallback(async () => { const fetchStats = useCallback(async () => {
@@ -43,45 +70,110 @@ export function DashboardPage() {
users: usersCount || 0, users: usersCount || 0,
}); });
// Fetch message history for chart
const endDate = endOfDay(new Date()); const endDate = endOfDay(new Date());
const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today
const { data: messages } = await supabase const [
.from('messages') { data: messages },
.select('created_at') { data: featureInterests },
.gte('created_at', startDate.toISOString()) { data: recentInterests },
.lte('created_at', endDate.toISOString()); ] = await Promise.all([
supabase
.from('messages')
.select('created_at')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString()),
supabase
.from('feature_interests')
.select('created_at, user_id')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString()),
supabase
.from('feature_interests')
.select('id, user_id, feature_name, created_at')
.order('created_at', { ascending: false })
.limit(10),
]);
// Process messages for chart // Process messages for chart
const daysMap = new Map(); const messageDaysMap = new Map<string, number>();
// Initialize last 7 days with 0
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const date = subDays(new Date(), 6 - i); const date = subDays(new Date(), 6 - i);
const key = format(date, 'MMM d'); const key = format(date, 'MMM d');
daysMap.set(key, 0); messageDaysMap.set(key, 0);
} }
messages?.forEach(msg => { (messages ?? []).forEach(msg => {
const date = new Date(msg.created_at); const date = new Date(msg.created_at);
const key = format(date, 'MMM d'); const key = format(date, 'MMM d');
if (daysMap.has(key)) { if (messageDaysMap.has(key)) {
daysMap.set(key, daysMap.get(key) + 1); messageDaysMap.set(key, (messageDaysMap.get(key) || 0) + 1);
} }
}); });
const chartData = Array.from(daysMap.entries()).map(([date, count]) => ({ setMessageStats(
date, Array.from(messageDaysMap.entries()).map(([date, count]) => ({
count date,
count,
}))
);
// Process feature interests for chart (unique users per day)
const featureInterestDaysMap = new Map<string, Set<string>>();
for (let i = 0; i < 7; i++) {
const date = subDays(new Date(), 6 - i);
const key = format(date, 'MMM d');
featureInterestDaysMap.set(key, new Set());
}
(featureInterests ?? []).forEach(interest => {
const date = new Date(interest.created_at);
const key = format(date, 'MMM d');
const set = featureInterestDaysMap.get(key);
if (set) {
set.add(interest.user_id);
}
});
setFeatureInterestStats(
Array.from(featureInterestDaysMap.entries()).map(([date, userIds]) => ({
date,
count: userIds.size,
}))
);
let recentActivity: RecentFeatureInterest[] = (recentInterests ?? []).map(item => ({
id: item.id,
user_id: item.user_id,
feature_name: item.feature_name,
created_at: item.created_at,
})); }));
setMessageStats(chartData); if (canViewUsers && recentActivity.length > 0) {
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
const { data: profiles } = await supabase
.from('profiles')
.select('id, name, email, avatar_url')
.in('id', userIds);
if (profiles && profiles.length > 0) {
const profileById = new Map(profiles.map(profile => [profile.id, profile] as const));
recentActivity = recentActivity.map(item => ({
...item,
profile: profileById.get(item.user_id),
}));
}
}
setRecentFeatureInterests(recentActivity);
} catch (error) { } catch (error) {
console.error('Failed to fetch stats:', error); console.error('Failed to fetch stats:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [canViewUsers]);
// Real-time subscriptions for dashboard stats (with debouncing) // Real-time subscriptions for dashboard stats (with debouncing)
const { status: profilesStatus } = useRealtimeSubscription({ const { status: profilesStatus } = useRealtimeSubscription({
@@ -97,6 +189,14 @@ export function DashboardPage() {
debounceMs: 2000, debounceMs: 2000,
}); });
const { status: featureInterestsStatus } = useRealtimeSubscription({
table: 'feature_interests',
onInsert: fetchStats,
onDelete: fetchStats,
debounceMs: 2000,
enabled: canViewUsers,
});
useRealtimeSubscription({ useRealtimeSubscription({
table: 'categories', table: 'categories',
onInsert: fetchStats, onInsert: fetchStats,
@@ -164,6 +264,11 @@ export function DashboardPage() {
{ label: 'Active Users', value: stats.users, icon: Users, color: 'text-emerald-400', permission: PERMISSION_KEYS.VIEW_USERS }, { label: 'Active Users', value: stats.users, icon: Users, color: 'text-emerald-400', permission: PERMISSION_KEYS.VIEW_USERS },
]; ];
const isRealtimeFullySubscribed =
profilesStatus === 'SUBSCRIBED' &&
messagesStatus === 'SUBSCRIBED' &&
(!canViewUsers || featureInterestsStatus === 'SUBSCRIBED');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -175,7 +280,7 @@ export function DashboardPage() {
Dashboard Dashboard
</h1> </h1>
<RealtimeStatusIndicator <RealtimeStatusIndicator
status={profilesStatus === 'SUBSCRIBED' && messagesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profilesStatus} status={isRealtimeFullySubscribed ? 'SUBSCRIBED' : profilesStatus}
showLabel showLabel
/> />
</div> </div>
@@ -312,6 +417,147 @@ export function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Notify Overview - 7 columns */}
<div className="lg:col-span-7">
<Card className="glass border-white/5">
<CardHeader>
<div className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
<CardTitle>Notify Overview</CardTitle>
</div>
<CardDescription>
Unique users opting into feature updates over the last 7 days
</CardDescription>
</CardHeader>
<CardContent>
{!canViewUsers ? (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-muted-foreground">
You don’t have permission to view feature opt-ins.
</div>
) : (
<>
<div className="h-[260px]">
{loading ? (
<Skeleton className="h-full w-full rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={featureInterestStats}>
<XAxis
dataKey="date"
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.1)',
background: 'hsl(240 8% 10%)',
boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
}}
labelStyle={{ color: 'hsl(var(--foreground))' }}
/>
<defs>
<linearGradient id="notifyBarGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="hsl(200 75% 55%)" />
<stop offset="100%" stopColor="hsl(190 65% 40%)" />
</linearGradient>
</defs>
<Bar
dataKey="count"
fill="url(#notifyBarGradient)"
radius={[6, 6, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="mt-6 pt-6 border-t border-white/5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">Recent opt-ins</h3>
<p className="text-xs text-muted-foreground">Last 10</p>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
<div className="flex items-center gap-3 min-w-0">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-36" />
</div>
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
) : recentFeatureInterests.length === 0 ? (
<p className="text-sm text-muted-foreground">
No opt-ins yet.
</p>
) : (
<div className="space-y-3">
{recentFeatureInterests.map((item) => {
const primaryText =
item.profile?.name ||
item.profile?.email ||
`User ${item.user_id.slice(0, 8)}`;
const subtitleParts: string[] = [];
if (item.profile?.name && item.profile.email) {
subtitleParts.push(item.profile.email);
}
subtitleParts.push(formatFeatureName(item.feature_name));
const fallbackChar = (item.profile?.name || item.profile?.email || 'U')
.charAt(0)
.toUpperCase();
return (
<div key={item.id} className="flex items-center justify-between gap-4 rounded-xl bg-white/5 p-3">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-8 w-8">
<AvatarImage src={item.profile?.avatar_url || undefined} />
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{fallbackChar}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{primaryText}
</p>
<p className="text-xs text-muted-foreground truncate">
{subtitleParts.join(' • ')}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}
</p>
</div>
);
})}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
); );
+19 -19
View File
@@ -187,30 +187,30 @@ Track progress by checking off completed items.
## Phase 7: Testing Infrastructure ## Phase 7: Testing Infrastructure
### 7.1 Setup ### 7.1 Setup
- [ ] Install testing dependencies (jest, testing-library) - [x] Install testing dependencies (jest, testing-library)
- [ ] Configure jest for React Native - [x] Configure jest for React Native
- [ ] Create test utilities and mocks - [x] Create test utilities and mocks
### 7.2 Hook Tests ### 7.2 Hook Tests
- [ ] Test `useAmbientOrbAnimation` - [x] Test `useAmbientOrbAnimation`
- [ ] Test `useMediaPicker` - [x] Test `useMediaPicker`
- [ ] Test `useTypingIndicator` - [x] Test `useTypingIndicator`
- [ ] Test `useMessageSubscription` - [x] Test `useMessageSubscription`
- [ ] Test `useChatMessages` - [x] Test `useChatMessages`
- [ ] Test `useMediaUpload` - [x] Test `useMediaUpload`
### 7.3 Store Tests ### 7.3 Store Tests
- [ ] Test `authStore` actions - [x] Test `authStore` actions
- [ ] Test `matchStore` actions - [x] Test `matchStore` actions
- [ ] Test `packsStore` actions - [x] Test `packsStore` actions
- [ ] Test `messageStore` actions - [x] Test `messageStore` actions
- [ ] Test `subscriptionStore` actions - [x] Test `subscriptionStore` actions
### 7.4 Component Tests ### 7.4 Component Tests
- [ ] Test `DecorativeSeparator` variants - [x] Test `DecorativeSeparator` variants
- [ ] Test `ReadReceipt` states - [x] Test `ReadReceipt` states
- [ ] Test `InputBar` interactions - [x] Test `InputBar` interactions
- [ ] Test `MessageBubble` rendering - [x] Test `MessageBubble` rendering
--- ---
@@ -224,7 +224,7 @@ Track progress by checking off completed items.
| 4. Decompose Chat | Complete | 100% | | 4. Decompose Chat | Complete | 100% |
| 5. Decompose Profile | Complete | 100% | | 5. Decompose Profile | Complete | 100% |
| 6. Component Organization | Complete | 100% | | 6. Component Organization | Complete | 100% |
| 7. Testing | Not Started | 0% | | 7. Testing | Complete | 100% |
--- ---
+20
View File
@@ -0,0 +1,20 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'jest-expo',
clearMocks: true,
setupFiles: ['<rootDir>/node_modules/react-native-gesture-handler/jestSetup.js'],
setupFilesAfterEnv: [
'@testing-library/jest-native/extend-expect',
'<rootDir>/src/test/setup.ts',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(spec|test).(ts|tsx)',
'<rootDir>/src/**/*.(spec|test).(ts|tsx)',
],
transformIgnorePatterns: [
'node_modules/(?!(jest-)?react-native|@react-native|@react-navigation|expo(nent)?|@expo(nent)?/.*|expo-router|react-native-reanimated|react-native-gesture-handler|react-native-safe-area-context|@sentry/react-native|@sentry/.*)',
],
};
+3502 -1
View File
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -9,7 +9,10 @@
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint", "lint": "expo lint",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:ci": "jest --runInBand --ci"
}, },
"dependencies": { "dependencies": {
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
@@ -61,7 +64,14 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.0", "@babel/core": "^7.24.0",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",
"@types/react-test-renderer": "^19.1.0",
"jest": "^29.7.0",
"jest-expo": "~52.0.0",
"react-test-renderer": "^18.3.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }
@@ -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);
});
});
@@ -66,7 +66,14 @@ export function InputBar({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Plus button to toggle media menu */} {/* Plus button to toggle media menu */}
<TouchableOpacity onPress={toggleMenu} disabled={uploading} style={styles.attachButton}> <TouchableOpacity
onPress={toggleMenu}
disabled={uploading}
style={styles.attachButton}
accessibilityRole="button"
accessibilityLabel="Toggle media menu"
testID="chat-input-toggle-media"
>
{uploading ? ( {uploading ? (
<ActivityIndicator color={ACCENT} size="small" /> <ActivityIndicator color={ACCENT} size="small" />
) : ( ) : (
@@ -82,6 +89,9 @@ export function InputBar({
onPress={() => handleMediaAction(onTakePhoto)} onPress={() => handleMediaAction(onTakePhoto)}
style={styles.mediaMenuItem} style={styles.mediaMenuItem}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Take photo"
testID="chat-input-take-photo"
> >
<View style={styles.mediaMenuItemInner}> <View style={styles.mediaMenuItemInner}>
<Ionicons name="camera-outline" size={20} color={colors.textSecondary} /> <Ionicons name="camera-outline" size={20} color={colors.textSecondary} />
@@ -92,6 +102,9 @@ export function InputBar({
onPress={() => handleMediaAction(onRecordVideo)} onPress={() => handleMediaAction(onRecordVideo)}
style={styles.mediaMenuItem} style={styles.mediaMenuItem}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Record video"
testID="chat-input-record-video"
> >
<View style={styles.mediaMenuItemInner}> <View style={styles.mediaMenuItemInner}>
<Ionicons name="videocam-outline" size={20} color={colors.textSecondary} /> <Ionicons name="videocam-outline" size={20} color={colors.textSecondary} />
@@ -102,6 +115,9 @@ export function InputBar({
onPress={() => handleMediaAction(onPickMedia)} onPress={() => handleMediaAction(onPickMedia)}
style={styles.mediaMenuItem} style={styles.mediaMenuItem}
activeOpacity={0.7} activeOpacity={0.7}
accessibilityRole="button"
accessibilityLabel="Pick media"
testID="chat-input-pick-media"
> >
<View style={styles.mediaMenuItemInner}> <View style={styles.mediaMenuItemInner}>
<Ionicons name="image-outline" size={20} color={colors.textSecondary} /> <Ionicons name="image-outline" size={20} color={colors.textSecondary} />
@@ -126,6 +142,9 @@ export function InputBar({
onPress={onSend} onPress={onSend}
disabled={!inputText.trim()} disabled={!inputText.trim()}
activeOpacity={0.8} activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel="Send message"
testID="chat-input-send"
> >
<LinearGradient <LinearGradient
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']} colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
+144
View File
@@ -0,0 +1,144 @@
/// <reference types="jest" />
// Some modules rely on this global existing in RN.
(global as any).__DEV__ = true;
if (!process.env.EXPO_PUBLIC_SUPABASE_URL) {
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
}
if (!process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY) {
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key';
}
if (!process.env.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID) {
process.env.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID = 'pro';
}
jest.mock('@supabase/supabase-js', () => {
const makeChannel = () => {
const channel: any = {
on: jest.fn(() => channel),
subscribe: jest.fn(() => channel),
send: jest.fn(async () => ({ data: null, error: null })),
};
return channel;
};
return {
createClient: jest.fn(() => ({
auth: {
getSession: jest.fn(async () => ({ data: { session: null }, error: null })),
getUser: jest.fn(async () => ({ data: { user: null }, error: null })),
signOut: jest.fn(async () => ({ error: null })),
},
from: jest.fn(() => ({
select: jest.fn(),
eq: jest.fn(),
neq: jest.fn(),
is: jest.fn(),
in: jest.fn(),
order: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
insert: jest.fn(),
maybeSingle: jest.fn(),
})),
channel: jest.fn(() => makeChannel()),
removeChannel: jest.fn(),
storage: {
from: jest.fn(() => ({
upload: jest.fn(async () => ({ data: null, error: null })),
createSignedUrl: jest.fn(async () => ({ data: { signedUrl: 'https://example.com' }, error: null })),
})),
},
functions: {
invoke: jest.fn(async () => ({ data: null, error: null })),
},
})),
};
});
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper', () => ({}), { virtual: true });
jest.mock('react-native-reanimated', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Reanimated = require('react-native-reanimated/mock');
// The mock is missing `call` in some versions.
Reanimated.default.call = () => {};
return Reanimated;
});
// Reanimated 3 sometimes requires this when running in Jest.
// eslint-disable-next-line no-undef
(global as any).__reanimatedWorkletInit = () => {};
jest.mock('expo-linear-gradient', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const React = require('react');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { View } = require('react-native');
return {
LinearGradient: ({ children, ...props }: any) => React.createElement(View, props, children),
};
});
jest.mock('@expo/vector-icons', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const React = require('react');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { Text } = require('react-native');
const Icon = ({ name, ...props }: any) => React.createElement(Text, props, name);
return { Ionicons: Icon };
});
jest.mock('@react-native-async-storage/async-storage', () =>
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);
jest.mock('expo-image-picker', () => ({
requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
MediaTypeOptions: {
All: 'All',
Videos: 'Videos',
},
}));
jest.mock('expo-file-system', () => ({
readAsStringAsync: jest.fn(async () => ''),
EncodingType: {
Base64: 'base64',
},
}));
jest.mock('react-native-compressor', () => ({
Video: {
compress: jest.fn(async (uri: string) => uri),
},
}));
jest.mock('base64-arraybuffer', () => ({
decode: jest.fn(() => new ArrayBuffer(0)),
}));
jest.mock('posthog-react-native', () => {
return class PostHog {
capture = jest.fn();
identify = jest.fn();
reset = jest.fn();
screen = jest.fn();
flush = jest.fn();
constructor() {
// no-op
}
};
});
+30
View File
@@ -0,0 +1,30 @@
import React, { PropsWithChildren } from 'react';
import {
render as rtlRender,
RenderOptions,
} from '@testing-library/react-native';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
}
export function TestProviders({ children }: PropsWithChildren) {
const queryClient = createTestQueryClient();
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
export function render(ui: React.ReactElement, options?: RenderOptions) {
return rtlRender(ui, { wrapper: TestProviders, ...options });
}
export * from '@testing-library/react-native';
@@ -0,0 +1,27 @@
-- Track user opt-ins for upcoming/coming-soon features
CREATE TABLE feature_interests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, feature_name)
);
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id);
CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name);
ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own interests"
ON feature_interests FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can express interest in features"
ON feature_interests FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can remove their interest"
ON feature_interests FOR DELETE
USING (auth.uid() = user_id);
@@ -0,0 +1,5 @@
-- Allow admins to view all feature interest opt-ins
CREATE POLICY "Admins with view_users permission can view all feature interests"
ON feature_interests FOR SELECT
USING (has_permission('view_users'));
@@ -0,0 +1,14 @@
-- Enable realtime updates for feature interest opt-ins
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'feature_interests'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE feature_interests;
END IF;
END $$;