mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10: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
@@ -26,10 +26,20 @@ If you find ANY potential secrets:
|
||||
|
||||
## Step 2: Stage All Changes
|
||||
If security check passes, stage all changes.
|
||||
// turbo
|
||||
|
||||
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:
|
||||
- Summarizes what changed (not how)
|
||||
- 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.
|
||||
|
||||
## Step 4: Commit and Push
|
||||
## Step 5: Commit and Push
|
||||
1. Create the commit with the generated message.
|
||||
2. Push to the remote repository.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
description: Security check, commit with sensible message, 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:*)
|
||||
---
|
||||
---
|
||||
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:*), Bash(npm:*)
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
@@ -31,17 +31,27 @@ Run `git diff --cached` to inspect what will be committed. If you find ANY poten
|
||||
### Step 2: Stage All Changes
|
||||
If security check passes, stage all changes with `git add -A`.
|
||||
|
||||
### Step 3: Generate Commit Message
|
||||
Analyze the changes and create a concise, meaningful commit message that:
|
||||
- Summarizes what changed (not how)
|
||||
- Uses imperative mood ("Add feature" not "Added feature")
|
||||
- Is 50 chars or less for the subject line
|
||||
- Includes the standard Claude Code footer
|
||||
|
||||
Look at recent commits with `git log --oneline -5` to match the repository's commit style.
|
||||
|
||||
### Step 4: Commit and Push
|
||||
1. Create the commit with the generated message
|
||||
2. Push to the remote repository
|
||||
### 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:
|
||||
- Summarizes what changed (not how)
|
||||
- Uses imperative mood ("Add feature" not "Added feature")
|
||||
- Is 50 chars or less for the subject line
|
||||
- Includes the standard Claude Code footer
|
||||
|
||||
Look at recent commits with `git log --oneline -5` to match the repository's commit style.
|
||||
|
||||
### Step 5: Commit and Push
|
||||
1. Create the commit with the generated message
|
||||
2. Push to the remote repository
|
||||
|
||||
If any step fails, report the error and stop.
|
||||
@@ -3,15 +3,40 @@ import { Link } from 'react-router-dom';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/config';
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
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 { 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() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||
const [stats, setStats] = useState({
|
||||
categories: 0,
|
||||
packs: 0,
|
||||
@@ -19,6 +44,8 @@ export function DashboardPage() {
|
||||
users: 0,
|
||||
});
|
||||
const [messageStats, setMessageStats] = useState<{ date: string; count: number }[]>([]);
|
||||
const [featureInterestStats, setFeatureInterestStats] = useState<FeatureInterestChartPoint[]>([]);
|
||||
const [recentFeatureInterests, setRecentFeatureInterests] = useState<RecentFeatureInterest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
@@ -43,45 +70,110 @@ export function DashboardPage() {
|
||||
users: usersCount || 0,
|
||||
});
|
||||
|
||||
// Fetch message history for chart
|
||||
const endDate = endOfDay(new Date());
|
||||
const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today
|
||||
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('created_at')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString());
|
||||
const [
|
||||
{ data: messages },
|
||||
{ data: featureInterests },
|
||||
{ data: recentInterests },
|
||||
] = 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
|
||||
const daysMap = new Map();
|
||||
// Initialize last 7 days with 0
|
||||
const messageDaysMap = new Map<string, number>();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = subDays(new Date(), 6 - i);
|
||||
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 key = format(date, 'MMM d');
|
||||
if (daysMap.has(key)) {
|
||||
daysMap.set(key, daysMap.get(key) + 1);
|
||||
if (messageDaysMap.has(key)) {
|
||||
messageDaysMap.set(key, (messageDaysMap.get(key) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
const chartData = Array.from(daysMap.entries()).map(([date, count]) => ({
|
||||
date,
|
||||
count
|
||||
setMessageStats(
|
||||
Array.from(messageDaysMap.entries()).map(([date, 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) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [canViewUsers]);
|
||||
|
||||
// Real-time subscriptions for dashboard stats (with debouncing)
|
||||
const { status: profilesStatus } = useRealtimeSubscription({
|
||||
@@ -97,6 +189,14 @@ export function DashboardPage() {
|
||||
debounceMs: 2000,
|
||||
});
|
||||
|
||||
const { status: featureInterestsStatus } = useRealtimeSubscription({
|
||||
table: 'feature_interests',
|
||||
onInsert: fetchStats,
|
||||
onDelete: fetchStats,
|
||||
debounceMs: 2000,
|
||||
enabled: canViewUsers,
|
||||
});
|
||||
|
||||
useRealtimeSubscription({
|
||||
table: 'categories',
|
||||
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 },
|
||||
];
|
||||
|
||||
const isRealtimeFullySubscribed =
|
||||
profilesStatus === 'SUBSCRIBED' &&
|
||||
messagesStatus === 'SUBSCRIBED' &&
|
||||
(!canViewUsers || featureInterestsStatus === 'SUBSCRIBED');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -175,7 +280,7 @@ export function DashboardPage() {
|
||||
Dashboard
|
||||
</h1>
|
||||
<RealtimeStatusIndicator
|
||||
status={profilesStatus === 'SUBSCRIBED' && messagesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profilesStatus}
|
||||
status={isRealtimeFullySubscribed ? 'SUBSCRIBED' : profilesStatus}
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
@@ -312,6 +417,147 @@ export function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -186,31 +186,31 @@ Track progress by checking off completed items.
|
||||
|
||||
## Phase 7: Testing Infrastructure
|
||||
|
||||
### 7.1 Setup
|
||||
- [ ] Install testing dependencies (jest, testing-library)
|
||||
- [ ] Configure jest for React Native
|
||||
- [ ] Create test utilities and mocks
|
||||
|
||||
### 7.2 Hook Tests
|
||||
- [ ] Test `useAmbientOrbAnimation`
|
||||
- [ ] Test `useMediaPicker`
|
||||
- [ ] Test `useTypingIndicator`
|
||||
- [ ] Test `useMessageSubscription`
|
||||
- [ ] Test `useChatMessages`
|
||||
- [ ] Test `useMediaUpload`
|
||||
|
||||
### 7.3 Store Tests
|
||||
- [ ] Test `authStore` actions
|
||||
- [ ] Test `matchStore` actions
|
||||
- [ ] Test `packsStore` actions
|
||||
- [ ] Test `messageStore` actions
|
||||
- [ ] Test `subscriptionStore` actions
|
||||
|
||||
### 7.4 Component Tests
|
||||
- [ ] Test `DecorativeSeparator` variants
|
||||
- [ ] Test `ReadReceipt` states
|
||||
- [ ] Test `InputBar` interactions
|
||||
- [ ] Test `MessageBubble` rendering
|
||||
### 7.1 Setup
|
||||
- [x] Install testing dependencies (jest, testing-library)
|
||||
- [x] Configure jest for React Native
|
||||
- [x] Create test utilities and mocks
|
||||
|
||||
### 7.2 Hook Tests
|
||||
- [x] Test `useAmbientOrbAnimation`
|
||||
- [x] Test `useMediaPicker`
|
||||
- [x] Test `useTypingIndicator`
|
||||
- [x] Test `useMessageSubscription`
|
||||
- [x] Test `useChatMessages`
|
||||
- [x] Test `useMediaUpload`
|
||||
|
||||
### 7.3 Store Tests
|
||||
- [x] Test `authStore` actions
|
||||
- [x] Test `matchStore` actions
|
||||
- [x] Test `packsStore` actions
|
||||
- [x] Test `messageStore` actions
|
||||
- [x] Test `subscriptionStore` actions
|
||||
|
||||
### 7.4 Component Tests
|
||||
- [x] Test `DecorativeSeparator` variants
|
||||
- [x] Test `ReadReceipt` states
|
||||
- [x] Test `InputBar` interactions
|
||||
- [x] Test `MessageBubble` rendering
|
||||
|
||||
---
|
||||
|
||||
@@ -224,7 +224,7 @@ Track progress by checking off completed items.
|
||||
| 4. Decompose Chat | Complete | 100% |
|
||||
| 5. Decompose Profile | Complete | 100% |
|
||||
| 6. Component Organization | Complete | 100% |
|
||||
| 7. Testing | Not Started | 0% |
|
||||
| 7. Testing | Complete | 100% |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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/.*)',
|
||||
],
|
||||
};
|
||||
Generated
+3502
-1
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,10 @@
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:ci": "jest --runInBand --ci"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/ngrok": "^4.1.0",
|
||||
@@ -61,7 +64,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-test-renderer": "^19.1.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-expo": "~52.0.0",
|
||||
"react-test-renderer": "^18.3.1",
|
||||
"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 (
|
||||
<View style={styles.container}>
|
||||
{/* 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 ? (
|
||||
<ActivityIndicator color={ACCENT} size="small" />
|
||||
) : (
|
||||
@@ -82,6 +89,9 @@ export function InputBar({
|
||||
onPress={() => handleMediaAction(onTakePhoto)}
|
||||
style={styles.mediaMenuItem}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Take photo"
|
||||
testID="chat-input-take-photo"
|
||||
>
|
||||
<View style={styles.mediaMenuItemInner}>
|
||||
<Ionicons name="camera-outline" size={20} color={colors.textSecondary} />
|
||||
@@ -92,6 +102,9 @@ export function InputBar({
|
||||
onPress={() => handleMediaAction(onRecordVideo)}
|
||||
style={styles.mediaMenuItem}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Record video"
|
||||
testID="chat-input-record-video"
|
||||
>
|
||||
<View style={styles.mediaMenuItemInner}>
|
||||
<Ionicons name="videocam-outline" size={20} color={colors.textSecondary} />
|
||||
@@ -102,6 +115,9 @@ export function InputBar({
|
||||
onPress={() => handleMediaAction(onPickMedia)}
|
||||
style={styles.mediaMenuItem}
|
||||
activeOpacity={0.7}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Pick media"
|
||||
testID="chat-input-pick-media"
|
||||
>
|
||||
<View style={styles.mediaMenuItemInner}>
|
||||
<Ionicons name="image-outline" size={20} color={colors.textSecondary} />
|
||||
@@ -126,6 +142,9 @@ export function InputBar({
|
||||
onPress={onSend}
|
||||
disabled={!inputText.trim()}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Send message"
|
||||
testID="chat-input-send"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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 $$;
|
||||
Reference in new issue
Block a user