Add match archiving, chat enhancements, and daily limits

- Match archives with swipe-to-archive and unread filtering
- Chat settings sheet and full-screen media modals
- Match notification modal for new matches
- Admin AI settings with model/provider configuration
- Daily response limit feature with recommended questions
- New test coverage for stores, hooks, and components
- Claude Code agent and command configurations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-09 20:18:34 +00:00
1 parent 595d6bd394
commit b0f1700e40
57 files changed
+8359 -1661

No files matched your search

@@ -0,0 +1,284 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Text, View, Platform } from 'react-native';
import { GlassButton } from '@/components/ui/GlassButton';
// Store the mock for expo-haptics so we can check calls
const mockImpactAsync = jest.fn();
// Mock expo-haptics - must happen before import
jest.mock('expo-haptics', () => ({
impactAsync: mockImpactAsync,
ImpactFeedbackStyle: {
Light: 'light',
},
}));
describe('GlassButton', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders children text correctly', () => {
const { getByText } = render(
<GlassButton>Click Me</GlassButton>
);
expect(getByText('Click Me')).toBeTruthy();
});
it('renders with primary variant by default', () => {
const { getByText } = render(
<GlassButton>Primary Button</GlassButton>
);
const text = getByText('Primary Button');
expect(text).toBeTruthy();
});
it('renders secondary variant', () => {
const { getByText } = render(
<GlassButton variant="secondary">Secondary Button</GlassButton>
);
expect(getByText('Secondary Button')).toBeTruthy();
});
it('renders ghost variant', () => {
const { getByText } = render(
<GlassButton variant="ghost">Ghost Button</GlassButton>
);
expect(getByText('Ghost Button')).toBeTruthy();
});
it('renders danger variant', () => {
const { getByText } = render(
<GlassButton variant="danger">Danger Button</GlassButton>
);
expect(getByText('Danger Button')).toBeTruthy();
});
it('renders with icon when provided', () => {
const TestIcon = () => <Text testID="test-icon">Icon</Text>;
const { getByTestId, getByText } = render(
<GlassButton icon={<TestIcon />}>With Icon</GlassButton>
);
expect(getByTestId('test-icon')).toBeTruthy();
expect(getByText('With Icon')).toBeTruthy();
});
it('renders loading indicator when loading is true', () => {
const { queryByText, UNSAFE_getByType } = render(
<GlassButton loading>Loading Button</GlassButton>
);
// When loading, text should not be visible
expect(queryByText('Loading Button')).toBeNull();
// Activity indicator should be present
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('renders with different sizes', () => {
const { rerender, getByText } = render(
<GlassButton size="sm">Small</GlassButton>
);
expect(getByText('Small')).toBeTruthy();
rerender(<GlassButton size="md">Medium</GlassButton>);
expect(getByText('Medium')).toBeTruthy();
rerender(<GlassButton size="lg">Large</GlassButton>);
expect(getByText('Large')).toBeTruthy();
});
});
describe('press handling', () => {
it('calls onPress when pressed', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>Press Me</GlassButton>
);
fireEvent.press(getByText('Press Me'));
await waitFor(() => {
expect(onPress).toHaveBeenCalledTimes(1);
});
});
it('does not call onPress when disabled', () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} disabled>
Disabled Button
</GlassButton>
);
fireEvent.press(getByText('Disabled Button'));
expect(onPress).not.toHaveBeenCalled();
});
it('does not call onPress when loading', () => {
const onPress = jest.fn();
const { UNSAFE_getByType } = render(
<GlassButton onPress={onPress} loading>
Loading Button
</GlassButton>
);
const ActivityIndicator = require('react-native').ActivityIndicator;
const indicator = UNSAFE_getByType(ActivityIndicator);
// Try to press the parent pressable
// Since loading hides the text, we can't press by text
// The component should still be non-pressable
expect(onPress).not.toHaveBeenCalled();
});
// Note: Haptic tests use haptic={false} because the component uses dynamic import
// which doesn't work properly in Jest without --experimental-vm-modules
it('handles button with haptic prop', () => {
// Verify the component renders with haptic prop
// Actual haptic functionality cannot be tested due to dynamic import limitations
const { getByText } = render(
<GlassButton haptic>Haptic Button</GlassButton>
);
expect(getByText('Haptic Button')).toBeTruthy();
});
it('calls onPress when haptic is false', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>
No Haptic
</GlassButton>
);
fireEvent.press(getByText('No Haptic'));
await waitFor(() => {
expect(onPress).toHaveBeenCalled();
});
});
});
describe('styling', () => {
it('applies fullWidth style when fullWidth is true', () => {
const { getByText } = render(
<GlassButton fullWidth>Full Width Button</GlassButton>
);
const button = getByText('Full Width Button');
expect(button).toBeTruthy();
// The fullWidth style is applied to parent, which sets width: '100%'
});
it('applies custom style prop', () => {
const customStyle = { marginTop: 20 };
const { getByText } = render(
<GlassButton style={customStyle}>Styled Button</GlassButton>
);
expect(getByText('Styled Button')).toBeTruthy();
});
it('applies custom textStyle prop', () => {
const customTextStyle = { fontWeight: 'bold' as const };
const { getByText } = render(
<GlassButton textStyle={customTextStyle}>Styled Text</GlassButton>
);
expect(getByText('Styled Text')).toBeTruthy();
});
it('applies disabled opacity when disabled', () => {
const { getByText } = render(
<GlassButton disabled>Disabled Button</GlassButton>
);
// The disabled style reduces opacity to 0.5
expect(getByText('Disabled Button')).toBeTruthy();
});
});
describe('press in/out animations', () => {
it('handles press in event', () => {
const { getByText } = render(
<GlassButton>Animated Button</GlassButton>
);
const button = getByText('Animated Button');
// Trigger press in - this should work without errors
fireEvent(button, 'pressIn');
expect(button).toBeTruthy();
});
it('handles press out event', () => {
const { getByText } = render(
<GlassButton>Animated Button</GlassButton>
);
const button = getByText('Animated Button');
// Trigger press in and out
fireEvent(button, 'pressIn');
fireEvent(button, 'pressOut');
expect(button).toBeTruthy();
});
});
describe('default props', () => {
it('has default variant of primary', () => {
const { getByText } = render(<GlassButton>Default</GlassButton>);
expect(getByText('Default')).toBeTruthy();
});
it('has default size of md', () => {
const { getByText } = render(<GlassButton>Default Size</GlassButton>);
expect(getByText('Default Size')).toBeTruthy();
});
it('has haptic enabled by default', () => {
// The component has haptic={true} by default
// We can't test the actual haptic call due to dynamic import limitations
// but we verify the prop default works
const { getByText } = render(<GlassButton>Default Haptic</GlassButton>);
expect(getByText('Default Haptic')).toBeTruthy();
});
it('has disabled false by default', async () => {
const onPress = jest.fn();
const { getByText } = render(
<GlassButton onPress={onPress} haptic={false}>Not Disabled</GlassButton>
);
fireEvent.press(getByText('Not Disabled'));
await waitFor(() => {
expect(onPress).toHaveBeenCalled();
});
});
it('has loading false by default', () => {
const { getByText } = render(
<GlassButton>Not Loading</GlassButton>
);
expect(getByText('Not Loading')).toBeTruthy();
});
it('has fullWidth false by default', () => {
const { getByText } = render(<GlassButton>Not Full Width</GlassButton>);
expect(getByText('Not Full Width')).toBeTruthy();
});
});
});
@@ -0,0 +1,299 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { Text, Platform } from 'react-native';
import { GlassCard } from '@/components/ui/GlassCard';
// Mock expo-blur
jest.mock('expo-blur', () => {
const React = require('react');
const { View } = require('react-native');
return {
BlurView: ({ children, intensity, tint, style, ...props }: any) =>
React.createElement(
View,
{ ...props, style, testID: 'blur-view', 'data-intensity': intensity, 'data-tint': tint },
children
),
};
});
// Store original Platform.OS
const originalPlatformOS = Platform.OS;
describe('GlassCard', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to iOS for consistent testing
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
afterAll(() => {
Object.defineProperty(Platform, 'OS', {
get: () => originalPlatformOS,
});
});
describe('rendering', () => {
it('renders children correctly', () => {
const { getByText } = render(
<GlassCard>
<Text>Card Content</Text>
</GlassCard>
);
expect(getByText('Card Content')).toBeTruthy();
});
it('renders multiple children', () => {
const { getByText } = render(
<GlassCard>
<Text>First Child</Text>
<Text>Second Child</Text>
</GlassCard>
);
expect(getByText('First Child')).toBeTruthy();
expect(getByText('Second Child')).toBeTruthy();
});
it('renders nested components', () => {
const { getByText, getByTestId } = render(
<GlassCard>
<Text testID="nested-text">Nested Content</Text>
</GlassCard>
);
expect(getByTestId('nested-text')).toBeTruthy();
expect(getByText('Nested Content')).toBeTruthy();
});
});
describe('platform behavior', () => {
it('uses BlurView on iOS', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByTestId } = render(
<GlassCard>
<Text>iOS Card</Text>
</GlassCard>
);
expect(getByTestId('blur-view')).toBeTruthy();
});
// Note: Testing Android platform requires component re-evaluation
// which is complex with module-level Platform checks
it('renders correctly', () => {
const { getByText } = render(
<GlassCard>
<Text>Card Content</Text>
</GlassCard>
);
expect(getByText('Card Content')).toBeTruthy();
});
});
describe('intensity prop', () => {
beforeEach(() => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
it('applies light intensity', () => {
const { getByTestId } = render(
<GlassCard intensity="light">
<Text>Light Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
});
it('applies medium intensity by default', () => {
const { getByTestId } = render(
<GlassCard>
<Text>Medium Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
});
it('applies heavy intensity', () => {
const { getByTestId } = render(
<GlassCard intensity="heavy">
<Text>Heavy Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
});
});
describe('variant prop', () => {
it('renders default variant', () => {
const { getByText } = render(
<GlassCard variant="default">
<Text>Default Card</Text>
</GlassCard>
);
expect(getByText('Default Card')).toBeTruthy();
});
it('renders elevated variant', () => {
const { getByText } = render(
<GlassCard variant="elevated">
<Text>Elevated Card</Text>
</GlassCard>
);
expect(getByText('Elevated Card')).toBeTruthy();
});
it('renders subtle variant', () => {
const { getByText } = render(
<GlassCard variant="subtle">
<Text>Subtle Card</Text>
</GlassCard>
);
expect(getByText('Subtle Card')).toBeTruthy();
});
});
describe('noPadding prop', () => {
it('has padding by default', () => {
const { getByText } = render(
<GlassCard>
<Text>Padded Card</Text>
</GlassCard>
);
expect(getByText('Padded Card')).toBeTruthy();
});
it('removes padding when noPadding is true', () => {
const { getByText } = render(
<GlassCard noPadding>
<Text>No Padding Card</Text>
</GlassCard>
);
expect(getByText('No Padding Card')).toBeTruthy();
});
});
describe('style prop', () => {
it('applies custom style', () => {
const customStyle = { marginTop: 20, backgroundColor: 'red' };
const { getByText } = render(
<GlassCard style={customStyle}>
<Text>Styled Card</Text>
</GlassCard>
);
expect(getByText('Styled Card')).toBeTruthy();
});
it('merges custom style with default styles', () => {
const { getByText } = render(
<GlassCard style={{ marginBottom: 10 }}>
<Text>Merged Styles Card</Text>
</GlassCard>
);
expect(getByText('Merged Styles Card')).toBeTruthy();
});
});
describe('default props', () => {
it('has default intensity of medium', () => {
const { getByText } = render(
<GlassCard>
<Text>Default Intensity</Text>
</GlassCard>
);
expect(getByText('Default Intensity')).toBeTruthy();
});
it('has default variant of default', () => {
const { getByText } = render(
<GlassCard>
<Text>Default Variant</Text>
</GlassCard>
);
expect(getByText('Default Variant')).toBeTruthy();
});
it('has noPadding false by default', () => {
const { getByText } = render(
<GlassCard>
<Text>Has Padding</Text>
</GlassCard>
);
expect(getByText('Has Padding')).toBeTruthy();
});
});
describe('gradient highlight', () => {
it('renders highlight gradient', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByText } = render(
<GlassCard>
<Text>Card with Highlight</Text>
</GlassCard>
);
// LinearGradient is mocked to render as View, so we just verify the card renders
expect(getByText('Card with Highlight')).toBeTruthy();
});
});
describe('accessibility', () => {
it('renders children accessible to screen readers', () => {
const { getByText } = render(
<GlassCard>
<Text accessibilityLabel="Accessible content">Content</Text>
</GlassCard>
);
const text = getByText('Content');
expect(text.props.accessibilityLabel).toBe('Accessible content');
});
});
describe('combination of props', () => {
it('handles all props together', () => {
const customStyle = { margin: 10 };
const { getByText } = render(
<GlassCard
intensity="heavy"
variant="elevated"
noPadding
style={customStyle}
>
<Text>Full Props Card</Text>
</GlassCard>
);
expect(getByText('Full Props Card')).toBeTruthy();
});
});
});
@@ -0,0 +1,380 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Text, Platform } from 'react-native';
import { GlassInput } from '@/components/ui/GlassInput';
// Mock expo-blur
jest.mock('expo-blur', () => {
const React = require('react');
const { View } = require('react-native');
return {
BlurView: ({ children, intensity, tint, style, ...props }: any) =>
React.createElement(
View,
{ ...props, style, testID: 'blur-view' },
children
),
};
});
// Store original Platform.OS
const originalPlatformOS = Platform.OS;
describe('GlassInput', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset to iOS for consistent testing
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
afterAll(() => {
// Restore original
Object.defineProperty(Platform, 'OS', {
get: () => originalPlatformOS,
});
});
describe('rendering', () => {
it('renders text input', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Enter text" />
);
expect(getByPlaceholderText('Enter text')).toBeTruthy();
});
it('renders with label', () => {
const { getByText, getByPlaceholderText } = render(
<GlassInput label="Username" placeholder="Enter username" />
);
expect(getByText('Username')).toBeTruthy();
expect(getByPlaceholderText('Enter username')).toBeTruthy();
});
it('renders without label', () => {
const { queryByText, getByPlaceholderText } = render(
<GlassInput placeholder="No label input" />
);
expect(getByPlaceholderText('No label input')).toBeTruthy();
// No label text should be present
});
it('renders error message when provided', () => {
const { getByText } = render(
<GlassInput placeholder="Input" error="This field is required" />
);
expect(getByText('This field is required')).toBeTruthy();
});
it('does not render error message when not provided', () => {
const { queryByText, getByPlaceholderText } = render(
<GlassInput placeholder="No error input" />
);
expect(getByPlaceholderText('No error input')).toBeTruthy();
expect(queryByText(/error/i)).toBeNull();
});
it('renders with icon', () => {
const TestIcon = () => <Text testID="input-icon">Icon</Text>;
const { getByTestId } = render(
<GlassInput placeholder="Input with icon" icon={<TestIcon />} />
);
expect(getByTestId('input-icon')).toBeTruthy();
});
});
describe('platform behavior', () => {
it('uses BlurView on iOS', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByTestId } = render(
<GlassInput placeholder="iOS Input" />
);
expect(getByTestId('blur-view')).toBeTruthy();
});
// Note: Testing Android platform requires component re-evaluation
// which is complex with module-level Platform checks
it('renders correctly', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Input" />
);
expect(getByPlaceholderText('Input')).toBeTruthy();
});
});
describe('input interactions', () => {
it('handles text input changes', () => {
const onChangeText = jest.fn();
const { getByPlaceholderText } = render(
<GlassInput placeholder="Type here" onChangeText={onChangeText} />
);
const input = getByPlaceholderText('Type here');
fireEvent.changeText(input, 'Hello World');
expect(onChangeText).toHaveBeenCalledWith('Hello World');
});
it('handles focus events', () => {
const onFocus = jest.fn();
const { getByPlaceholderText } = render(
<GlassInput placeholder="Focus me" onFocus={onFocus} />
);
const input = getByPlaceholderText('Focus me');
fireEvent(input, 'focus', {});
expect(onFocus).toHaveBeenCalled();
});
it('handles blur events', () => {
const onBlur = jest.fn();
const { getByPlaceholderText } = render(
<GlassInput placeholder="Blur me" onBlur={onBlur} />
);
const input = getByPlaceholderText('Blur me');
fireEvent(input, 'blur', {});
expect(onBlur).toHaveBeenCalled();
});
it('displays value prop', () => {
const { getByDisplayValue } = render(
<GlassInput placeholder="Input" value="Initial value" />
);
expect(getByDisplayValue('Initial value')).toBeTruthy();
});
it('handles controlled input', () => {
const onChangeText = jest.fn();
const { getByDisplayValue, rerender } = render(
<GlassInput
placeholder="Controlled"
value="Initial"
onChangeText={onChangeText}
/>
);
expect(getByDisplayValue('Initial')).toBeTruthy();
rerender(
<GlassInput
placeholder="Controlled"
value="Updated"
onChangeText={onChangeText}
/>
);
expect(getByDisplayValue('Updated')).toBeTruthy();
});
});
describe('focus state styling', () => {
it('updates styling on focus', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Focus styling" />
);
const input = getByPlaceholderText('Focus styling');
// Trigger focus
fireEvent(input, 'focus', {});
// The component should update without errors
expect(input).toBeTruthy();
});
it('updates styling on blur', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Blur styling" />
);
const input = getByPlaceholderText('Blur styling');
// Trigger focus then blur
fireEvent(input, 'focus', {});
fireEvent(input, 'blur', {});
expect(input).toBeTruthy();
});
});
describe('error state', () => {
it('displays error styling when error prop is provided', () => {
const { getByText, getByPlaceholderText } = render(
<GlassInput
placeholder="Error input"
error="Invalid input"
/>
);
expect(getByPlaceholderText('Error input')).toBeTruthy();
expect(getByText('Invalid input')).toBeTruthy();
});
it('applies error border color', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Error border" error="Has error" />
);
// The component renders with error styling
expect(getByPlaceholderText('Error border')).toBeTruthy();
});
});
describe('input props passthrough', () => {
it('passes secureTextEntry prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Password" secureTextEntry />
);
const input = getByPlaceholderText('Password');
expect(input.props.secureTextEntry).toBe(true);
});
it('passes keyboardType prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Email" keyboardType="email-address" />
);
const input = getByPlaceholderText('Email');
expect(input.props.keyboardType).toBe('email-address');
});
it('passes autoCapitalize prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Name" autoCapitalize="words" />
);
const input = getByPlaceholderText('Name');
expect(input.props.autoCapitalize).toBe('words');
});
it('passes autoCorrect prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Code" autoCorrect={false} />
);
const input = getByPlaceholderText('Code');
expect(input.props.autoCorrect).toBe(false);
});
it('passes maxLength prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Short" maxLength={10} />
);
const input = getByPlaceholderText('Short');
expect(input.props.maxLength).toBe(10);
});
it('passes multiline prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Message" multiline />
);
const input = getByPlaceholderText('Message');
expect(input.props.multiline).toBe(true);
});
it('passes editable prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Read only" editable={false} />
);
const input = getByPlaceholderText('Read only');
expect(input.props.editable).toBe(false);
});
it('passes returnKeyType prop', () => {
const { getByPlaceholderText } = render(
<GlassInput placeholder="Search" returnKeyType="search" />
);
const input = getByPlaceholderText('Search');
expect(input.props.returnKeyType).toBe('search');
});
});
describe('containerStyle prop', () => {
it('applies custom container style', () => {
const customStyle = { marginTop: 20 };
const { getByPlaceholderText } = render(
<GlassInput
placeholder="Styled container"
containerStyle={customStyle}
/>
);
expect(getByPlaceholderText('Styled container')).toBeTruthy();
});
});
describe('accessibility', () => {
it('supports accessibilityLabel', () => {
const { getByLabelText } = render(
<GlassInput
placeholder="Accessible input"
accessibilityLabel="Username input field"
/>
);
expect(getByLabelText('Username input field')).toBeTruthy();
});
it('supports accessibilityHint', () => {
const { getByPlaceholderText } = render(
<GlassInput
placeholder="Hint input"
accessibilityHint="Enter your username"
/>
);
const input = getByPlaceholderText('Hint input');
expect(input.props.accessibilityHint).toBe('Enter your username');
});
});
describe('combination of props', () => {
it('handles all props together', () => {
const onChangeText = jest.fn();
const onFocus = jest.fn();
const TestIcon = () => <Text>Icon</Text>;
const { getByText, getByPlaceholderText } = render(
<GlassInput
label="Full Props Input"
placeholder="Enter value"
error="Validation error"
icon={<TestIcon />}
value="Initial"
onChangeText={onChangeText}
onFocus={onFocus}
secureTextEntry
keyboardType="default"
containerStyle={{ margin: 10 }}
/>
);
expect(getByText('Full Props Input')).toBeTruthy();
expect(getByPlaceholderText('Enter value')).toBeTruthy();
expect(getByText('Validation error')).toBeTruthy();
expect(getByText('Icon')).toBeTruthy();
});
});
});
@@ -0,0 +1,255 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { LoadingOverlay } from '@/components/ui/LoadingOverlay';
describe('LoadingOverlay', () => {
describe('visibility', () => {
it('returns null when not visible', () => {
const { queryByText, toJSON } = render(
<LoadingOverlay visible={false} statusText="Loading..." />
);
expect(queryByText('Loading...')).toBeNull();
expect(toJSON()).toBeNull();
});
it('renders when visible', () => {
const { getByText } = render(
<LoadingOverlay visible={true} statusText="Loading..." />
);
expect(getByText('Loading...')).toBeTruthy();
});
});
describe('spinner variant (default)', () => {
it('renders spinner by default', () => {
const { UNSAFE_getByType } = render(
<LoadingOverlay visible={true} />
);
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('renders status text', () => {
const { getByText } = render(
<LoadingOverlay visible={true} statusText="Please wait..." />
);
expect(getByText('Please wait...')).toBeTruthy();
});
it('renders detail text', () => {
const { getByText } = render(
<LoadingOverlay visible={true} detailText="This may take a moment" />
);
expect(getByText('This may take a moment')).toBeTruthy();
});
it('renders both status and detail text', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
statusText="Uploading..."
detailText="50% complete"
/>
);
expect(getByText('Uploading...')).toBeTruthy();
expect(getByText('50% complete')).toBeTruthy();
});
});
describe('shimmer variant', () => {
it('renders shimmer variant', () => {
const { UNSAFE_getByType, getByText } = render(
<LoadingOverlay visible={true} variant="shimmer" statusText="Loading..." />
);
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
expect(getByText('Loading...')).toBeTruthy();
});
it('renders shimmer with detail text', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
variant="shimmer"
statusText="Syncing"
detailText="Almost done"
/>
);
expect(getByText('Syncing')).toBeTruthy();
expect(getByText('Almost done')).toBeTruthy();
});
});
describe('progress variant', () => {
it('renders progress variant', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
variant="progress"
progress={50}
statusText="Uploading..."
/>
);
expect(getByText('Uploading...')).toBeTruthy();
});
it('renders progress with detail text', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
variant="progress"
progress={75}
statusText="Processing"
detailText="3 of 4 complete"
/>
);
expect(getByText('Processing')).toBeTruthy();
expect(getByText('3 of 4 complete')).toBeTruthy();
});
it('clamps progress to 0-100', () => {
// Progress value should be clamped internally
const { toJSON } = render(
<LoadingOverlay visible={true} variant="progress" progress={150} />
);
// Should render without error
expect(toJSON()).toBeTruthy();
});
it('handles negative progress', () => {
const { toJSON } = render(
<LoadingOverlay visible={true} variant="progress" progress={-10} />
);
// Should render without error
expect(toJSON()).toBeTruthy();
});
});
describe('fullScreen mode', () => {
it('renders in fullScreen mode', () => {
const { getByText } = render(
<LoadingOverlay
visible={true}
fullScreen={true}
statusText="Full screen loading"
/>
);
expect(getByText('Full screen loading')).toBeTruthy();
});
it('renders fullScreen with different variants', () => {
const { rerender, getByText } = render(
<LoadingOverlay
visible={true}
fullScreen={true}
variant="spinner"
statusText="Spinner"
/>
);
expect(getByText('Spinner')).toBeTruthy();
rerender(
<LoadingOverlay
visible={true}
fullScreen={true}
variant="progress"
statusText="Progress"
/>
);
expect(getByText('Progress')).toBeTruthy();
rerender(
<LoadingOverlay
visible={true}
fullScreen={true}
variant="shimmer"
statusText="Shimmer"
/>
);
expect(getByText('Shimmer')).toBeTruthy();
});
});
describe('custom styling', () => {
it('applies custom style', () => {
const customStyle = { marginTop: 20 };
const { getByText } = render(
<LoadingOverlay
visible={true}
style={customStyle}
statusText="Styled"
/>
);
expect(getByText('Styled')).toBeTruthy();
});
it('applies custom text style', () => {
const customTextStyle = { fontSize: 20 };
const { getByText } = render(
<LoadingOverlay
visible={true}
textStyle={customTextStyle}
statusText="Custom text"
/>
);
expect(getByText('Custom text')).toBeTruthy();
});
it('uses custom spinner color', () => {
const { UNSAFE_getByType } = render(
<LoadingOverlay
visible={true}
spinnerColor="#FF0000"
/>
);
const ActivityIndicator = require('react-native').ActivityIndicator;
const indicator = UNSAFE_getByType(ActivityIndicator);
expect(indicator.props.color).toBe('#FF0000');
});
});
describe('default props', () => {
it('has spinner variant by default', () => {
const { UNSAFE_getByType } = render(
<LoadingOverlay visible={true} />
);
const ActivityIndicator = require('react-native').ActivityIndicator;
expect(UNSAFE_getByType(ActivityIndicator)).toBeTruthy();
});
it('has fullScreen false by default', () => {
const { toJSON } = render(
<LoadingOverlay visible={true} statusText="Test" />
);
// Should render in non-fullscreen mode
expect(toJSON()).toBeTruthy();
});
it('has progress 0 by default', () => {
const { toJSON } = render(
<LoadingOverlay visible={true} variant="progress" />
);
// Should render without error
expect(toJSON()).toBeTruthy();
});
});
});
@@ -0,0 +1,186 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import { Text, View } from 'react-native';
import { ShimmerEffect } from '@/components/ui/ShimmerEffect';
describe('ShimmerEffect', () => {
describe('rendering', () => {
it('renders children correctly', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>Child Content</Text>
</ShimmerEffect>
);
expect(getByText('Child Content')).toBeTruthy();
});
it('renders multiple children', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>First Child</Text>
<Text>Second Child</Text>
</ShimmerEffect>
);
expect(getByText('First Child')).toBeTruthy();
expect(getByText('Second Child')).toBeTruthy();
});
it('renders nested components', () => {
const { getByText, getByTestId } = render(
<ShimmerEffect>
<View testID="nested-view">
<Text>Nested Content</Text>
</View>
</ShimmerEffect>
);
expect(getByTestId('nested-view')).toBeTruthy();
expect(getByText('Nested Content')).toBeTruthy();
});
});
describe('enabled prop', () => {
it('shows glow effect when enabled (default)', () => {
const { toJSON } = render(
<ShimmerEffect>
<Text>Content</Text>
</ShimmerEffect>
);
// Should render with glow elements
expect(toJSON()).toBeTruthy();
});
it('hides glow effect when disabled', () => {
const { getByText, toJSON } = render(
<ShimmerEffect enabled={false}>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
// The glow container should not be rendered when disabled
expect(toJSON()).toBeTruthy();
});
it('toggles enabled state', () => {
const { rerender, getByText } = render(
<ShimmerEffect enabled={true}>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
rerender(
<ShimmerEffect enabled={false}>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
});
});
describe('custom styling', () => {
it('applies custom style', () => {
const customStyle = { margin: 10, padding: 20 };
const { getByText } = render(
<ShimmerEffect style={customStyle}>
<Text>Styled Content</Text>
</ShimmerEffect>
);
expect(getByText('Styled Content')).toBeTruthy();
});
it('uses custom shimmer color', () => {
const { getByText } = render(
<ShimmerEffect shimmerColor="#FF0000">
<Text>Red Shimmer</Text>
</ShimmerEffect>
);
expect(getByText('Red Shimmer')).toBeTruthy();
});
});
describe('duration prop', () => {
it('accepts custom duration', () => {
const { getByText } = render(
<ShimmerEffect duration={2000}>
<Text>Fast Shimmer</Text>
</ShimmerEffect>
);
expect(getByText('Fast Shimmer')).toBeTruthy();
});
it('uses default duration of 4000ms', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>Default Duration</Text>
</ShimmerEffect>
);
expect(getByText('Default Duration')).toBeTruthy();
});
});
describe('default props', () => {
it('has enabled true by default', () => {
const { toJSON, getByText } = render(
<ShimmerEffect>
<Text>Content</Text>
</ShimmerEffect>
);
expect(getByText('Content')).toBeTruthy();
expect(toJSON()).toBeTruthy();
});
it('uses default shimmer color', () => {
const { getByText } = render(
<ShimmerEffect>
<Text>Default Color</Text>
</ShimmerEffect>
);
expect(getByText('Default Color')).toBeTruthy();
});
});
describe('composition', () => {
it('works with complex children', () => {
const { getByText, getByTestId } = render(
<ShimmerEffect>
<View testID="outer">
<Text>Header</Text>
<View testID="inner">
<Text>Body</Text>
</View>
<Text>Footer</Text>
</View>
</ShimmerEffect>
);
expect(getByTestId('outer')).toBeTruthy();
expect(getByTestId('inner')).toBeTruthy();
expect(getByText('Header')).toBeTruthy();
expect(getByText('Body')).toBeTruthy();
expect(getByText('Footer')).toBeTruthy();
});
it('can be nested', () => {
const { getByText } = render(
<ShimmerEffect shimmerColor="#FF0000">
<ShimmerEffect shimmerColor="#00FF00" enabled={false}>
<Text>Nested Shimmer</Text>
</ShimmerEffect>
</ShimmerEffect>
);
expect(getByText('Nested Shimmer')).toBeTruthy();
});
});
});
@@ -0,0 +1,197 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useFeatureInterest } from '@/hooks/useFeatureInterest';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
maybeSingle: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('useFeatureInterest', () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: 'user1' } } as any);
});
it('fetches initial interest status as true when interest exists', async () => {
const query = createThenableQuery({ data: { id: 'interest1' } });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
expect(query.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(query.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('fetches initial interest status as false when no interest exists', async () => {
const query = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
});
it('returns isAuthenticated false when user is null', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isInterested).toBe(false);
expect(supabase.from).not.toHaveBeenCalled();
});
it('adds interest when toggling from not interested', async () => {
// Initial query returns no interest
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(insertQuery); // Insert on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(true);
expect(insertQuery.insert).toHaveBeenCalledWith({
user_id: 'user1',
feature_name: 'dares',
});
});
it('removes interest when toggling from interested', async () => {
// Initial query returns existing interest
const initialQuery = createThenableQuery({ data: { id: 'interest1' } });
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(deleteQuery); // Delete on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(false);
expect(deleteQuery.delete).toHaveBeenCalled();
expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(deleteQuery.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('reverts optimistic update on error', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: new Error('Insert failed') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
// Should revert to original state after error
expect(result.current.isInterested).toBe(false);
});
it('does not toggle when user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
// Should not make any API calls
expect(supabase.from).not.toHaveBeenCalled();
});
it('isToggling is false when not toggling', async () => {
const initialQuery = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValueOnce(initialQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isToggling).toBe(false);
});
it('isToggling is false after toggle completes', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isToggling).toBe(false);
});
});
@@ -6,91 +6,355 @@ import { useMediaPicker } from '@/hooks/useMediaPicker';
describe('useMediaPicker', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
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();
afterEach(() => {
jest.useRealTimers();
});
it('maps selected image to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
describe('pickMedia', () => {
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',
type: 'image',
mediaType: 'image',
width: 100,
height: 200,
}],
duration: undefined,
});
});
const { result } = renderHook(() => useMediaPicker());
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,
}],
});
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
const { result } = renderHook(() => useMediaPicker());
expect(picked).toEqual({
uri: 'file://image.jpg',
mediaType: 'image',
width: 100,
height: 200,
duration: undefined,
});
});
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
it('maps selected video to MediaResult', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
expect(picked).toEqual({
uri: 'file://video.mp4',
type: 'video',
mediaType: 'video',
width: 640,
height: 480,
duration: 1234,
}],
});
});
const { result } = renderHook(() => useMediaPicker());
it('uses custom config options', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
});
let picked: any;
await act(async () => {
picked = await result.current.pickMedia();
});
const { result } = renderHook(() => useMediaPicker({
imageQuality: 0.5,
libraryVideoMaxDuration: 120,
}));
expect(picked).toEqual({
uri: 'file://video.mp4',
mediaType: 'video',
width: 640,
height: 480,
duration: 1234,
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
mediaTypes: ImagePicker.MediaTypeOptions.All,
quality: 0.5,
videoMaxDuration: 120,
});
});
});
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' });
describe('requestCameraPermission', () => {
it('returns true when permission granted', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
const { result } = renderHook(() => useMediaPicker());
const { result } = renderHook(() => useMediaPicker());
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
let ok: any;
await act(async () => {
ok = await result.current.requestCameraPermission();
});
expect(ok).toBe(true);
});
expect(ok).toBe(false);
expect(alertSpy).toHaveBeenCalled();
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).toHaveBeenCalledWith(
'Camera Permission',
'Please allow camera access to use this feature.'
);
});
});
describe('takePhoto', () => {
it('takes photo successfully', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://photo.jpg',
type: 'image',
width: 3024,
height: 4032,
}],
});
const { result } = renderHook(() => useMediaPicker());
let photo: any;
await act(async () => {
photo = await result.current.takePhoto();
});
expect(photo).toEqual({
uri: 'file://photo.jpg',
mediaType: 'image',
width: 3024,
height: 4032,
});
});
it('returns null when permission denied', async () => {
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useMediaPicker());
let photo: any;
await act(async () => {
photo = await result.current.takePhoto();
});
expect(photo).toBeNull();
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
});
it('returns null when cancelled', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: true,
});
const { result } = renderHook(() => useMediaPicker());
let photo: any;
await act(async () => {
photo = await result.current.takePhoto();
});
expect(photo).toBeNull();
});
it('uses custom image quality', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker({ imageQuality: 0.9 }));
await act(async () => {
await result.current.takePhoto();
});
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({ quality: 0.9 });
});
});
describe('recordVideo', () => {
it('records video successfully', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://video.mp4',
type: 'video',
width: 1920,
height: 1080,
duration: 15000,
}],
});
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video).toEqual({
uri: 'file://video.mp4',
mediaType: 'video',
width: 1920,
height: 1080,
duration: 15000,
});
});
it('returns null when permission denied', async () => {
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied' });
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video).toBeNull();
});
it('returns null when cancelled', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video).toBeNull();
});
it('uses custom video max duration', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker({ cameraVideoMaxDuration: 30 }));
await act(async () => {
await result.current.recordVideo();
});
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith(
expect.objectContaining({
videoMaxDuration: 30,
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
})
);
});
it('handles null duration in video result', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({
canceled: false,
assets: [{
uri: 'file://video.mp4',
type: 'video',
width: 1920,
height: 1080,
duration: null,
}],
});
const { result } = renderHook(() => useMediaPicker());
let video: any;
await act(async () => {
video = await result.current.recordVideo();
});
expect(video.duration).toBeUndefined();
});
});
describe('default config', () => {
it('uses default image quality of 0.7', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
expect.objectContaining({ quality: 0.7 })
);
});
it('uses default library video max duration of 300', async () => {
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
await act(async () => {
await result.current.pickMedia();
});
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith(
expect.objectContaining({ videoMaxDuration: 300 })
);
});
it('uses default camera video max duration of 60', async () => {
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'granted' });
(ImagePicker.launchCameraAsync as jest.Mock).mockResolvedValueOnce({ canceled: true });
const { result } = renderHook(() => useMediaPicker());
await act(async () => {
await result.current.recordVideo();
});
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith(
expect.objectContaining({ videoMaxDuration: 60 })
);
});
});
});
@@ -0,0 +1,231 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
import { useMediaSaver } from '@/hooks/useMediaSaver';
// Mock expo-media-library
jest.mock('expo-media-library', () => ({
usePermissions: jest.fn(),
createAssetAsync: jest.fn(),
}));
// Mock expo-file-system with all required methods
jest.mock('expo-file-system', () => ({
readAsStringAsync: jest.fn(async () => ''),
downloadAsync: jest.fn(),
deleteAsync: jest.fn(),
cacheDirectory: 'file:///cache/',
EncodingType: {
Base64: 'base64',
},
}));
// Mock Alert
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
describe('useMediaSaver', () => {
const mockRequestPermission = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Default: permission granted
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
{ granted: true },
mockRequestPermission,
]);
(MediaLibrary.createAssetAsync as jest.Mock).mockResolvedValue({ id: 'asset1' });
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
uri: 'file:///cache/download_123.jpg',
});
(FileSystem.deleteAsync as jest.Mock).mockResolvedValue(undefined);
});
it('initializes with saving as false', () => {
const { result } = renderHook(() => useMediaSaver());
expect(result.current.saving).toBe(false);
expect(typeof result.current.saveMedia).toBe('function');
});
it('saves local image successfully', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/image.jpg');
expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Image saved to your gallery.');
expect(result.current.saving).toBe(false);
});
it('saves local video successfully', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/video.mp4', 'video');
});
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///local/video.mp4');
expect(Alert.alert).toHaveBeenCalledWith('Saved', 'Video saved to your gallery.');
});
it('downloads remote image before saving', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/image.jpg', 'image');
});
expect(FileSystem.downloadAsync).toHaveBeenCalledWith(
'https://example.com/image.jpg',
expect.stringMatching(/download_\d+\.jpg$/)
);
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg');
expect(FileSystem.deleteAsync).toHaveBeenCalledWith('file:///cache/download_123.jpg', { idempotent: true });
});
it('downloads remote video before saving', async () => {
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
uri: 'file:///cache/download_123.mp4',
});
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/video.mp4', 'video');
});
expect(FileSystem.downloadAsync).toHaveBeenCalledWith(
'https://example.com/video.mp4',
expect.stringMatching(/download_\d+\.mp4$/)
);
expect(MediaLibrary.createAssetAsync).toHaveBeenCalledWith('file:///cache/download_123.mp4');
});
it('handles http URLs (upgrades to https internally)', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('http://example.com/image.jpg', 'image');
});
// http URLs are also considered remote
expect(FileSystem.downloadAsync).toHaveBeenCalled();
});
it('requests permission when not granted', async () => {
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
{ granted: false },
mockRequestPermission,
]);
mockRequestPermission.mockResolvedValue({ granted: true });
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(mockRequestPermission).toHaveBeenCalled();
expect(MediaLibrary.createAssetAsync).toHaveBeenCalled();
});
it('shows alert and returns early when permission denied', async () => {
(MediaLibrary.usePermissions as jest.Mock).mockReturnValue([
{ granted: false },
mockRequestPermission,
]);
mockRequestPermission.mockResolvedValue({ granted: false });
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(Alert.alert).toHaveBeenCalledWith(
'Permission required',
'Please allow access to your photo library to save media.'
);
expect(MediaLibrary.createAssetAsync).not.toHaveBeenCalled();
expect(result.current.saving).toBe(false);
});
it('handles save errors gracefully', async () => {
(MediaLibrary.createAssetAsync as jest.Mock).mockRejectedValue(new Error('Save failed'));
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.');
expect(result.current.saving).toBe(false);
});
it('handles download errors gracefully', async () => {
(FileSystem.downloadAsync as jest.Mock).mockRejectedValue(new Error('Download failed'));
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/image.jpg', 'image');
});
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to save media.');
expect(result.current.saving).toBe(false);
});
it('sets saving to true during operation', async () => {
let savingDuringOperation = false;
(MediaLibrary.createAssetAsync as jest.Mock).mockImplementation(async () => {
// This would capture the saving state during the async operation
// but since we can't access result.current here synchronously,
// we'll test this differently
return { id: 'asset1' };
});
const { result } = renderHook(() => useMediaSaver());
const savePromise = act(async () => {
// Check saving is true after calling but before completion
const promise = result.current.saveMedia('file:///local/image.jpg', 'image');
// Can't reliably check intermediate state, so we just verify final state
await promise;
});
await savePromise;
expect(result.current.saving).toBe(false);
});
it('does not delete local files after saving', async () => {
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('file:///local/image.jpg', 'image');
});
expect(FileSystem.deleteAsync).not.toHaveBeenCalled();
});
it('cleans up downloaded file after saving remote URL', async () => {
(FileSystem.downloadAsync as jest.Mock).mockResolvedValue({
uri: 'file:///cache/download_123.jpg',
});
const { result } = renderHook(() => useMediaSaver());
await act(async () => {
await result.current.saveMedia('https://example.com/image.jpg', 'image');
});
expect(FileSystem.deleteAsync).toHaveBeenCalledWith(
'file:///cache/download_123.jpg',
{ idempotent: true }
);
});
});
@@ -11,6 +11,9 @@ function createThenableQuery(result: any) {
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
update: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
@@ -19,31 +22,464 @@ function createThenableQuery(result: any) {
describe('matchStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
useMatchStore.setState({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isLoadingMore: false,
page: 0,
hasMore: true,
error: null,
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
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 },
];
describe('fetchMatches', () => {
it('sorts matches with unread messages first', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery) // archived matches
.mockReturnValueOnce(countQuery) // count
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
await useMatchStore.getState().fetchMatches();
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
});
it('sets empty state when user has no couple', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.newMatchesCount).toBe(0);
expect(state.totalCount).toBe(0);
expect(supabase.from).not.toHaveBeenCalled();
});
it('prevents concurrent loading', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('filters out archived matches', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: false },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
const archivesQuery = createThenableQuery({ data: [{ match_id: 'a' }] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: [], error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('b');
// Total count should subtract archived
expect(state.totalCount).toBe(1);
});
it('handles empty matches correctly', async () => {
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 0 });
const matchesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.hasMore).toBe(false);
expect(state.isLoading).toBe(false);
});
it('handles fetch error gracefully', async () => {
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 0 });
// Use createThenableQuery with error data to simulate error response
const matchesQuery = createThenableQuery({ data: null, error: { message: 'Network error' } });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.error).toBe('Failed to load matches');
expect(state.isLoading).toBe(false);
});
it('paginates matches correctly', async () => {
useMatchStore.setState({
matches: [{ id: 'existing' }] as any,
page: 1,
hasMore: true,
});
const newMatches = [
{ id: 'c', question_id: 'q3', created_at: '2024-01-01T00:00:00.000Z', is_new: false },
];
const matchesQuery = createThenableQuery({ data: newMatches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchMatches(false);
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(2);
expect(state.page).toBe(2);
});
});
describe('markAsSeen', () => {
it('marks a match as seen and updates count', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1', is_new: true } as any,
{ id: 'm2', is_new: true } as any,
],
newMatchesCount: 2,
});
const updateQuery = createThenableQuery({});
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
await useMatchStore.getState().markAsSeen('m1');
const state = useMatchStore.getState();
expect(state.matches[0].is_new).toBe(false);
expect(state.matches[1].is_new).toBe(true);
expect(state.newMatchesCount).toBe(1);
expect(updateQuery.update).toHaveBeenCalledWith({ is_new: false });
});
});
describe('markAllAsSeen', () => {
it('marks all matches as seen', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1', is_new: true } as any,
{ id: 'm2', is_new: true } as any,
],
newMatchesCount: 2,
});
const updateQuery = createThenableQuery({});
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
await useMatchStore.getState().markAllAsSeen();
const state = useMatchStore.getState();
expect(state.matches.every(m => !m.is_new)).toBe(true);
expect(state.newMatchesCount).toBe(0);
});
it('does nothing when no new matches', async () => {
useMatchStore.setState({
matches: [{ id: 'm1', is_new: false } as any],
newMatchesCount: 0,
});
await useMatchStore.getState().markAllAsSeen();
expect(supabase.from).not.toHaveBeenCalled();
});
});
describe('addMatch', () => {
it('adds a new match to the beginning', () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
newMatchesCount: 0,
totalCount: 1,
});
useMatchStore.getState().addMatch({ id: 'm2', is_new: true } as any);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('m2');
expect(state.matches).toHaveLength(2);
expect(state.newMatchesCount).toBe(1);
expect(state.totalCount).toBe(2);
});
});
describe('updateMatchUnreadCount', () => {
it('increments unread count', () => {
useMatchStore.setState({
matches: [{ id: 'm1', unreadCount: 1 } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', 2);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(3);
});
it('decrements unread count but not below zero', () => {
useMatchStore.setState({
matches: [{ id: 'm1', unreadCount: 1 } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(0);
});
it('handles missing unreadCount', () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', 1);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(1);
});
});
describe('clearMatches', () => {
it('resets all state to initial values', () => {
useMatchStore.setState({
matches: [{ id: 'm1' }] as any,
newMatchesCount: 5,
totalCount: 10,
isLoading: true,
error: 'some error',
page: 3,
hasMore: false,
isLoadingMore: true,
archivedMatches: [{ id: 'a1' }] as any,
archivedMatchIds: new Set(['a1']),
showArchived: true,
isLoadingArchived: true,
});
useMatchStore.getState().clearMatches();
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.newMatchesCount).toBe(0);
expect(state.totalCount).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(state.page).toBe(0);
expect(state.hasMore).toBe(true);
expect(state.isLoadingMore).toBe(false);
expect(state.archivedMatches).toEqual([]);
expect(state.archivedMatchIds.size).toBe(0);
expect(state.showArchived).toBe(false);
expect(state.isLoadingArchived).toBe(false);
});
});
describe('archiveMatch', () => {
it('archives a match with optimistic update', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1' } as any,
{ id: 'm2' } as any,
],
archivedMatches: [],
archivedMatchIds: new Set(),
totalCount: 2,
});
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
await useMatchStore.getState().archiveMatch('m1');
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('m2');
expect(state.archivedMatches).toHaveLength(1);
expect(state.archivedMatches[0].id).toBe('m1');
expect(state.archivedMatchIds.has('m1')).toBe(true);
expect(state.totalCount).toBe(1);
});
it('does nothing if user not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().archiveMatch('m1');
expect(supabase.from).not.toHaveBeenCalled();
});
it('reverts on error', async () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
archivedMatches: [],
archivedMatchIds: new Set(),
totalCount: 1,
});
const insertQuery = createThenableQuery({ error: new Error('DB error') });
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
await useMatchStore.getState().archiveMatch('m1');
const state = useMatchStore.getState();
// Should revert
expect(state.matches).toHaveLength(1);
expect(state.archivedMatchIds.has('m1')).toBe(false);
});
});
describe('unarchiveMatch', () => {
it('unarchives a match with optimistic update', async () => {
useMatchStore.setState({
matches: [],
archivedMatches: [{ id: 'm1' } as any],
archivedMatchIds: new Set(['m1']),
totalCount: 0,
});
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValue(deleteQuery);
await useMatchStore.getState().unarchiveMatch('m1');
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('m1');
expect(state.archivedMatches).toHaveLength(0);
expect(state.archivedMatchIds.has('m1')).toBe(false);
expect(state.totalCount).toBe(1);
});
it('does nothing if user not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().unarchiveMatch('m1');
expect(supabase.from).not.toHaveBeenCalled();
});
});
describe('fetchArchivedMatches', () => {
it('fetches archived matches', async () => {
const archivesQuery = createThenableQuery({ data: [{ match_id: 'm1' }] });
const matchesQuery = createThenableQuery({
data: [{ id: 'm1', question_id: 'q1', created_at: '2024-01-01' }],
});
const unreadQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchArchivedMatches();
const state = useMatchStore.getState();
expect(state.archivedMatches).toHaveLength(1);
expect(state.archivedMatches[0].id).toBe('m1');
expect(state.isLoadingArchived).toBe(false);
});
it('does nothing if no couple', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useMatchStore.getState().fetchArchivedMatches();
expect(supabase.from).not.toHaveBeenCalled();
});
it('handles empty archives', async () => {
const archivesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
await useMatchStore.getState().fetchArchivedMatches();
const state = useMatchStore.getState();
expect(state.archivedMatches).toEqual([]);
expect(state.isLoadingArchived).toBe(false);
});
});
describe('toggleShowArchived', () => {
it('toggles showArchived state', () => {
expect(useMatchStore.getState().showArchived).toBe(false);
useMatchStore.getState().toggleShowArchived();
expect(useMatchStore.getState().showArchived).toBe(true);
useMatchStore.getState().toggleShowArchived();
expect(useMatchStore.getState().showArchived).toBe(false);
});
it('fetches archived matches when toggling to show', async () => {
const archivesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
useMatchStore.getState().toggleShowArchived();
// Should trigger fetch
expect(supabase.from).toHaveBeenCalled();
});
});
describe('isMatchArchived', () => {
it('returns true for archived matches', () => {
useMatchStore.setState({
archivedMatchIds: new Set(['m1', 'm2']),
});
expect(useMatchStore.getState().isMatchArchived('m1')).toBe(true);
expect(useMatchStore.getState().isMatchArchived('m2')).toBe(true);
expect(useMatchStore.getState().isMatchArchived('m3')).toBe(false);
});
});
});
@@ -0,0 +1,499 @@
import { useResponsesStore, groupResponses, ResponseWithQuestion } from '@/store/responsesStore';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { supabase } from '@/lib/supabase';
// Mock authErrorHandler
jest.mock('@/lib/authErrorHandler', () => ({
invokeWithAuthRetry: jest.fn(),
}));
import { invokeWithAuthRetry } from '@/lib/authErrorHandler';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('responsesStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useResponsesStore.setState({
responses: [],
isLoading: false,
isLoadingMore: false,
groupBy: 'date',
dateSortOrder: 'newest',
hasMore: true,
page: 0,
totalCount: null,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
});
describe('fetchResponses', () => {
it('fetches responses with questions and packs on refresh', async () => {
const responses = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-01T00:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
partner_text: null,
intensity: 2,
pack_id: 'p1',
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
},
},
];
const countQuery = createThenableQuery({ count: 1 });
const responsesQuery = createThenableQuery({ data: responses });
const matchesQuery = createThenableQuery({ data: [{ id: 'm1', question_id: 'q1' }] });
const partnerQuery = createThenableQuery({ data: [{ question_id: 'q1' }] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(partnerQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.responses).toHaveLength(1);
expect(state.responses[0].has_match).toBe(true);
expect(state.responses[0].match_id).toBe('m1');
expect(state.responses[0].partner_answered).toBe(true);
expect(state.totalCount).toBe(1);
expect(state.isLoading).toBe(false);
});
it('does nothing if user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('does nothing if couple_id is missing', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('prevents concurrent loading', async () => {
useResponsesStore.setState({ isLoading: true });
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('handles empty responses correctly', async () => {
const countQuery = createThenableQuery({ count: 0 });
const responsesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.responses).toEqual([]);
expect(state.hasMore).toBe(false);
expect(state.totalCount).toBe(0);
});
it('paginates responses correctly', async () => {
// Set up initial state with some responses
useResponsesStore.setState({
page: 1,
hasMore: true,
responses: [{ id: 'r0' }] as any,
});
const responses = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-01T00:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
},
},
];
const responsesQuery = createThenableQuery({ data: responses });
const matchesQuery = createThenableQuery({ data: [] });
const partnerQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(partnerQuery);
await useResponsesStore.getState().fetchResponses(false);
const state = useResponsesStore.getState();
// Should append to existing responses
expect(state.responses).toHaveLength(2);
expect(state.page).toBe(2);
});
it('handles fetch errors gracefully', async () => {
const countQuery = createThenableQuery({ count: 1 });
const responsesQuery = createThenableQuery({ data: null, error: new Error('Network error') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.isLoading).toBe(false);
expect(state.isLoadingMore).toBe(false);
});
});
describe('updateResponse', () => {
it('updates response and refreshes matches on success', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'maybe', has_match: false } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, new_match: { id: 'm1' } },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(result.success).toBe(true);
expect(result.new_match).toEqual({ id: 'm1' });
const state = useResponsesStore.getState();
expect(state.responses[0].answer).toBe('yes');
expect(state.responses[0].has_match).toBe(true);
expect(state.responses[0].match_id).toBe('m1');
expect(fetchMatchesMock).toHaveBeenCalled();
});
it('handles match deletion correctly', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'yes', has_match: true, match_id: 'm1' } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, match_deleted: true },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'no', true);
expect(result.success).toBe(true);
expect(result.match_deleted).toBe(true);
const state = useResponsesStore.getState();
expect(state.responses[0].answer).toBe('no');
expect(state.responses[0].has_match).toBe(false);
expect(state.responses[0].match_id).toBeUndefined();
expect(fetchMatchesMock).toHaveBeenCalled();
});
it('returns requires_confirmation without updating local state', async () => {
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'yes', has_match: true } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: false, requires_confirmation: true, message_count: 5 },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
expect(result.requires_confirmation).toBe(true);
expect(result.message_count).toBe(5);
const state = useResponsesStore.getState();
// Should not update local state
expect(state.responses[0].answer).toBe('yes');
});
it('handles errors from edge function', async () => {
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: null,
error: { message: 'Server error' },
});
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(result.success).toBe(false);
expect(result.error).toBe('Server error');
});
it('refreshes matches when match_type_updated', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'maybe', has_match: true, match_id: 'm1' } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, match_type_updated: true },
error: null,
});
await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(fetchMatchesMock).toHaveBeenCalled();
});
});
describe('setGroupBy', () => {
it('updates groupBy option', () => {
useResponsesStore.getState().setGroupBy('pack');
expect(useResponsesStore.getState().groupBy).toBe('pack');
useResponsesStore.getState().setGroupBy('answer');
expect(useResponsesStore.getState().groupBy).toBe('answer');
useResponsesStore.getState().setGroupBy('date');
expect(useResponsesStore.getState().groupBy).toBe('date');
});
});
describe('setDateSortOrder', () => {
it('updates date sort order', () => {
useResponsesStore.getState().setDateSortOrder('oldest');
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().setDateSortOrder('newest');
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
});
describe('toggleDateSortOrder', () => {
it('toggles between newest and oldest', () => {
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
});
describe('clearResponses', () => {
it('resets store to initial state', () => {
useResponsesStore.setState({
responses: [{ id: 'r1' } as any],
isLoading: true,
groupBy: 'pack',
dateSortOrder: 'oldest',
page: 5,
hasMore: false,
isLoadingMore: true,
totalCount: 10,
});
useResponsesStore.getState().clearResponses();
const state = useResponsesStore.getState();
expect(state.responses).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.groupBy).toBe('date');
expect(state.dateSortOrder).toBe('newest');
expect(state.page).toBe(0);
expect(state.hasMore).toBe(true);
expect(state.isLoadingMore).toBe(false);
expect(state.totalCount).toBeNull();
});
});
});
describe('groupResponses', () => {
const mockResponses: ResponseWithQuestion[] = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-15T10:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
partner_text: null,
intensity: 2,
pack_id: 'p1',
created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
},
has_match: true,
partner_answered: true,
},
{
id: 'r2',
question_id: 'q2',
answer: 'maybe',
created_at: '2024-01-15T14:00:00.000Z',
question: {
id: 'q2',
text: 'Question 2',
partner_text: null,
intensity: 3,
pack_id: 'p2',
created_at: '',
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
},
has_match: false,
partner_answered: false,
},
{
id: 'r3',
question_id: 'q3',
answer: 'no',
created_at: '2024-01-14T10:00:00.000Z',
question: {
id: 'q3',
text: 'Question 3',
partner_text: null,
intensity: 1,
pack_id: 'p1',
created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
},
has_match: false,
partner_answered: true,
},
{
id: 'r4',
question_id: 'q4',
answer: 'yes',
created_at: '2024-01-14T14:00:00.000Z',
question: {
id: 'q4',
text: 'Question 4',
partner_text: null,
intensity: 2,
pack_id: 'p2',
created_at: '',
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
},
has_match: true,
partner_answered: true,
},
];
describe('groupBy pack', () => {
it('groups responses by pack name', () => {
const grouped = groupResponses(mockResponses, 'pack');
expect(grouped).toHaveLength(2);
const packA = grouped.find(g => g.title === 'Pack A');
const packB = grouped.find(g => g.title === 'Pack B');
expect(packA?.data).toHaveLength(2);
expect(packB?.data).toHaveLength(2);
});
});
describe('groupBy answer', () => {
it('groups responses by answer type in order: yes, maybe, no', () => {
const grouped = groupResponses(mockResponses, 'answer');
expect(grouped).toHaveLength(3);
expect(grouped[0].title).toBe('Yes');
expect(grouped[0].data).toHaveLength(2);
expect(grouped[1].title).toBe('Maybe');
expect(grouped[1].data).toHaveLength(1);
expect(grouped[2].title).toBe('No');
expect(grouped[2].data).toHaveLength(1);
});
it('excludes empty answer groups', () => {
const yesOnlyResponses = mockResponses.filter(r => r.answer === 'yes');
const grouped = groupResponses(yesOnlyResponses, 'answer');
expect(grouped).toHaveLength(1);
expect(grouped[0].title).toBe('Yes');
});
});
describe('groupBy date', () => {
it('groups responses by date with newest first', () => {
const grouped = groupResponses(mockResponses, 'date', 'newest');
expect(grouped).toHaveLength(2);
// January 15 should be first (newer)
expect(grouped[0].title).toContain('January 15');
expect(grouped[0].data).toHaveLength(2);
expect(grouped[1].title).toContain('January 14');
expect(grouped[1].data).toHaveLength(2);
});
it('groups responses by date with oldest first', () => {
const grouped = groupResponses(mockResponses, 'date', 'oldest');
expect(grouped).toHaveLength(2);
// January 14 should be first (older)
expect(grouped[0].title).toContain('January 14');
expect(grouped[1].title).toContain('January 15');
});
it('sorts responses within each date group', () => {
const grouped = groupResponses(mockResponses, 'date', 'newest');
// Within January 15, the response at 14:00 should come first (newest)
const jan15Group = grouped[0];
expect(new Date(jan15Group.data[0].created_at).getTime())
.toBeGreaterThan(new Date(jan15Group.data[1].created_at).getTime());
});
});
describe('default grouping', () => {
it('returns all responses in a single group for unknown groupBy', () => {
// @ts-expect-error Testing unknown groupBy value
const grouped = groupResponses(mockResponses, 'unknown');
expect(grouped).toHaveLength(1);
expect(grouped[0].title).toBe('All Responses');
expect(grouped[0].data).toHaveLength(4);
});
});
});
@@ -1,11 +1,12 @@
jest.mock('../../lib/revenuecat', () => {
const service = {
jest.mock('../../lib/revenuecat', () => ({
__esModule: true,
default: {
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),
addCustomerInfoUpdateListener: jest.fn(() => () => {}),
getOfferingsDebug: jest.fn(async () => ({
current: { identifier: 'default', availablePackages: [{ identifier: 'monthly', packageType: 'MONTHLY', product: { price: 1, priceString: '$1', currencyCode: 'USD' } }] },
availableOfferings: ['default'],
@@ -15,24 +16,33 @@ jest.mock('../../lib/revenuecat', () => {
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: {},
};
});
},
get revenueCatService() {
return this.default;
},
SubscriptionState: {},
PurchasesPackage: {},
PurchasesOffering: {},
}));
import { useSubscriptionStore } from '@/store/subscriptionStore';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import revenueCatService from '../../lib/revenuecat';
describe('subscriptionStore', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset mock implementations to defaults
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(true);
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(true);
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: false,
activeSubscription: null,
expirationDate: null,
willRenew: false,
});
useSubscriptionStore.setState({
subscription: { isProUser: false, activeSubscription: null, expirationDate: null, willRenew: false },
offerings: null,
@@ -40,24 +50,236 @@ describe('subscriptionStore', () => {
isPurchasing: false,
error: null,
} as any);
useAuthStore.setState({ user: { id: 'me', is_premium: false } } as any);
useAuthStore.setState({
user: { id: 'me', is_premium: false },
fetchUser: jest.fn(),
} as any);
});
it('fetchOfferings stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
describe('initializeRevenueCat', () => {
it('skips initialization if RevenueCat not available', async () => {
(revenueCatService.isAvailable as jest.Mock).mockReturnValue(false);
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeTruthy();
expect(state.isLoadingOfferings).toBe(false);
expect(state.error).toBeNull();
await useSubscriptionStore.getState().initializeRevenueCat('user1');
expect(revenueCatService.initialize).not.toHaveBeenCalled();
});
it('initializes and logs in when available', async () => {
await useSubscriptionStore.getState().initializeRevenueCat('user1');
expect(revenueCatService.initialize).toHaveBeenCalledWith('user1');
expect(revenueCatService.login).toHaveBeenCalledWith('user1');
expect(revenueCatService.addCustomerInfoUpdateListener).toHaveBeenCalled();
});
it('handles initialization errors gracefully', async () => {
(revenueCatService.initialize as jest.Mock).mockRejectedValueOnce(new Error('Init failed'));
// Should not throw
await useSubscriptionStore.getState().initializeRevenueCat('user1');
// Error is logged but not set in state
expect(useSubscriptionStore.getState().error).toBeNull();
});
});
it('clearSubscription resets state', () => {
useSubscriptionStore.setState({ offerings: { identifier: 'x', availablePackages: [] } } as any);
useSubscriptionStore.getState().clearSubscription();
describe('fetchOfferings', () => {
it('stores offering on success', async () => {
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeNull();
expect(state.subscription.isProUser).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeTruthy();
expect(state.isLoadingOfferings).toBe(false);
expect(state.error).toBeNull();
});
it('sets error if RevenueCat not initialized', async () => {
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(false);
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Subscriptions not available');
expect(state.offerings).toBeNull();
expect(state.isLoadingOfferings).toBe(false);
});
it('handles offering fetch error', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
error: 'Failed to fetch',
});
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Failed to fetch');
expect(state.offerings).toBeNull();
});
it('handles no current offering', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
current: null,
availableOfferings: ['test'],
});
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toContain('No current offering');
});
it('handles empty packages', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockResolvedValueOnce({
current: { identifier: 'default', availablePackages: [] },
});
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toContain('No packages in offering');
});
it('handles exception during fetch', async () => {
(revenueCatService.getOfferingsDebug as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
await useSubscriptionStore.getState().fetchOfferings();
const state = useSubscriptionStore.getState();
expect(state.error).toContain('Error: Network error');
expect(state.isLoadingOfferings).toBe(false);
});
});
describe('purchasePackage', () => {
it('purchases package successfully', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
expirationDate: '2025-01-01',
willRenew: true,
});
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(true);
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
});
it('handles cancelled purchase', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
new Error('Purchase cancelled')
);
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase was cancelled');
expect(state.isPurchasing).toBe(false);
});
it('handles other purchase errors', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
new Error('Payment failed')
);
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase failed. Please try again.');
});
});
describe('restorePurchases', () => {
it('restores purchases successfully when user has pro', async () => {
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'yearly',
expirationDate: '2025-12-01',
willRenew: true,
});
const result = await useSubscriptionStore.getState().restorePurchases();
expect(result).toBe(true);
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
});
it('returns false when no purchases to restore', async () => {
const result = await useSubscriptionStore.getState().restorePurchases();
expect(result).toBe(false);
});
it('handles restore error', async () => {
(revenueCatService.restorePurchases as jest.Mock).mockRejectedValueOnce(
new Error('Restore failed')
);
const result = await useSubscriptionStore.getState().restorePurchases();
expect(result).toBe(false);
const state = useSubscriptionStore.getState();
expect(state.error).toBe('Failed to restore purchases');
});
});
describe('refreshSubscriptionStatus', () => {
it('refreshes subscription status', async () => {
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
expirationDate: '2025-01-01',
willRenew: true,
});
await useSubscriptionStore.getState().refreshSubscriptionStatus();
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
});
it('handles refresh errors gracefully', async () => {
(revenueCatService.getCustomerInfo as jest.Mock).mockRejectedValueOnce(
new Error('Network error')
);
// Should not throw
await useSubscriptionStore.getState().refreshSubscriptionStatus();
});
});
describe('clearSubscription', () => {
it('resets state and logs out', () => {
useSubscriptionStore.setState({
subscription: {
isProUser: true,
activeSubscription: 'monthly',
expirationDate: new Date('2025-01-01'),
willRenew: true,
},
offerings: { identifier: 'x', availablePackages: [] } as any,
error: 'some error',
});
useSubscriptionStore.getState().clearSubscription();
const state = useSubscriptionStore.getState();
expect(state.offerings).toBeNull();
expect(state.subscription.isProUser).toBe(false);
expect(state.subscription.activeSubscription).toBeNull();
expect(state.error).toBeNull();
expect(revenueCatService.logout).toHaveBeenCalled();
});
});
});