mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user