mock mock mock !
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 57m50s

This commit is contained in:
2025-12-19 20:31:04 -08:00
parent e62739810e
commit a3d3ddd772
48 changed files with 1062 additions and 507 deletions

View File

@@ -7,43 +7,28 @@ import App from './App';
import * as aiApiClient from './services/aiApiClient'; // Import aiApiClient
import * as apiClient from './services/apiClient';
import { AppProviders } from './providers/AppProviders';
import type { Flyer, Profile, User, UserProfile } from './types';
import type { Flyer, Profile, User, UserProfile} from './types';
import { createMockFlyer, createMockUserProfile, createMockUser, createMockProfile } from './tests/utils/mockFactories';
import type { HeaderProps } from './components/Header';
import type { ProfileManagerProps } from './pages/admin/components/ProfileManager';
import { mockUseAuth, mockUseFlyers, mockUseMasterItems, mockUseUserData, mockUseFlyerItems } from './tests/setup/mockHooks';
// Mock useAuth to allow overriding the user state in tests
const mockUseAuth = vi.fn();
vi.mock('./hooks/useAuth', () => ({
useAuth: () => mockUseAuth(),
}));
// --- Mock Implementations for Readability ---
// Mock the new data hooks
const mockUseFlyers = vi.fn();
vi.mock('./hooks/useFlyers', () => ({
useFlyers: () => mockUseFlyers(),
}));
const mockUseMasterItems = vi.fn();
vi.mock('./hooks/useMasterItems', () => ({
useMasterItems: () => mockUseMasterItems(),
}));
const mockUseUserData = vi.fn();
vi.mock('./hooks/useUserData', () => ({ useUserData: () => mockUseUserData() }));
// By defining mock components separately, we make the `vi.mock` section cleaner
// and the component logic easier to read and maintain. Using imported prop types
// ensures that our mocks stay in sync with the real components.
const MockHeader: React.FC<HeaderProps> = (props) => (
<header data-testid="header-mock">
<button onClick={props.onOpenProfile}>Open Profile</button>
<button onClick={props.onOpenVoiceAssistant}>Open Voice Assistant</button>
</header>
);
// --- End Mock Implementations ---
// Mock top-level components rendered by App's routes
vi.mock('./components/Header', () => ({ Header: (props: HeaderProps) => <header data-testid="header-mock"><button onClick={props.onOpenProfile}>Open Profile</button><button onClick={props.onOpenVoiceAssistant}>Open Voice Assistant</button></header> }));
vi.mock('./pages/admin/components/ProfileManager', () => ({ ProfileManager: ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }: { isOpen: boolean, onClose: () => void, onProfileUpdate: (p: Profile) => void, onLoginSuccess: (u: User, t: string, r: boolean) => void }) => isOpen ? <div data-testid="profile-manager-mock"><button onClick={onClose}>Close Profile</button><button onClick={() => onProfileUpdate({ user_id: '1', role: 'user', points: 0, full_name: 'Updated' })}>Update Profile</button><button onClick={() => onLoginSuccess({ user_id: '1', email: 'a@b.com' }, 'token', false)}>Login</button></div> : null }));
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({ VoiceAssistant: ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => isOpen ? <div data-testid="voice-assistant-mock"><button onClick={onClose}>Close Voice Assistant</button></div> : null }));
vi.mock('./pages/admin/AdminPage', () => ({ AdminPage: () => <div data-testid="admin-page-mock">Admin Page</div> }));
// In react-router v6, wrapper routes must render an <Outlet /> for nested routes to appear.
vi.mock('./components/AdminRoute', () => ({ AdminRoute: ({ children }: { children: React.ReactNode }) => <div data-testid="admin-route-mock">{children || <Outlet />}</div> }));
vi.mock('./pages/admin/CorrectionsPage', () => ({ CorrectionsPage: () => <div data-testid="corrections-page-mock">Corrections Page</div> }));
vi.mock('./pages/admin/AdminStatsPage', () => ({ AdminStatsPage: () => <div data-testid="admin-stats-page-mock">Admin Stats Page</div> }));
vi.mock('./pages/ResetPasswordPage', () => ({ ResetPasswordPage: () => <div data-testid="reset-password-page-mock">Reset Password Page</div> }));
vi.mock('./pages/admin/VoiceLabPage', () => ({ VoiceLabPage: () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div> })); // Corrected path
vi.mock('./components/WhatsNewModal', () => ({ WhatsNewModal: ({ isOpen, onClose }: { isOpen: boolean, onClose: () => void }) => isOpen ? <div data-testid="whats-new-modal-mock"><button onClick={onClose}>Close What's New</button></div> : null }));
vi.mock('./components/FlyerCorrectionTool', () => ({ FlyerCorrectionTool: ({ isOpen, onClose, onDataExtracted }: { isOpen: boolean, onClose: () => void, onDataExtracted: (type: 'store_name' | 'dates', value: string) => void }) => isOpen ? <div data-testid="flyer-correction-tool-mock"><button onClick={onClose}>Close Correction</button><button onClick={() => onDataExtracted('store_name', 'New Store Name')}>Extract Store</button><button onClick={() => onDataExtracted('dates', '2023-01-01')}>Extract Dates</button></div> : null }));
// Mock the new layout and page components
vi.mock('./layouts/MainLayout', () => ({ MainLayout: () => <div data-testid="main-layout-mock"><Outlet /></div> }));
vi.mock('./pages/HomePage', () => ({ HomePage: (props: { onOpenCorrectionTool: () => void }) => <div data-testid="home-page-mock"><button onClick={props.onOpenCorrectionTool}>Open Correction Tool</button></div> }));
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
// This must be done in any test file that imports App.tsx.
@@ -66,14 +51,9 @@ vi.mock('./config', () => ({
const mockedAiApiClient = vi.mocked(aiApiClient); // Mock aiApiClient
const mockedApiClient = vi.mocked(apiClient);
// Mock the useShoppingLists hook
vi.mock('./hooks/useShoppingLists', () => ({ useShoppingLists: vi.fn() }));
// Mock the useWatchedItems hook
vi.mock('./hooks/useWatchedItems', () => ({ useWatchedItems: vi.fn() }));
const mockFlyers: Flyer[] = [
{ flyer_id: 1, file_name: 'flyer1.jpg', image_url: 'url1', item_count: 10, created_at: '2023-01-01', store: { store_id: 1, name: 'Old Store', created_at: '2023-01-01' } },
{ flyer_id: 2, file_name: 'flyer2.jpg', image_url: 'url2', item_count: 20, created_at: '2023-01-02', store: { store_id: 2, name: 'Another Store', created_at: '2023-01-01' } },
createMockFlyer({ flyer_id: 1, store: { store_id: 1, name: 'Old Store' } }),
createMockFlyer({ flyer_id: 2, store: { store_id: 2, name: 'Another Store' } }),
];
describe('App Component', () => {
@@ -143,6 +123,10 @@ describe('App Component', () => {
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
});
mockUseFlyerItems.mockReturnValue({
flyerItems: [],
isLoading: false,
});
// Clear local storage to prevent state from leaking between tests.
localStorage.clear();
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
@@ -199,14 +183,11 @@ describe('App Component', () => {
});
it('should render the BulkImporter for an admin user', async () => {
const mockAdminProfile: UserProfile = {
const mockAdminProfile: UserProfile = createMockUserProfile({
user_id: 'admin-id',
user: { user_id: 'admin-id', email: 'admin@example.com' },
role: 'admin',
full_name: 'Admin',
avatar_url: '',
points: 0,
};
});
// Force the auth hook to return an authenticated admin user
mockUseAuth.mockReturnValue({
@@ -243,15 +224,11 @@ describe('App Component', () => {
});
it('should render the admin page on the /admin route', async () => {
// Mock a logged-in admin user
const mockAdminProfile: UserProfile = {
const mockAdminProfile: UserProfile = createMockUserProfile({
user_id: 'admin-id',
user: { user_id: 'admin-id', email: 'admin@example.com' },
role: 'admin',
full_name: 'Admin',
avatar_url: '',
points: 0,
};
});
// Directly set the auth state via the mocked hook
mockUseAuth.mockReturnValue({
@@ -277,10 +254,10 @@ describe('App Component', () => {
describe('Theme and Unit System Synchronization', () => {
it('should set dark mode based on user profile preferences', () => {
const profileWithDarkMode: UserProfile = {
const profileWithDarkMode: UserProfile = createMockUserProfile({
user_id: 'user-1', user: { user_id: 'user-1', email: 'dark@mode.com' }, role: 'user', points: 0,
preferences: { darkMode: true }
};
});
mockUseAuth.mockReturnValue({
user: profileWithDarkMode.user, profile: profileWithDarkMode, authStatus: 'AUTHENTICATED',
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
@@ -291,10 +268,10 @@ describe('App Component', () => {
});
it('should set light mode based on user profile preferences', () => {
const profileWithLightMode: UserProfile = {
const profileWithLightMode: UserProfile = createMockUserProfile({
user_id: 'user-1', user: { user_id: 'user-1', email: 'light@mode.com' }, role: 'user', points: 0,
preferences: { darkMode: false }
};
});
mockUseAuth.mockReturnValue({
user: profileWithLightMode.user, profile: profileWithLightMode, authStatus: 'AUTHENTICATED',
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
@@ -316,10 +293,10 @@ describe('App Component', () => {
});
it('should set unit system based on user profile preferences', async () => {
const profileWithMetric: UserProfile = {
const profileWithMetric: UserProfile = createMockUserProfile({
user_id: 'user-1', user: { user_id: 'user-1', email: 'metric@user.com' }, role: 'user', points: 0,
preferences: { unitSystem: 'metric' }
};
});
mockUseAuth.mockReturnValue({
user: profileWithMetric.user, profile: profileWithMetric, authStatus: 'AUTHENTICATED',
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
@@ -454,8 +431,8 @@ describe('App Component', () => {
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
mockUseAuth.mockReturnValue({
user: { user_id: '1', email: 'test@test.com' },
profile: { user_id: '1', role: 'user', points: 0, user: { user_id: '1', email: 'test@test.com' } },
user: createMockUser({ user_id: '1', email: 'test@test.com' }),
profile: createMockUserProfile({ user_id: '1', role: 'user' }),
authStatus: 'AUTHENTICATED',
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
});
@@ -497,10 +474,9 @@ describe('App Component', () => {
});
it('should render admin sub-routes correctly', async () => {
const mockAdminProfile: UserProfile = {
const mockAdminProfile: UserProfile = createMockUserProfile({
user_id: 'admin-id', user: { user_id: 'admin-id', email: 'admin@example.com' }, role: 'admin',
full_name: 'Admin', avatar_url: '', points: 0,
};
});
mockUseAuth.mockReturnValue({
user: mockAdminProfile.user, profile: mockAdminProfile, authStatus: 'AUTHENTICATED',
isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
@@ -597,7 +573,8 @@ describe('App Component', () => {
const profileManager = await screen.findByTestId('profile-manager-mock');
fireEvent.click(within(profileManager).getByText('Update Profile'));
expect(mockUpdateProfile).toHaveBeenCalledWith({ user_id: '1', role: 'user', points: 0, full_name: 'Updated' });
// The mock now returns a more complete object
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.objectContaining({ full_name: 'Updated' }));
});
it('should set an error state if login fails inside handleLoginSuccess', async () => {

View File

@@ -3,35 +3,20 @@ import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { AchievementsList } from './AchievementsList';
import { Achievement, UserAchievement } from '../types';
/**
* A mock factory for creating achievement data for tests.
* This makes the test setup cleaner and more reusable.
* @param overrides - Partial data to override the defaults.
*/
const createMockAchievement = (overrides: Partial<UserAchievement & Achievement>): (UserAchievement & Achievement) => ({
achievement_id: 1,
user_id: 'user-123',
achieved_at: new Date().toISOString(),
name: 'Test Achievement',
description: 'A default description.',
icon: 'heart',
points_value: 10,
...overrides,
});
import { createMockUserAchievement } from '../tests/utils/mockFactories';
describe('AchievementsList', () => {
it('should render the list of achievements with correct details', () => {
const mockAchievements = [
createMockAchievement({
createMockUserAchievement({
achievement_id: 1,
name: 'Recipe Creator',
description: 'Create your first recipe.',
icon: 'chef-hat',
points_value: 25,
}),
createMockAchievement({ name: 'List Maker', icon: 'list', points_value: 15 }),
createMockAchievement({ name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
createMockUserAchievement({ achievement_id: 2, name: 'List Maker', icon: 'list', points_value: 15 }),
createMockUserAchievement({ achievement_id: 3, name: 'Unknown Achievement', icon: 'star' }), // This icon is not in the component's map
];
render(<AchievementsList achievements={mockAchievements} />);

View File

@@ -4,7 +4,11 @@ import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { AdminRoute } from './AdminRoute';
import type { Profile } from '../types'; // Path remains the same
import type { Profile } from '../types';
import { createMockProfile } from '../tests/utils/mockFactories';
// Unmock the component to test the real implementation
vi.unmock('./AdminRoute');
const AdminContent = () => <div>Admin Page Content</div>;
const HomePage = () => <div>Home Page</div>;
@@ -24,7 +28,7 @@ const renderWithRouter = (profile: Profile | null, initialPath: string) => {
describe('AdminRoute', () => {
it('should render the admin content when user has admin role', () => {
const adminProfile: Profile = { user_id: '1', role: 'admin', points: 0 };
const adminProfile: Profile = createMockProfile({ user_id: '1', role: 'admin' });
renderWithRouter(adminProfile, '/admin');
expect(screen.getByText('Admin Page Content')).toBeInTheDocument();
@@ -32,7 +36,7 @@ describe('AdminRoute', () => {
});
it('should redirect to home page when user does not have admin role', () => {
const userProfile: Profile = { user_id: '2', role: 'user', points: 0 };
const userProfile: Profile = createMockProfile({ user_id: '2', role: 'user' });
renderWithRouter(userProfile, '/admin');
// The user is redirected, so we should see the home page content

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import type { Profile } from '../types';
interface AdminRouteProps {
export interface AdminRouteProps {
profile: Profile | null;
}

View File

@@ -6,6 +6,9 @@ import { FlyerCorrectionTool } from './FlyerCorrectionTool';
import * as aiApiClient from '../services/aiApiClient';
import { notifyError, notifySuccess } from '../services/notificationService';
// Unmock the component to test the real implementation
vi.unmock('./FlyerCorrectionTool');
// Mock dependencies
vi.mock('../services/aiApiClient');
vi.mock('../services/notificationService');

View File

@@ -7,7 +7,7 @@ import * as aiApiClient from '../services/aiApiClient';
import { notifyError, notifySuccess } from '../services/notificationService';
import { logger } from '../services/logger.client';
interface FlyerCorrectionToolProps {
export interface FlyerCorrectionToolProps {
isOpen: boolean;
onClose: () => void;
imageUrl: string;

View File

@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerCountDisplay } from './FlyerCountDisplay';
import { useFlyers } from '../hooks/useFlyers';
import type { Flyer } from '../types';
import { createMockFlyer } from '../tests/utils/mockFactories';
// Mock the dependencies
vi.mock('../hooks/useFlyers');
@@ -60,10 +61,7 @@ describe('FlyerCountDisplay', () => {
it('should render the flyer count when data is successfully loaded', () => {
// Arrange: Override the mock to return a successful state with some data.
const mockFlyers: Flyer[] = [
{ flyer_id: 1, file_name: 'flyer1.pdf', image_url: '', item_count: 10, created_at: '' },
{ flyer_id: 2, file_name: 'flyer2.pdf', image_url: '', item_count: 20, created_at: '' },
];
const mockFlyers: Flyer[] = [createMockFlyer(), createMockFlyer()];
mockedUseFlyers.mockReturnValue({
flyers: mockFlyers,
isLoadingFlyers: false,

View File

@@ -4,11 +4,15 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { Header } from './Header';
import type { User, Profile } from '../types'; // Path remains the same
import type { Profile } from '../types';
import { createMockProfile, createMockUser } from '../tests/utils/mockFactories';
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
const mockProfile: Profile = { user_id: 'user-123', role: 'user', points: 0 };
const mockAdminProfile: Profile = { user_id: 'user-123', role: 'admin', points: 0 };
// Unmock the component to test the real implementation
vi.unmock('./Header');
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockProfile: Profile = createMockProfile({ user_id: 'user-123', role: 'user', points: 0 });
const mockAdminProfile: Profile = createMockProfile({ user_id: 'user-123', role: 'admin', points: 0 });
const mockOnOpenProfile = vi.fn();
const mockOnOpenVoiceAssistant = vi.fn();

View File

@@ -5,6 +5,8 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import Leaderboard from './Leaderboard';
import * as apiClient from '../services/apiClient';
import { LeaderboardUser } from '../types';
import { createMockLeaderboardUser } from '../tests/utils/mockFactories';
import { createMockLogger } from '../tests/utils/mockLogger';
// Mock the apiClient
vi.mock('../services/apiClient'); // This was correct
@@ -12,9 +14,7 @@ const mockedApiClient = apiClient as Mocked<typeof apiClient>;
// Mock the logger
vi.mock('../services/logger', () => ({
logger: {
error: vi.fn(),
},
logger: createMockLogger(),
}));
// Mock lucide-react icons to prevent rendering errors in the test environment
@@ -25,10 +25,10 @@ vi.mock('lucide-react', () => ({
}));
const mockLeaderboardData: LeaderboardUser[] = [
{ user_id: 'user-1', full_name: 'Alice', avatar_url: null, points: 1000, rank: '1' },
{ user_id: 'user-2', full_name: 'Bob', avatar_url: 'http://example.com/bob.jpg', points: 950, rank: '2' },
{ user_id: 'user-3', full_name: 'Charlie', avatar_url: null, points: 900, rank: '3' },
{ user_id: 'user-4', full_name: 'Diana', avatar_url: null, points: 850, rank: '4' },
createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Alice', points: 1000, rank: '1' }),
createMockLeaderboardUser({ user_id: 'user-2', full_name: 'Bob', avatar_url: 'http://example.com/bob.jpg', points: 950, rank: '2' }),
createMockLeaderboardUser({ user_id: 'user-3', full_name: 'Charlie', points: 900, rank: '3' }),
createMockLeaderboardUser({ user_id: 'user-4', full_name: 'Diana', points: 850, rank: '4' }),
];
describe('Leaderboard', () => {
@@ -112,7 +112,7 @@ describe('Leaderboard', () => {
it('should handle users with missing names correctly', async () => {
const dataWithMissingNames: LeaderboardUser[] = [
{ user_id: 'user-anon', full_name: null, avatar_url: null, points: 500, rank: '5' },
createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }),
];
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify(dataWithMissingNames)));
render(<Leaderboard />);

View File

@@ -4,6 +4,9 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WhatsNewModal } from './WhatsNewModal';
// Unmock the component to test the real implementation
vi.unmock('./WhatsNewModal');
describe('WhatsNewModal', () => {
const mockOnClose = vi.fn();
const defaultProps = {

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { XCircleIcon } from './icons/XCircleIcon';
import { GiftIcon } from './icons/GiftIcon';
interface WhatsNewModalProps {
export interface WhatsNewModalProps {
isOpen: boolean;
onClose: () => void;
version: string;

View File

@@ -2,11 +2,12 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AnalysisPanel } from './AnalysisPanel';
import { AnalysisPanel, AnalysisTabType } from './AnalysisPanel';
import { useFlyerItems } from '../../hooks/useFlyerItems';
import type { Flyer, FlyerItem, Store, MasterGroceryItem } from '../../types';
import { useUserData } from '../../hooks/useUserData';
import { useAiAnalysis } from '../../hooks/useAiAnalysis';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockStore } from '../../tests/utils/mockFactories';
// Mock the logger
vi.mock('../../services/logger.client', () => ({
@@ -39,24 +40,19 @@ vi.mock('../../components/icons/ScaleIcon', () => ({ ScaleIcon: () => <div data-
const mockRunAnalysis = vi.fn();
const mockGenerateImage = vi.fn();
const mockFlyerItems: FlyerItem[] = [
{ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
];
const mockFlyerItems: FlyerItem[] = [createMockFlyerItem({ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1 })];
const mockWatchedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 101, name: 'Bananas', created_at: '' },
{ master_grocery_item_id: 102, name: 'Milk', created_at: '' },
createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Bananas' }),
createMockMasterGroceryItem({ master_grocery_item_id: 102, name: 'Milk' }),
];
const mockStore: Store = { store_id: 1, name: 'SuperMart', created_at: '' };
const mockFlyer: Flyer = {
flyer_id: 1,
created_at: '2024-01-01',
file_name: 'flyer.pdf',
image_url: 'http://example.com/flyer.jpg',
item_count: 1,
valid_from: '2024-01-01',
valid_to: '2024-01-07',
store: mockStore,
};
const mockStore: Store = createMockStore({ store_id: 1, name: 'SuperMart' });
const mockFlyer: Flyer = createMockFlyer({
flyer_id: 1,
store: mockStore,
});
describe('AnalysisPanel', () => {
beforeEach(() => {

View File

@@ -23,7 +23,7 @@ interface AnalysisPanelProps {
* that the `activeTab` state can only be one of these values, preventing
* type errors when indexing into results or loading state objects.
*/
type AnalysisTabType = Exclude<AnalysisType, AnalysisType.GENERATE_IMAGE>;
export type AnalysisTabType = Exclude<AnalysisType, AnalysisType.GENERATE_IMAGE>;
interface TabButtonProps {
label: string;

View File

@@ -3,8 +3,9 @@ import React from 'react';
import { render, screen, fireEvent, within, prettyDOM } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtractedDataTable, ExtractedDataTableProps } from './ExtractedDataTable';
import type { FlyerItem, MasterGroceryItem, ShoppingList, User } from '../../types';
import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../../types';
import { useAuth } from '../../hooks/useAuth';
import { createMockFlyerItem, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockUser } from '../../tests/utils/mockFactories';
import { useUserData } from '../../hooks/useUserData';
import { useMasterItems } from '../../hooks/useMasterItems';
import { useWatchedItems } from '../../hooks/useWatchedItems';
@@ -17,27 +18,27 @@ vi.mock('../../hooks/useMasterItems');
vi.mock('../../hooks/useWatchedItems');
vi.mock('../../hooks/useShoppingLists');
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockMasterItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', created_at: '' },
{ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', created_at: '' },
{ master_grocery_item_id: 3, name: 'Chicken Breast', category_id: 3, category_name: 'Meat', created_at: '' },
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce' }),
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy' }),
createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Chicken Breast', category_id: 3, category_name: 'Meat' }),
];
const mockFlyerItems: FlyerItem[] = [
{ flyer_item_id: 101, item: 'Gala Apples', price_display: '$1.99/lb', price_in_cents: 199, quantity: 'per lb', unit_price: { value: 1.99, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
{ flyer_item_id: 102, item: '2% Milk', price_display: '$4.50', price_in_cents: 450, quantity: '4L', unit_price: { value: 1.125, unit: 'L' }, master_item_id: 2, category_name: 'Dairy', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
{ flyer_item_id: 103, item: 'Boneless Chicken', price_display: '$8.00/kg', price_in_cents: 800, quantity: 'per kg', unit_price: { value: 8.00, unit: 'kg' }, master_item_id: 3, category_name: 'Meat', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
{ flyer_item_id: 104, item: 'Mystery Soda', price_display: '$1.00', price_in_cents: 100, quantity: '1 can', unit_price: { value: 1.00, unit: 'can' }, master_item_id: undefined, category_name: 'Beverages', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' }, // Unmatched item
{ flyer_item_id: 105, item: 'Apples', price_display: '$2.50/lb', price_in_cents: 250, quantity: 'per lb', unit_price: { value: 2.50, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' }, // Item name matches canonical name
createMockFlyerItem({ flyer_item_id: 101, item: 'Gala Apples', price_display: '$1.99/lb', price_in_cents: 199, quantity: 'per lb', unit_price: { value: 1.99, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1 }),
createMockFlyerItem({ flyer_item_id: 102, item: '2% Milk', price_display: '$4.50', price_in_cents: 450, quantity: '4L', unit_price: { value: 1.125, unit: 'L' }, master_item_id: 2, category_name: 'Dairy', flyer_id: 1 }),
createMockFlyerItem({ flyer_item_id: 103, item: 'Boneless Chicken', price_display: '$8.00/kg', price_in_cents: 800, quantity: 'per kg', unit_price: { value: 8.00, unit: 'kg' }, master_item_id: 3, category_name: 'Meat', flyer_id: 1 }),
createMockFlyerItem({ flyer_item_id: 104, item: 'Mystery Soda', price_display: '$1.00', price_in_cents: 100, quantity: '1 can', unit_price: { value: 1.00, unit: 'can' }, master_item_id: undefined, category_name: 'Beverages', flyer_id: 1 }), // Unmatched item
createMockFlyerItem({ flyer_item_id: 105, item: 'Apples', price_display: '$2.50/lb', price_in_cents: 250, quantity: 'per lb', unit_price: { value: 2.50, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1 }), // Item name matches canonical name
];
const mockShoppingLists: ShoppingList[] = [
{
shopping_list_id: 1, name: 'My List', user_id: 'user-123', created_at: '',
items: [{ shopping_list_item_id: 1, shopping_list_id: 1, master_item_id: 2, quantity: 1, is_purchased: false, added_at: '' }], // Contains Milk
},
createMockShoppingList({
shopping_list_id: 1, name: 'My List', user_id: 'user-123',
items: [createMockShoppingListItem({ shopping_list_item_id: 1, shopping_list_id: 1, master_item_id: 2, quantity: 1, is_purchased: false })], // Contains Milk
}),
];
const mockAddWatchedItem = vi.fn();

View File

@@ -3,14 +3,9 @@ import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerDisplay } from './FlyerDisplay';
import type { Store } from '../../types';
import { createMockStore } from '../../tests/utils/mockFactories';
const mockStore: Store = {
store_id: 1,
name: 'SuperMart',
logo_url: 'http://example.com/logo.png',
created_at: new Date().toISOString(),
};
const mockStore = createMockStore({ store_id: 1, name: 'SuperMart', logo_url: 'http://example.com/logo.png' });
const mockOnOpenCorrectionTool = vi.fn();

View File

@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { FlyerList, formatShortDate } from './FlyerList';
import type { Flyer, UserProfile } from '../../types';
import { createMockUserProfile } from '../../tests/utils/mockFactories';
import { createMockFlyer } from '../../tests/utils/mockFactories';
import * as apiClient from '../../services/apiClient';
import toast from 'react-hot-toast';
@@ -13,37 +14,27 @@ vi.mock('../../services/apiClient');
vi.mock('react-hot-toast', () => ({ default: { success: vi.fn(), error: vi.fn() } }));
const mockFlyers: Flyer[] = [
{
createMockFlyer({
flyer_id: 1,
created_at: '2023-10-01T10:00:00Z',
file_name: 'metro_flyer_oct_1.pdf',
item_count: 50,
image_url: 'http://example.com/flyer1.jpg',
store: {
store_id: 101,
name: 'Metro',
created_at: '2023-01-01T00:00:00Z',
},
store: { store_id: 101, name: 'Metro' },
valid_from: '2023-10-05',
valid_to: '2023-10-11',
},
{
created_at: '2023-10-01T10:00:00Z',
}),
createMockFlyer({
flyer_id: 2,
created_at: '2023-10-02T11:00:00Z',
file_name: 'walmart_flyer.pdf',
item_count: 75,
image_url: 'http://example.com/flyer2.jpg',
store: {
store_id: 102,
name: 'Walmart',
created_at: '2023-01-01T00:00:00Z',
},
store: { store_id: 102, name: 'Walmart' },
valid_from: '2023-10-06',
valid_to: '2023-10-06', // Same day
},
{
}),
createMockFlyer({
flyer_id: 3,
created_at: '2023-10-03T12:00:00Z',
file_name: 'no-store-flyer.pdf',
item_count: 10,
image_url: 'http://example.com/flyer3.jpg',
@@ -52,21 +43,18 @@ const mockFlyers: Flyer[] = [
valid_from: '2023-10-07',
valid_to: '2023-10-08',
store_address: '456 Side St, Ottawa',
},
{
created_at: '2023-10-03T12:00:00Z',
}),
createMockFlyer({
flyer_id: 4,
created_at: 'invalid-date',
file_name: 'bad-date-flyer.pdf',
item_count: 5,
image_url: 'http://example.com/flyer4.jpg',
store: {
store_id: 103,
name: 'Date Store',
created_at: '2023-01-01T00:00:00Z',
},
store: { store_id: 103, name: 'Date Store' },
created_at: 'invalid-date',
valid_from: 'invalid-from',
valid_to: null,
},
}),
];
const mockedApiClient = apiClient as Mocked<typeof apiClient>;

View File

@@ -6,6 +6,9 @@ import { VoiceAssistant } from './VoiceAssistant';
import * as aiApiClient from '../../services/aiApiClient';
import { encode } from '../../utils/audioUtils';
// Unmock the component to test the real implementation
vi.unmock('./VoiceAssistant');
// Mock dependencies to isolate the component
vi.mock('../../services/aiApiClient', () => ({
startVoiceSession: vi.fn(),

View File

@@ -8,7 +8,7 @@ import { encode } from '../../utils/audioUtils';
import { logger } from '../../services/logger.client';
import { XMarkIcon } from '../../components/icons/XMarkIcon';
interface VoiceAssistantProps {
export interface VoiceAssistantProps {
isOpen: boolean;
onClose: () => void;
}

View File

@@ -3,9 +3,9 @@ import { renderHook, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useActiveDeals } from './useActiveDeals';
import * as apiClient from '../services/apiClient';
import { useFlyers } from '../hooks/useFlyers';
import { useUserData } from '../hooks/useUserData';
import type { Flyer, MasterGroceryItem, FlyerItem, DealItem } from '../types';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem } from '../tests/utils/mockFactories';
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
// Explicitly mock apiClient to ensure stable spies are used
vi.mock('../services/apiClient', () => ({
@@ -16,14 +16,6 @@ vi.mock('../services/apiClient', () => ({
// The apiClient is globally mocked in our test setup, so we just need to cast it
const mockedApiClient = vi.mocked(apiClient);
// Mock the new data provider hooks
vi.mock('../hooks/useFlyers');
vi.mock('../hooks/useUserData');
// Create typed mocks for easier usage
const mockedUseFlyers = vi.mocked(useFlyers);
const mockedUseUserData = vi.mocked(useUserData);
// Mock the logger to prevent console noise
vi.mock('../services/logger.client', () => ({
logger: {
@@ -46,7 +38,7 @@ describe('useActiveDeals Hook', () => {
vi.clearAllMocks();
// Set up default successful mocks for the new data hooks
mockedUseFlyers.mockReturnValue({
mockUseFlyers.mockReturnValue({
flyers: mockFlyers,
isLoadingFlyers: false,
flyersError: null,
@@ -55,7 +47,7 @@ describe('useActiveDeals Hook', () => {
isRefetchingFlyers: false,
refetchFlyers: vi.fn(),
});
mockedUseUserData.mockReturnValue({
mockUseUserData.mockReturnValue({
watchedItems: mockWatchedItems,
shoppingLists: [],
setWatchedItems: vi.fn(),
@@ -71,23 +63,61 @@ describe('useActiveDeals Hook', () => {
const mockFlyers: Flyer[] = [
// A currently valid flyer
{ flyer_id: 1, file_name: 'valid.pdf', image_url: '', item_count: 10, created_at: '', valid_from: '2024-01-10', valid_to: '2024-01-20', store: { store_id: 1, name: 'Valid Store', created_at: '', logo_url: '' } },
createMockFlyer({
flyer_id: 1,
file_name: 'valid.pdf',
item_count: 10,
valid_from: '2024-01-10',
valid_to: '2024-01-20',
store: { store_id: 1, name: 'Valid Store' },
}),
// An expired flyer
{ flyer_id: 2, file_name: 'expired.pdf', image_url: '', item_count: 5, created_at: '', valid_from: '2024-01-01', valid_to: '2024-01-05', store: { store_id: 2, name: 'Expired Store', created_at: '', logo_url: '' } },
createMockFlyer({
flyer_id: 2,
file_name: 'expired.pdf',
item_count: 5,
valid_from: '2024-01-01',
valid_to: '2024-01-05',
store: { store_id: 2, name: 'Expired Store' },
}),
// A future flyer
{ flyer_id: 3, file_name: 'future.pdf', image_url: '', item_count: 8, created_at: '', valid_from: '2024-02-01', valid_to: '2024-02-10', store: { store_id: 3, name: 'Future Store', created_at: '', logo_url: '' } },
createMockFlyer({
flyer_id: 3,
file_name: 'future.pdf',
item_count: 8,
valid_from: '2024-02-01',
valid_to: '2024-02-10',
store: { store_id: 3, name: 'Future Store' },
}),
];
const mockWatchedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 101, name: 'Apples', created_at: '' },
{ master_grocery_item_id: 102, name: 'Milk', created_at: '' },
createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Apples' }),
createMockMasterGroceryItem({ master_grocery_item_id: 102, name: 'Milk' }),
];
const mockFlyerItems: FlyerItem[] = [
// A deal for a watched item in a valid flyer
{ flyer_item_id: 1, flyer_id: 1, item: 'Red Apples', price_display: '$1.99', price_in_cents: 199, quantity: 'lb', master_item_id: 101, master_item_name: 'Apples', created_at: '', view_count: 0, click_count: 0, updated_at: '' },
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 1,
item: 'Red Apples',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'lb',
master_item_id: 101,
master_item_name: 'Apples',
}),
// An item that is not a deal
{ flyer_item_id: 2, flyer_id: 1, item: 'Oranges', price_display: '$2.49', price_in_cents: 249, quantity: 'lb', master_item_id: 201, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
createMockFlyerItem({
flyer_item_id: 2,
flyer_id: 1,
item: 'Oranges',
price_display: '$2.49',
price_in_cents: 249,
quantity: 'lb',
master_item_id: 201,
}),
];
it('should return loading state initially and then calculated data', async () => {
@@ -126,7 +156,7 @@ describe('useActiveDeals Hook', () => {
it('should not fetch flyer items if there are no watched items', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
mockedUseUserData.mockReturnValue({
mockUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
@@ -148,7 +178,7 @@ describe('useActiveDeals Hook', () => {
it('should handle the case where there are no valid flyers', async () => {
// Override flyers mock to only include invalid ones
mockedUseFlyers.mockReturnValue({
mockUseFlyers.mockReturnValue({
flyers: [mockFlyers[1], mockFlyers[2]],
isLoadingFlyers: false,
flyersError: null,
@@ -219,19 +249,26 @@ describe('useActiveDeals Hook', () => {
it('should use "Unknown Store" as a fallback if flyer has no store or store name', async () => {
// Create a flyer with a null store object
const flyerWithoutStore: Flyer = {
const flyerWithoutStore = createMockFlyer({
flyer_id: 4,
file_name: 'no-store.pdf',
image_url: '',
item_count: 1,
created_at: '',
valid_from: '2024-01-10',
valid_to: '2024-01-20',
store: null as any, // Explicitly set to null
};
const itemInFlyerWithoutStore: FlyerItem = { flyer_item_id: 3, flyer_id: 4, item: 'Mystery Item', price_display: '$5.00', price_in_cents: 500, master_item_id: 101, master_item_name: 'Apples', created_at: '', view_count: 0, click_count: 0, updated_at: '' };
});
(flyerWithoutStore as any).store = null; // Explicitly set to null
mockedUseFlyers.mockReturnValue({ ...mockedUseFlyers(), flyers: [flyerWithoutStore] });
const itemInFlyerWithoutStore = createMockFlyerItem({
flyer_item_id: 3,
flyer_id: 4,
item: 'Mystery Item',
price_display: '$5.00',
price_in_cents: 500,
master_item_id: 101,
master_item_name: 'Apples',
});
mockUseFlyers.mockReturnValue({ ...mockUseFlyers(), flyers: [flyerWithoutStore] });
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 1 })));
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([itemInFlyerWithoutStore])));
@@ -249,11 +286,11 @@ describe('useActiveDeals Hook', () => {
const mixedItems: FlyerItem[] = [
// Watched item (Master ID 101 is in mockWatchedItems)
{ flyer_item_id: 1, flyer_id: 1, item: 'Watched Item', price_display: '$1.00', price_in_cents: 100, quantity: 'ea', master_item_id: 101, master_item_name: 'Apples', created_at: '', view_count: 0, click_count: 0, updated_at: '' },
createMockFlyerItem({ flyer_item_id: 1, flyer_id: 1, item: 'Watched Item', price_display: '$1.00', price_in_cents: 100, quantity: 'ea', master_item_id: 101, master_item_name: 'Apples' }),
// Unwatched item (Master ID 999 is NOT in mockWatchedItems)
{ flyer_item_id: 2, flyer_id: 1, item: 'Unwatched Item', price_display: '$2.00', price_in_cents: 200, quantity: 'ea', master_item_id: 999, master_item_name: 'Unknown', created_at: '', view_count: 0, click_count: 0, updated_at: '' },
createMockFlyerItem({ flyer_item_id: 2, flyer_id: 1, item: 'Unwatched Item', price_display: '$2.00', price_in_cents: 200, quantity: 'ea', master_item_id: 999, master_item_name: 'Unknown' }),
// Item with no master ID
{ flyer_item_id: 3, flyer_id: 1, item: 'No Master ID', price_display: '$3.00', price_in_cents: 300, quantity: 'ea', master_item_id: undefined, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
createMockFlyerItem({ flyer_item_id: 3, flyer_id: 1, item: 'No Master ID', price_display: '$3.00', price_in_cents: 300, quantity: 'ea', master_item_id: undefined }),
];
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify(mixedItems)));
@@ -307,8 +344,8 @@ describe('useActiveDeals Hook', () => {
});
// Change watched items
const newWatchedItems = [...mockWatchedItems, { master_grocery_item_id: 103, name: 'Bread', created_at: '' }];
mockedUseUserData.mockReturnValue({
const newWatchedItems = [...mockWatchedItems, createMockMasterGroceryItem({ master_grocery_item_id: 103, name: 'Bread' })];
mockUseUserData.mockReturnValue({
watchedItems: newWatchedItems,
shoppingLists: [],
setWatchedItems: vi.fn(),
@@ -330,16 +367,16 @@ describe('useActiveDeals Hook', () => {
// TODAY is 2024-01-15T12:00:00.000Z
const boundaryFlyers: Flyer[] = [
// Ends today
{ flyer_id: 10, file_name: 'ends-today.pdf', image_url: '', item_count: 1, created_at: '', valid_from: '2024-01-01', valid_to: '2024-01-15', store: { store_id: 1, name: 'Store A', created_at: '', logo_url: '' } },
createMockFlyer({ flyer_id: 10, file_name: 'ends-today.pdf', item_count: 1, valid_from: '2024-01-01', valid_to: '2024-01-15', store: { store_id: 1, name: 'Store A' } }),
// Starts today
{ flyer_id: 11, file_name: 'starts-today.pdf', image_url: '', item_count: 1, created_at: '', valid_from: '2024-01-15', valid_to: '2024-01-30', store: { store_id: 1, name: 'Store B', created_at: '', logo_url: '' } },
createMockFlyer({ flyer_id: 11, file_name: 'starts-today.pdf', item_count: 1, valid_from: '2024-01-15', valid_to: '2024-01-30', store: { store_id: 1, name: 'Store B' } }),
// Valid only today
{ flyer_id: 12, file_name: 'only-today.pdf', image_url: '', item_count: 1, created_at: '', valid_from: '2024-01-15', valid_to: '2024-01-15', store: { store_id: 1, name: 'Store C', created_at: '', logo_url: '' } },
createMockFlyer({ flyer_id: 12, file_name: 'only-today.pdf', item_count: 1, valid_from: '2024-01-15', valid_to: '2024-01-15', store: { store_id: 1, name: 'Store C' } }),
// Ends yesterday (invalid)
{ flyer_id: 13, file_name: 'ends-yesterday.pdf', image_url: '', item_count: 1, created_at: '', valid_from: '2024-01-01', valid_to: '2024-01-14', store: { store_id: 1, name: 'Store D', created_at: '', logo_url: '' } },
createMockFlyer({ flyer_id: 13, file_name: 'ends-yesterday.pdf', item_count: 1, valid_from: '2024-01-01', valid_to: '2024-01-14', store: { store_id: 1, name: 'Store D' } }),
];
mockedUseFlyers.mockReturnValue({
mockUseFlyers.mockReturnValue({
flyers: boundaryFlyers,
isLoadingFlyers: false,
flyersError: null,
@@ -361,7 +398,7 @@ describe('useActiveDeals Hook', () => {
});
it('should handle missing price_in_cents and quantity in deal items', async () => {
const incompleteItem = {
const incompleteItem = createMockFlyerItem({
flyer_item_id: 99,
flyer_id: 1,
item: 'Incomplete Item',
@@ -369,11 +406,9 @@ describe('useActiveDeals Hook', () => {
// price_in_cents and quantity are missing
master_item_id: 101,
master_item_name: 'Apples',
created_at: '',
view_count: 0,
click_count: 0,
updated_at: ''
} as FlyerItem;
price_in_cents: null,
quantity: undefined,
});
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 1 })));
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([incompleteItem])));

View File

@@ -3,8 +3,8 @@ import React from 'react';
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
import { useAiAnalysis, aiAnalysisReducer } from './useAiAnalysis';
import { AnalysisType } from '../types';
import type { Flyer, FlyerItem, MasterGroceryItem } from '../types';
import { AnalysisType, Flyer, FlyerItem, MasterGroceryItem } from '../types';
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem } from '../tests/utils/mockFactories';
import { logger } from '../services/logger.client';
import { AiAnalysisService } from '../services/aiAnalysisService';
@@ -22,9 +22,16 @@ vi.mock('../services/logger.client', () => ({
}));
// 2. Mock data
const mockFlyerItems: FlyerItem[] = [{ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' }];
const mockWatchedItems: MasterGroceryItem[] = [{ master_grocery_item_id: 101, name: 'Bananas', created_at: '' }];
const mockSelectedFlyer: Flyer = { flyer_id: 1, store: { store_id: 1, name: 'SuperMart', created_at: '' } } as Flyer;
const mockFlyerItems: FlyerItem[] = [
createMockFlyerItem({ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1 }),
];
const mockWatchedItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Bananas' }),
];
const mockSelectedFlyer: Flyer = createMockFlyer({
flyer_id: 1,
store: { store_id: 1, name: 'SuperMart' },
});
describe('useAiAnalysis Hook', () => {
let mockService: Mocked<AiAnalysisService>;

View File

@@ -6,6 +6,7 @@ import { useAuth } from './useAuth';
import { AuthProvider } from '../providers/AuthProvider';
import * as apiClient from '../services/apiClient';
import type { User, UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock the dependencies
vi.mock('../services/apiClient', () => ({
@@ -24,15 +25,14 @@ vi.mock('../services/logger.client', () => ({
const mockedApiClient = vi.mocked(apiClient);
// Define mock data
const mockUser: User = { user_id: 'user-abc-123', email: 'test@example.com' };
const mockProfile: UserProfile = {
const mockProfile: UserProfile = createMockUserProfile({
user_id: 'user-abc-123',
full_name: 'Test User',
points: 100,
role: 'user',
user: mockUser,
};
user: { user_id: 'user-abc-123', email: 'test@example.com' },
});
const mockUser: User = mockProfile.user;
// Reusable wrapper for rendering the hook within the provider
const wrapper = ({ children }: { children: ReactNode }) => <AuthProvider>{children}</AuthProvider>;

View File

@@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useFlyerItems } from './useFlyerItems';
import { useApiOnMount } from './useApiOnMount';
import type { Flyer, FlyerItem } from '../types';
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
import * as apiClient from '../services/apiClient';
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
@@ -13,7 +13,7 @@ vi.mock('../services/apiClient');
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
describe('useFlyerItems Hook', () => {
const mockFlyer: Flyer = {
const mockFlyer = createMockFlyer({
flyer_id: 123,
file_name: 'test-flyer.jpg',
image_url: '/test.jpg',
@@ -21,17 +21,15 @@ describe('useFlyerItems Hook', () => {
checksum: 'abc',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
item_count: 1,
store: {
store_id: 1,
name: 'Test Store',
created_at: new Date().toISOString(),
},
item_count: 1,
created_at: new Date().toISOString(),
};
});
const mockFlyerItems: FlyerItem[] = [
{ flyer_item_id: 1, flyer_id: 123, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', created_at: new Date().toISOString(), view_count: 0, click_count: 0, updated_at: new Date().toISOString() },
const mockFlyerItems = [
createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb' }),
];
beforeEach(() => {

View File

@@ -6,6 +6,7 @@ import { useFlyers } from './useFlyers';
import { FlyersProvider } from '../providers/FlyersProvider';
import { useInfiniteQuery } from './useInfiniteQuery';
import type { Flyer } from '../types';
import { createMockFlyer } from '../tests/utils/mockFactories';
// 1. Mock the useInfiniteQuery hook, which is the dependency of our FlyersProvider.
vi.mock('./useInfiniteQuery');
@@ -64,7 +65,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
it('should return flyers data and hasNextPage on successful fetch', () => {
// Arrange: Mock a successful data fetch.
const mockFlyers: Flyer[] = [
{ flyer_id: 1, file_name: 'flyer1.jpg', image_url: 'url1', item_count: 5, created_at: '2024-01-01' },
createMockFlyer({ flyer_id: 1, file_name: 'flyer1.jpg', image_url: 'url1', item_count: 5, created_at: '2024-01-01' }),
];
mockedUseInfiniteQuery.mockReturnValue({
data: mockFlyers,

View File

@@ -6,6 +6,7 @@ import { useMasterItems } from './useMasterItems';
import { MasterItemsProvider } from '../providers/MasterItemsProvider';
import { useApiOnMount } from './useApiOnMount';
import type { MasterGroceryItem } from '../types';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// 1. Mock the useApiOnMount hook, which is the dependency of our provider.
vi.mock('./useApiOnMount');
@@ -57,8 +58,8 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
it('should return masterItems on successful fetch', () => {
// Arrange: Mock a successful data fetch.
const mockItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 1, name: 'Milk', category_id: 1, category_name: 'Dairy', created_at: '' },
{ master_grocery_item_id: 2, name: 'Bread', category_id: 2, category_name: 'Bakery', created_at: '' },
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk', category_id: 1, category_name: 'Dairy' }),
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Bread', category_id: 2, category_name: 'Bakery' }),
];
mockedUseApiOnMount.mockReturnValue({
data: mockItems,

View File

@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import toast from 'react-hot-toast';
import { useProfileAddress } from './useProfileAddress';
import { useApi } from './useApi';
import type { Profile, Address } from '../types';
import { createMockAddress, createMockProfile } from '../tests/utils/mockFactories';
// Mock dependencies
vi.mock('react-hot-toast', () => ({
@@ -25,20 +25,18 @@ const mockedUseApi = vi.mocked(useApi);
const mockedToast = vi.mocked(toast);
// Mock data
const mockProfile: Profile = {
const mockProfile = createMockProfile({
user_id: 'user-123',
address_id: 1,
full_name: 'Test User',
role: 'user',
points: 0,
};
});
const mockProfileNoAddress: Profile = {
const mockProfileNoAddress = createMockProfile({
...mockProfile,
address_id: null,
};
});
const mockAddress: Address = {
const mockAddress = createMockAddress({
address_id: 1,
address_line_1: '123 Main St',
city: 'Anytown',
@@ -47,9 +45,7 @@ const mockAddress: Address = {
country: 'USA',
latitude: 34.05,
longitude: -118.25,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
});
describe('useProfileAddress Hook', () => {
let mockGeocode: Mock;

View File

@@ -6,8 +6,9 @@ import { useApi } from './useApi';
import { useAuth } from '../hooks/useAuth';
import { useUserData } from '../hooks/useUserData';
import * as apiClient from '../services/apiClient';
import type { ShoppingList, ShoppingListItem, User } from '../types';
import React from 'react'; // Required for Dispatch/SetStateAction types
import { createMockShoppingList, createMockShoppingListItem, createMockUserProfile } from '../tests/utils/mockFactories';
import React from 'react';
import type { ShoppingList, User } from '../types'; // Import ShoppingList and User types
// Define a type for the mock return value of useApi to ensure type safety in tests
type MockApiResult = {
@@ -30,7 +31,9 @@ const mockedUseApi = vi.mocked(useApi);
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseUserData = vi.mocked(useUserData);
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
// Create a mock User object by extracting it from a mock UserProfile
const mockUserProfile = createMockUserProfile({ user_id: 'user-123', user: { user_id: 'user-123', email: 'test@example.com' } });
const mockUser: User = mockUserProfile.user;
describe('useShoppingLists Hook', () => {
// Create a mock setter function that we can spy on
@@ -96,9 +99,9 @@ describe('useShoppingLists Hook', () => {
});
it('should set the first list as active on initial load if lists exist', async () => {
const mockLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
{ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123', created_at: '', items: [] },
const mockLists = [
createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }),
createMockShoppingList({ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123' }),
];
mockedUseUserData.mockReturnValue({
@@ -125,9 +128,7 @@ describe('useShoppingLists Hook', () => {
logout: vi.fn(),
updateProfile: vi.fn(),
});
const mockLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
];
const mockLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' })];
mockedUseUserData.mockReturnValue({
shoppingLists: mockLists,
setShoppingLists: mockSetShoppingLists,
@@ -143,7 +144,7 @@ describe('useShoppingLists Hook', () => {
});
it('should set activeListId to null when lists become empty', async () => {
const mockLists: ShoppingList[] = [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] }];
const mockLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' })];
// Initial render with a list
mockedUseUserData.mockReturnValue({
@@ -221,7 +222,7 @@ describe('useShoppingLists Hook', () => {
describe('createList', () => {
it('should call the API and update state on successful creation', async () => {
const newList: ShoppingList = { shopping_list_id: 99, name: 'New List', user_id: 'user-123', created_at: '', items: [] };
const newList = createMockShoppingList({ shopping_list_id: 99, name: 'New List', user_id: 'user-123' });
let currentLists: ShoppingList[] = [];
// Mock the implementation of the setter to simulate a real state update.
@@ -257,10 +258,10 @@ describe('useShoppingLists Hook', () => {
describe('deleteList', () => {
// Use a function to get a fresh copy for each test run
const getMockLists = (): ShoppingList[] => ([
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
{ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123', created_at: '', items: [] },
]);
const getMockLists = () => [
createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }),
createMockShoppingList({ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123' }),
];
let currentLists: ShoppingList[] = [];
@@ -347,7 +348,7 @@ describe('useShoppingLists Hook', () => {
it('should set activeListId to null when the last list is deleted', async () => {
console.log('TEST: should set activeListId to null when the last list is deleted');
const singleList: ShoppingList[] = [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] }];
const singleList = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' })];
// Override the state for this specific test
currentLists = singleList;
mockDeleteListApi.mockResolvedValue(null);
@@ -371,7 +372,7 @@ describe('useShoppingLists Hook', () => {
describe('addItemToList', () => {
let currentLists: ShoppingList[] = [];
const getMockLists = () => [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] }];
const getMockLists = () => [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' })];
beforeEach(() => {
currentLists = getMockLists();
@@ -389,7 +390,7 @@ describe('useShoppingLists Hook', () => {
});
it('should call API and add item to the correct list', async () => {
const newItem: ShoppingListItem = { shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', is_purchased: false, quantity: 1, added_at: new Date().toISOString() };
const newItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk' });
mockAddItemApi.mockResolvedValue(newItem);
const { result, rerender } = renderHook(() => useShoppingLists());
@@ -406,11 +407,11 @@ describe('useShoppingLists Hook', () => {
it('should not add a duplicate item (by master_item_id) to a list', async () => {
console.log('TEST: should not add a duplicate item (by master_item_id) to a list');
const existingItem: ShoppingListItem = { shopping_list_item_id: 100, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk', is_purchased: false, quantity: 1, added_at: '' };
const existingItem = createMockShoppingListItem({ shopping_list_item_id: 100, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk' });
// Override state for this specific test
currentLists = [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [existingItem] }];
currentLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', items: [existingItem] })];
// This is what the API would return for adding master_item_id 5 again. It has a new shopping_list_item_id.
const newItemFromApi: ShoppingListItem = { shopping_list_item_id: 101, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk', is_purchased: false, quantity: 1, added_at: '' };
const newItemFromApi = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk' });
mockAddItemApi.mockResolvedValue(newItemFromApi);
@@ -433,17 +434,15 @@ describe('useShoppingLists Hook', () => {
});
describe('updateItemInList', () => {
const initialItem: ShoppingListItem = { shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', is_purchased: false, quantity: 1, added_at: new Date().toISOString() };
const mockLists: ShoppingList[] = [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [initialItem] }];
const otherList: ShoppingList = { shopping_list_id: 2, name: 'Other', user_id: 'user-123', created_at: '', items: [] };
const multiLists = [mockLists[0], otherList];
const initialItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', is_purchased: false, quantity: 1 });
const multiLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', items: [initialItem] }), createMockShoppingList({ shopping_list_id: 2, name: 'Other' })];
beforeEach(() => {
mockedUseUserData.mockReturnValue({ shoppingLists: multiLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null });
});
it('should call API and update the correct item, leaving other lists unchanged', async () => {
const updatedItem: ShoppingListItem = { ...initialItem, is_purchased: true };
const updatedItem = { ...initialItem, is_purchased: true };
mockUpdateItemApi.mockResolvedValue(updatedItem);
const { result } = renderHook(() => useShoppingLists());
@@ -457,7 +456,7 @@ describe('useShoppingLists Hook', () => {
const updater = (mockSetShoppingLists as Mock).mock.calls[0][0];
const newState = updater(multiLists);
expect(newState[0].items[0].is_purchased).toBe(true);
expect(newState[1]).toBe(otherList); // Verify other list is unchanged
expect(newState[1]).toBe(multiLists[1]); // Verify other list is unchanged
});
it('should not call update API if no list is active', async () => {
@@ -482,10 +481,8 @@ describe('useShoppingLists Hook', () => {
});
describe('removeItemFromList', () => {
const initialItem: ShoppingListItem = { shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', is_purchased: false, quantity: 1, added_at: new Date().toISOString() };
const mockLists: ShoppingList[] = [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [initialItem] }];
const otherList: ShoppingList = { shopping_list_id: 2, name: 'Other', user_id: 'user-123', created_at: '', items: [] };
const multiLists = [mockLists[0], otherList];
const initialItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk' });
const multiLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', items: [initialItem] }), createMockShoppingList({ shopping_list_id: 2, name: 'Other' })];
beforeEach(() => {
mockedUseUserData.mockReturnValue({
@@ -511,7 +508,7 @@ describe('useShoppingLists Hook', () => {
const updater = (mockSetShoppingLists as Mock).mock.calls[0][0];
const newState = updater(multiLists);
expect(newState[0].items).toHaveLength(0);
expect(newState[1]).toBe(otherList); // Verify other list is unchanged
expect(newState[1]).toBe(multiLists[1]); // Verify other list is unchanged
});
it('should not call remove API if no list is active', async () => {

View File

@@ -7,6 +7,7 @@ import { useAuth } from '../hooks/useAuth';
import { UserDataProvider } from '../providers/UserDataProvider';
import { useApiOnMount } from './useApiOnMount';
import type { MasterGroceryItem, ShoppingList, UserProfile } from '../types';
import { createMockMasterGroceryItem, createMockShoppingList, createMockUserProfile } from '../tests/utils/mockFactories';
// 1. Mock the hook's dependencies
vi.mock('../hooks/useAuth');
@@ -21,21 +22,16 @@ const mockedUseApiOnMount = vi.mocked(useApiOnMount);
const wrapper = ({ children }: { children: ReactNode }) => <UserDataProvider>{children}</UserDataProvider>; // No change needed here, just for context
// 4. Mock data for testing
const mockUser: UserProfile = {
const mockUser: UserProfile = createMockUserProfile({
user_id: 'user-123',
full_name: 'Test User',
role: 'user',
points: 100,
user: { user_id: 'user-123', email: 'test@example.com' },
};
});
const mockWatchedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 1, name: 'Milk', created_at: '' },
];
const mockWatchedItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
const mockShoppingLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Weekly Groceries', user_id: 'user-123', created_at: '', items: [] },
];
const mockShoppingLists = [createMockShoppingList({ shopping_list_id: 1, name: 'Weekly Groceries', user_id: 'user-123' })];
describe('useUserData Hook and UserDataProvider', () => {
beforeEach(() => {

View File

@@ -7,6 +7,7 @@ import { useAuth } from '../hooks/useAuth';
import { useUserData } from '../hooks/useUserData';
import * as apiClient from '../services/apiClient';
import type { MasterGroceryItem, User } from '../types';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// Mock the hooks that useWatchedItems depends on
vi.mock('./useApi');
@@ -20,9 +21,9 @@ const mockedUseAuth = vi.mocked(useAuth);
const mockedUseUserData = vi.mocked(useUserData);
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
const mockInitialItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 1, name: 'Milk', created_at: '' },
{ master_grocery_item_id: 2, name: 'Bread', created_at: '' },
const mockInitialItems = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' }),
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Bread' }),
];
describe('useWatchedItems Hook', () => {
@@ -97,7 +98,7 @@ describe('useWatchedItems Hook', () => {
describe('addWatchedItem', () => {
it('should call the API and update state on successful addition', async () => {
const newItem: MasterGroceryItem = { master_grocery_item_id: 3, name: 'Cheese', created_at: '' };
const newItem = createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Cheese' });
mockAddWatchedItemApi.mockResolvedValue(newItem);
const { result } = renderHook(() => useWatchedItems());
@@ -148,7 +149,7 @@ describe('useWatchedItems Hook', () => {
it('should not add duplicate items to the state', async () => {
// Item ID 1 ('Milk') already exists in mockInitialItems
const existingItem: MasterGroceryItem = { master_grocery_item_id: 1, name: 'Milk', created_at: '' };
const existingItem = createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' });
mockAddWatchedItemApi.mockResolvedValue(existingItem);
const { result } = renderHook(() => useWatchedItems());
@@ -169,12 +170,12 @@ describe('useWatchedItems Hook', () => {
});
it('should sort items alphabetically by name when adding a new item', async () => {
const unsortedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 2, name: 'Zucchini', created_at: '' },
{ master_grocery_item_id: 1, name: 'Apple', created_at: '' },
const unsortedItems = [
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Zucchini' }),
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apple' }),
];
const newItem: MasterGroceryItem = { master_grocery_item_id: 3, name: 'Banana', created_at: '' };
const newItem = createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Banana' });
mockAddWatchedItemApi.mockResolvedValue(newItem);
const { result } = renderHook(() => useWatchedItems());

View File

@@ -13,8 +13,12 @@ import { useShoppingLists } from '../hooks/useShoppingLists';
import { useWatchedItems } from '../hooks/useWatchedItems';
import { useActiveDeals } from '../hooks/useActiveDeals';
import { createMockFlyer, createMockShoppingList, createMockUser, createMockUserProfile } from '../tests/utils/mockFactories';
import type { ActivityLogItem, UserProfile } from '../types';
// Unmock the component to test the real implementation
vi.unmock('./MainLayout');
// Mock child components to simplify testing and focus on the layout's logic
vi.mock('../features/flyer/FlyerList', () => ({ FlyerList: () => <div data-testid="flyer-list" /> }));
vi.mock('../features/flyer/FlyerUploader', () => ({ FlyerUploader: () => <div data-testid="flyer-uploader" /> }));
@@ -70,7 +74,7 @@ const mockedUseShoppingLists = vi.mocked(useShoppingLists);
const mockedUseWatchedItems = vi.mocked(useWatchedItems);
const mockedUseActiveDeals = vi.mocked(useActiveDeals);
const mockUser = { user_id: 'user-123', email: 'test@example.com' };
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
// A simple component to render inside the Outlet for context testing
const OutletContent = () => <div>Outlet Content</div>;
@@ -103,13 +107,7 @@ describe('MainLayout Component', () => {
updateProfile: vi.fn(),
} as const;
const defaultUseFlyersReturn = {
flyers: [{
flyer_id: 1,
file_name: 'flyer.jpg',
created_at: new Date().toISOString(),
image_url: 'http://example.com/flyer.jpg',
item_count: 10,
}],
flyers: [createMockFlyer({ flyer_id: 1, file_name: 'flyer.jpg', item_count: 10 })],
isLoadingFlyers: false,
flyersError: null,
refetchFlyers: vi.fn(),
@@ -202,7 +200,7 @@ describe('MainLayout Component', () => {
...defaultUseAuthReturn,
user: mockUser,
authStatus: 'AUTHENTICATED',
profile: { user: mockUser } as UserProfile,
profile: createMockUserProfile({ user: mockUser }),
});
});
@@ -248,7 +246,7 @@ describe('MainLayout Component', () => {
it('calls setActiveListId when a list is shared via ActivityLog and the list exists', () => {
mockedUseShoppingLists.mockReturnValueOnce({
...defaultUseShoppingListsReturn,
shoppingLists: [{ shopping_list_id: 1, name: 'My List', user_id: 'user-123', created_at: '', items: [] }],
shoppingLists: [createMockShoppingList({ shopping_list_id: 1, name: 'My List', user_id: 'user-123' })],
});
renderWithRouter(<MainLayout {...defaultProps} />);

View File

@@ -19,7 +19,7 @@ import { ActivityLog, ActivityLogClickHandler } from '../pages/admin/ActivityLog
import { AnonymousUserBanner } from '../pages/admin/components/AnonymousUserBanner';
import { ErrorDisplay } from '../components/ErrorDisplay';
interface MainLayoutProps {
export interface MainLayoutProps {
onFlyerSelect: (flyer: import('../types').Flyer) => void;
selectedFlyerId: number | null;
onOpenProfile: () => void;

View File

@@ -9,6 +9,9 @@ import type { Flyer, FlyerItem } from '../types';
import type { FlyerDisplayProps } from '../features/flyer/FlyerDisplay'; // Keep this for FlyerDisplay mock
import type { ExtractedDataTableProps } from '../features/flyer/ExtractedDataTable'; // Import the props for ExtractedDataTable
// Unmock the component to test the real implementation
vi.unmock('./HomePage');
// Mock child components to isolate the HomePage logic
vi.mock('../features/flyer/FlyerDisplay', () => ({
FlyerDisplay: (props: FlyerDisplayProps) => <div data-testid="flyer-display" data-image-url={props.imageUrl} />,

View File

@@ -5,7 +5,7 @@ import { ExtractedDataTable } from '../features/flyer/ExtractedDataTable';
import { AnalysisPanel } from '../features/flyer/AnalysisPanel';
import type { Flyer, FlyerItem } from '../types';
interface HomePageProps {
export interface HomePageProps {
selectedFlyer: Flyer | null;
flyerItems: FlyerItem[];
onOpenCorrectionTool: () => void;

View File

@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import UserProfilePage from './UserProfilePage';
import * as apiClient from '../services/apiClient';
import { UserProfile, Achievement, UserAchievement } from '../types';
import { createMockUserProfile, createMockUserAchievement } from '../tests/utils/mockFactories';
// Mock dependencies
vi.mock('../services/apiClient'); // This was correct
@@ -28,17 +29,17 @@ vi.mock('../components/AchievementsList', () => ({
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
// --- Mock Data ---
const mockProfile: UserProfile = {
const mockProfile: UserProfile = createMockUserProfile({
user_id: 'user-123',
user: { user_id: 'user-123', email: 'test@example.com' },
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.jpg',
points: 150,
role: 'user',
};
});
const mockAchievements: (UserAchievement & Achievement)[] = [
{
createMockUserAchievement({
achievement_id: 1,
user_id: 'user-123',
achieved_at: '2024-01-01T00:00:00Z',
@@ -46,7 +47,7 @@ const mockAchievements: (UserAchievement & Achievement)[] = [
description: 'Uploaded first flyer.',
icon: 'upload',
points_value: 10,
},
}),
];
describe('UserProfilePage', () => {

View File

@@ -5,18 +5,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import toast from 'react-hot-toast';
import { AdminBrandManager } from './AdminBrandManager';
import * as apiClient from '../../../services/apiClient';
import type { Brand } from '../../../types';
import { createMockBrand } from '../../../tests/utils/mockFactories';
// After mocking, we can get a type-safe mocked version of the module.
// This allows us to use .mockResolvedValue, .mockRejectedValue, etc. on the functions.
// The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts.
const mockedApiClient = vi.mocked(apiClient);
const mockedToast = vi.mocked(toast, true);
const mockBrands: Brand[] = [
{ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null },
{ brand_id: 2, name: 'Compliments', store_name: 'Sobeys', logo_url: 'http://example.com/compliments.png' },
const mockBrands = [
createMockBrand({ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null }),
createMockBrand({ brand_id: 2, name: 'Compliments', store_name: 'Sobeys', logo_url: 'http://example.com/compliments.png' }),
];
describe('AdminBrandManager', () => {

View File

@@ -5,7 +5,7 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { CorrectionRow } from './CorrectionRow';
import * as apiClient from '../../../services/apiClient';
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../../types';
import { createMockSuggestedCorrection, createMockMasterGroceryItem, createMockCategory } from '../../../tests/utils/mockFactories';
// Cast the mocked module to its mocked type to retain type safety and autocompletion.
// The apiClient is now mocked globally via src/tests/setup/tests-setup-unit.ts.
@@ -35,26 +35,20 @@ vi.mock('../../../components/ConfirmationModal', () => ({
),
}));
const mockCorrection: SuggestedCorrection = {
const mockCorrection = createMockSuggestedCorrection({
suggested_correction_id: 1,
flyer_item_id: 101,
user_id: 'user-1',
correction_type: 'WRONG_PRICE',
suggested_value: '250', // $2.50
status: 'pending',
created_at: new Date().toISOString(),
flyer_item_name: 'Bananas',
flyer_item_price_display: '$1.99',
user_email: 'test@example.com',
};
});
const mockMasterItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 1, name: 'Bananas', created_at: '', category_id: 1, category_name: 'Produce' },
];
const mockCategories: Category[] = [
{ category_id: 1, name: 'Produce' },
];
const mockMasterItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Bananas', category_id: 1, category_name: 'Produce' })];
const mockCategories = [createMockCategory({ category_id: 1, name: 'Produce' })];
const mockOnProcessed = vi.fn();
@@ -305,10 +299,7 @@ describe('CorrectionRow', () => {
it('should save an edited value for INCORRECT_ITEM_LINK', async () => {
// Add a temporary item to the master list for this test
const localMasterItems = [
...mockMasterItems,
{ master_grocery_item_id: 2, name: 'Milk', created_at: '', category_id: 2, category_name: 'Dairy' },
];
const localMasterItems = [...mockMasterItems, createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy' })];
mockedApiClient.updateSuggestedCorrection.mockResolvedValue(new Response(JSON.stringify({ ...mockCorrection, correction_type: 'INCORRECT_ITEM_LINK', suggested_value: '2' })));
renderInTable({
@@ -330,10 +321,7 @@ describe('CorrectionRow', () => {
});
it('should save an edited value for ITEM_IS_MISCATEGORIZED', async () => {
const localCategories = [
...mockCategories,
{ category_id: 2, name: 'Dairy' },
];
const localCategories = [...mockCategories, createMockCategory({ category_id: 2, name: 'Dairy' })];
mockedApiClient.updateSuggestedCorrection.mockResolvedValue(new Response(JSON.stringify({ ...mockCorrection, correction_type: 'ITEM_IS_MISCATEGORIZED', suggested_value: '2' })));
renderInTable({

View File

@@ -7,6 +7,11 @@ import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
import toast from 'react-hot-toast';
import * as logger from '../../../services/logger.client';
import { createMockProfile, createMockAddress, createMockUser } from '../../../tests/utils/mockFactories';
import { createMockLogger } from '../../../tests/utils/mockLogger';
// Unmock the component to test the real implementation
vi.unmock('./ProfileManager');
const mockedApiClient = vi.mocked(apiClient, true);
@@ -19,12 +24,7 @@ vi.mock('react-hot-toast', () => ({
},
}));
vi.mock('../../../services/logger.client', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
logger: createMockLogger(),
}));
const mockOnClose = vi.fn();
@@ -33,24 +33,23 @@ const mockOnSignOut = vi.fn();
const mockOnProfileUpdate = vi.fn();
// --- MOCK DATA ---
const authenticatedUser = { user_id: 'auth-user-123', email: 'test@example.com' };
const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'test@example.com' });
const mockAddressId = 123;
const authenticatedProfile = {
const authenticatedProfile = createMockProfile({
user_id: 'auth-user-123',
full_name: 'Test User',
avatar_url: 'http://example.com/avatar.png',
role: 'user' as const,
role: 'user',
points: 100,
preferences: {
darkMode: false,
unitSystem: 'imperial' as const,
unitSystem: 'imperial',
},
address_id: mockAddressId,
};
});
const mockAddress = {
const mockAddress = createMockAddress({
address_id: mockAddressId,
user_id: 'auth-user-123',
address_line_1: '123 Main St',
city: 'Anytown',
province_state: 'ON',
@@ -58,9 +57,7 @@ const mockAddress = {
country: 'Canada',
latitude: 43.0,
longitude: -79.0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
});
const defaultSignedOutProps = {
isOpen: true,

View File

@@ -18,7 +18,7 @@ import { AuthView } from './AuthView';
import { AddressForm } from './AddressForm';
import { useProfileAddress } from '../../../hooks/useProfileAddress';
interface ProfileManagerProps {
export interface ProfileManagerProps {
isOpen: boolean;
onClose: () => void;
user: User | null; // Can be null for login/register

View File

@@ -2,8 +2,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; // This was a duplicate, fixed.
import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
import adminRouter from './admin.routes';
import { createMockUserProfile, createMockAdminUserView, createMockProfile } from '../tests/utils/mockFactories';
import { UserProfile, Profile } from '../types';
import { NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -118,12 +118,10 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('PUT /users/:id', () => {
it('should update a user role successfully', async () => {
// The `updateUserRole` function returns a `Profile` object, not a `User` object.
const updatedUser: Profile = {
const updatedUser = createMockProfile({
user_id: userId,
role: 'admin',
points: 0,
};
});
vi.mocked(adminRepo.updateUserRole).mockResolvedValue(updatedUser);
const response = await supertest(app)
.put(`/api/admin/users/${userId}`)

View File

@@ -44,7 +44,7 @@ vi.mock('passport-local', () => ({
import * as db from '../services/db/index.db';
import { UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
// Mock dependencies before importing the passport configuration
@@ -105,15 +105,14 @@ describe('Passport Configuration', () => {
it('should call done(null, user) on successful authentication', async () => {
// Arrange
const mockUser = {
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'hashed_password',
failed_login_attempts: 0,
last_failed_login: null,
points: 0,
role: 'user' as const
};
const mockUser = {
...createMockUserWithPasswordHash({
user_id: 'user-123',
email: 'test@test.com',
}),
points: 0,
role: 'user' as const,
};
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
@@ -140,15 +139,15 @@ describe('Passport Configuration', () => {
});
it('should call done(null, false) and increment failed attempts on password mismatch', async () => {
const mockUser = {
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'hashed_password',
failed_login_attempts: 1,
last_failed_login: null,
points: 0,
role: 'user' as const
};
const mockUser = {
...createMockUserWithPasswordHash({
user_id: 'user-123',
email: 'test@test.com',
failed_login_attempts: 1,
}),
points: 0,
role: 'user' as const,
};
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
@@ -162,15 +161,15 @@ describe('Passport Configuration', () => {
});
it('should call done(null, false) for an OAuth user (no password hash)', async () => {
const mockUser = {
user_id: 'oauth-user',
email: 'oauth@test.com',
password_hash: null,
failed_login_attempts: 0,
last_failed_login: null,
points: 0,
role: 'user' as const
};
const mockUser = {
...createMockUserWithPasswordHash({
user_id: 'oauth-user',
email: 'oauth@test.com',
password_hash: null,
}),
points: 0,
role: 'user' as const,
};
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
if (localStrategyCallbackWrapper.callback) {
@@ -182,13 +181,14 @@ describe('Passport Configuration', () => {
it('should call done(null, false) if account is locked', async () => {
const mockUser = {
user_id: 'locked-user',
email: 'locked@test.com',
password_hash: 'hashed_password',
failed_login_attempts: 5,
last_failed_login: new Date().toISOString(),
points: 0,
role: 'user' as const
...createMockUserWithPasswordHash({
user_id: 'locked-user',
email: 'locked@test.com',
failed_login_attempts: 5,
last_failed_login: new Date().toISOString(),
}),
points: 0,
role: 'user' as const,
};
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
@@ -201,13 +201,14 @@ describe('Passport Configuration', () => {
it('should allow login if lockout period has expired', async () => {
const mockUser = {
user_id: 'expired-lock-user',
email: 'expired@test.com',
password_hash: 'hashed_password',
failed_login_attempts: 5,
last_failed_login: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
points: 0,
role: 'user' as const
...createMockUserWithPasswordHash({
user_id: 'expired-lock-user',
email: 'expired@test.com',
failed_login_attempts: 5,
last_failed_login: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
}),
points: 0,
role: 'user' as const,
};
vi.mocked(mockedDb.userRepo.findUserWithProfileByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Correct password
@@ -303,13 +304,7 @@ describe('Passport Configuration', () => {
it('should call next() if user has "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
// Create a complete, type-safe mock UserProfile object.
user: {
user_id: 'admin-id',
role: 'admin',
points: 100,
user: { user_id: 'admin-id', email: 'admin@test.com' }
},
user: createMockUserProfile({ user_id: 'admin-id', role: 'admin', user: { user_id: 'admin-id', email: 'admin@test.com' } }),
};
// Act
@@ -323,12 +318,7 @@ describe('Passport Configuration', () => {
it('should return 403 Forbidden if user does not have "admin" role', () => {
// Arrange
const mockReq: Partial<Request> = {
user: {
user_id: 'user-id',
role: 'user',
points: 50,
user: { user_id: 'user-id', email: 'user@test.com' }
},
user: createMockUserProfile({ user_id: 'user-id', role: 'user', user: { user_id: 'user-id', email: 'user@test.com' } }),
};
// Act

View File

@@ -4,7 +4,7 @@ import supertest from 'supertest';
import express from 'express';
// Use * as bcrypt to match the implementation's import style and ensure mocks align.
import * as bcrypt from 'bcrypt';
import userRouter from './user.routes';
import userRouter from './user.routes'; // This was a duplicate, fixed.
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe, createMockNotification, createMockDietaryRestriction, createMockAppliance, createMockUserWithPasswordHash, createMockAddress } from '../tests/utils/mockFactories';
import { Appliance, Notification, DietaryRestriction, Address } from '../types';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
@@ -460,7 +460,7 @@ describe('User Routes (/api/users)', () => {
describe('PUT /profile', () => {
it('should update the user profile successfully', async () => {
const profileUpdates = { full_name: 'New Name' };
const updatedProfile = { ...mockUserProfile, ...profileUpdates };
const updatedProfile = createMockUserProfile({ ...mockUserProfile, ...profileUpdates });
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile')
@@ -588,10 +588,10 @@ describe('User Routes (/api/users)', () => {
describe('PUT /profile/preferences', () => {
it('should update user preferences successfully', async () => {
const preferencesUpdate = { darkMode: true, unitSystem: 'metric' as const };
const updatedProfile = {
...mockUserProfile,
preferences: { ...mockUserProfile.preferences, ...preferencesUpdate }
};
const updatedProfile = createMockUserProfile({
...mockUserProfile,
preferences: { ...mockUserProfile.preferences, ...preferencesUpdate },
});
vi.mocked(db.userRepo.updateUserPreferences).mockResolvedValue(updatedProfile);
const response = await supertest(app)
.put('/api/users/profile/preferences')
@@ -928,7 +928,7 @@ describe('User Routes (/api/users)', () => {
it('PUT /recipes/:recipeId should update a user\'s own recipe', async () => {
const updates = { description: 'A new delicious description.' };
const mockUpdatedRecipe = { ...createMockRecipe({ recipe_id: 1 }), ...updates };
const mockUpdatedRecipe = createMockRecipe({ recipe_id: 1, ...updates });
vi.mocked(db.recipeRepo.updateRecipe).mockResolvedValue(mockUpdatedRecipe);
const response = await supertest(app)

View File

@@ -5,6 +5,7 @@ import type { Logger } from 'pino';
import type { MasterGroceryItem } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks.
import { AIService } from './aiService.server';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
vi.mock('./logger.server', () => ({
@@ -146,7 +147,7 @@ describe('AI Service (Server)', () => {
});
describe('extractCoreDataFromFlyerImage', () => {
const mockMasterItems: MasterGroceryItem[] = [{ master_grocery_item_id: 1, name: 'Apples', created_at: '' }];
const mockMasterItems: MasterGroceryItem[] = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples' })];
it('should extract and post-process flyer data correctly', async () => {
const mockAiResponse = {

View File

@@ -0,0 +1,25 @@
import { vi } from 'vitest';
// Create mock functions for each custom hook
export const mockUseAuth = vi.fn();
export const mockUseFlyers = vi.fn();
export const mockUseMasterItems = vi.fn();
export const mockUseUserData = vi.fn();
export const mockUseShoppingLists = vi.fn();
export const mockUseWatchedItems = vi.fn();
export const mockUseActiveDeals = vi.fn();
export const mockUseFlyerItems = vi.fn();
export const mockUseAiAnalysis = vi.fn();
// Mock the modules. The factory function returns the mock function.
// By placing these in a central file, any test that imports one of these
// mock functions will automatically have the corresponding hook mocked.
vi.mock('../../hooks/useAuth', () => ({ useAuth: mockUseAuth }));
vi.mock('../../hooks/useFlyers', () => ({ useFlyers: mockUseFlyers }));
vi.mock('../../hooks/useMasterItems', () => ({ useMasterItems: mockUseMasterItems }));
vi.mock('../../hooks/useUserData', () => ({ useUserData: mockUseUserData }));
vi.mock('../../hooks/useShoppingLists', () => ({ useShoppingLists: mockUseShoppingLists }));
vi.mock('../../hooks/useWatchedItems', () => ({ useWatchedItems: mockUseWatchedItems }));
vi.mock('../../hooks/useActiveDeals', () => ({ useActiveDeals: mockUseActiveDeals }));
vi.mock('../../hooks/useFlyerItems', () => ({ useFlyerItems: mockUseFlyerItems }));
vi.mock('../../hooks/useAiAnalysis', () => ({ useAiAnalysis: mockUseAiAnalysis }));

View File

@@ -0,0 +1,7 @@
// src/tests/setup/tests-setup-integration.ts
import { beforeEach } from 'vitest';
import { resetMockIds } from '../utils/mockFactories';
beforeEach(() => {
resetMockIds();
});

View File

@@ -1,8 +1,14 @@
// src/tests/setup/tests-setup-unit.ts
import { vi, afterEach } from 'vitest';
import { vi, afterEach, beforeEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import type { Request, Response, NextFunction } from 'express';
import '@testing-library/jest-dom/vitest';
import { resetMockIds } from '../utils/mockFactories';
// Reset mock ID counter before each test to ensure test isolation.
beforeEach(() => {
resetMockIds();
});
// Mock the GeolocationPositionError global that exists in browsers but not in JSDOM.
// This is necessary for tests that simulate geolocation failures.

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { createMockProfile, createMockUser } from './mockFactories';
import type { HeaderProps } from '../../components/Header';
import type { ProfileManagerProps } from '../../pages/admin/components/ProfileManager';
import type { VoiceAssistantProps } from '../../features/voice-assistant/VoiceAssistant';
import type { FlyerCorrectionToolProps } from '../../components/FlyerCorrectionTool';
import type { WhatsNewModalProps } from '../../components/WhatsNewModal';
import type { AdminRouteProps } from '../../components/AdminRoute';
import type { MainLayoutProps } from '../../layouts/MainLayout';
import type { HomePageProps } from '../../pages/HomePage';
export const MockHeader: React.FC<HeaderProps> = (props) => (
<header data-testid="header-mock">
<button onClick={props.onOpenProfile}>Open Profile</button>
<button onClick={props.onOpenVoiceAssistant}>Open Voice Assistant</button>
</header>
);
export const MockProfileManager: React.FC<Partial<ProfileManagerProps>> = ({ isOpen, onClose, onProfileUpdate, onLoginSuccess }) => {
if (!isOpen) return null;
return (
<div data-testid="profile-manager-mock">
<button onClick={onClose}>Close Profile</button>
<button onClick={() => onProfileUpdate?.(createMockProfile({ user_id: '1', role: 'user', points: 0, full_name: 'Updated' }))}>Update Profile</button>
<button onClick={() => onLoginSuccess?.(createMockUser({ user_id: '1', email: 'a@b.com' }), 'token', false)}>Login</button>
</div>
);
};
export const MockVoiceAssistant: React.FC<VoiceAssistantProps> = ({ isOpen, onClose }) => (
isOpen ? <div data-testid="voice-assistant-mock"><button onClick={onClose}>Close Voice Assistant</button></div> : null
);
export const MockFlyerCorrectionTool: React.FC<Partial<FlyerCorrectionToolProps>> = ({ isOpen, onClose, onDataExtracted }) => (
isOpen ? <div data-testid="flyer-correction-tool-mock"><button onClick={onClose}>Close Correction</button><button onClick={() => onDataExtracted?.('store_name', 'New Store Name')}>Extract Store</button><button onClick={() => onDataExtracted?.('dates', '2023-01-01')}>Extract Dates</button></div> : null
);
export const MockWhatsNewModal: React.FC<Partial<WhatsNewModalProps>> = ({ isOpen, onClose }) => (
isOpen ? <div data-testid="whats-new-modal-mock"><button onClick={onClose}>Close What's New</button></div> : null
);
// Add mock implementations for the remaining simple page/layout components
export const MockAdminPage: React.FC = () => <div data-testid="admin-page-mock">Admin Page</div>;
export const MockAdminRoute: React.FC<Partial<AdminRouteProps>> = () => <div data-testid="admin-route-mock"><Outlet /></div>;
export const MockCorrectionsPage: React.FC = () => <div data-testid="corrections-page-mock">Corrections Page</div>;
export const MockAdminStatsPage: React.FC = () => <div data-testid="admin-stats-page-mock">Admin Stats Page</div>;
export const MockResetPasswordPage: React.FC = () => <div data-testid="reset-password-page-mock">Reset Password Page</div>;
export const MockVoiceLabPage: React.FC = () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div>;
export const MockMainLayout: React.FC<Partial<MainLayoutProps>> = () => <div data-testid="main-layout-mock"><Outlet /></div>;
export const MockHomePage: React.FC<Partial<HomePageProps>> = ({ onOpenCorrectionTool }) => (
<div data-testid="home-page-mock">
<button onClick={onOpenCorrectionTool}>Open Correction Tool</button>
</div>
);

View File

@@ -1,5 +1,43 @@
// src/tests/utils/mockFactories.ts
import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeComment, ActivityLogItem, DietaryRestriction, Appliance, Notification, UnmatchedFlyerItem, AdminUserView, WatchedItemDeal, LeaderboardUser, UserWithPasswordHash, Profile, Address } from '../../types';
import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, Category, FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, Achievement, UserAchievement, Budget, SpendingByCategory, Recipe, RecipeIngredient, RecipeComment, ActivityLogItem, DietaryRestriction, UserDietaryRestriction, Appliance, UserAppliance, Notification, UnmatchedFlyerItem, AdminUserView, WatchedItemDeal, LeaderboardUser, UserWithPasswordHash, Profile, Address, MenuPlan, PlannedMeal, PantryItem, Product, ShoppingTrip, ShoppingTripItem, Receipt, ReceiptItem } from '../../types';
// --- ID Generator for Deterministic Mocking ---
let idCounter = 0;
/**
* Generates a sequential number for deterministic IDs in tests.
*/
const getNextId = () => ++idCounter;
/**
* Resets the ID counter. Call this in `beforeEach` in your test setup
* to ensure tests are isolated.
*
* @example
* beforeEach(() => {
* resetMockIds();
* });
*/
export const resetMockIds = () => {
idCounter = 0;
};
// --- End ID Generator ---
/**
* Creates a mock User object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe User object.
*/
export const createMockUser = (overrides: Partial<User> = {}): User => {
const userId = overrides.user_id ?? `user-${getNextId()}`;
const defaultUser: User = {
user_id: userId,
email: `${userId}@example.com`,
};
return { ...defaultUser, ...overrides };
};
/**
* Creates a mock UserProfile object for use in tests, ensuring type safety.
@@ -10,24 +48,53 @@ import { UserProfile, User, Flyer, Store, SuggestedCorrection, Brand, FlyerItem,
* @returns A complete and type-safe UserProfile object.
*/
export const createMockUserProfile = (overrides: Partial<UserProfile & { user: Partial<User> }> = {}): UserProfile => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
// Ensure the user_id is consistent between the profile and the nested user object
const userOverrides: Partial<User> = overrides.user || {};
if (overrides.user_id && !userOverrides.user_id) {
userOverrides.user_id = overrides.user_id;
}
const user = createMockUser(userOverrides);
const defaultProfile: UserProfile = {
user_id: userId,
user_id: user.user_id,
role: 'user',
points: 0,
full_name: 'Test User',
avatar_url: null,
preferences: {},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
created_by: null,
address: null,
user: {
user_id: userId,
email: `${userId}@example.com`,
...overrides.user, // Apply nested user overrides
},
user,
};
delete (defaultProfile as Partial<UserProfile>).address_id;
// Exclude 'user' from overrides to prevent overwriting the complete user object with a partial one
const { user: _, ...profileOverrides } = overrides;
return { ...defaultProfile, ...profileOverrides, user_id: user.user_id };
};
/**
* Creates a mock Store object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Store object.
*/
export const createMockStore = (overrides: Partial<Store> = {}): Store => {
const storeId = overrides.store_id ?? getNextId();
const defaultStore: Store = {
store_id: storeId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
name: 'Mock Store',
logo_url: null,
created_by: null,
};
return { ...defaultProfile, ...overrides };
return { ...defaultStore, ...overrides };
};
/**
@@ -38,33 +105,52 @@ export const createMockUserProfile = (overrides: Partial<UserProfile & { user: P
* e.g., `createMockFlyer({ item_count: 50, store: { name: 'Walmart' } })`
* @returns A complete and type-safe Flyer object.
*/
export const createMockFlyer = (overrides: Partial<Flyer & { store: Partial<Store> }> = {}): Flyer => {
const flyerId = overrides.flyer_id ?? Math.floor(Math.random() * 1000);
const storeId = overrides.store?.store_id ?? Math.floor(Math.random() * 100);
export const createMockFlyer = (overrides: Omit<Partial<Flyer>, 'store'> & { store?: Partial<Store> } = {}): Flyer => {
const flyerId = overrides.flyer_id ?? getNextId();
// Ensure the store_id is consistent between the flyer and the nested store object
const storeOverrides = overrides.store || {};
if (overrides.store_id && !storeOverrides.store_id) {
storeOverrides.store_id = overrides.store_id;
}
const store = createMockStore(storeOverrides);
// Determine the final file_name to generate dependent properties from.
const fileName = overrides.file_name ?? `flyer-${flyerId}.jpg`;
// A simple hash function for mock checksum generation.
const generateMockChecksum = (str: string) => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0; // Convert to 32bit integer
}
return `mock-checksum-${Math.abs(hash).toString(16)}`;
};
const defaultFlyer: Flyer = {
flyer_id: flyerId,
created_at: new Date().toISOString(),
file_name: `flyer-${flyerId}.jpg`,
image_url: `/flyer-images/flyer-${flyerId}.jpg`,
icon_url: `/flyer-images/icons/icon-flyer-${flyerId}.webp`,
checksum: `checksum-${flyerId}`,
store_id: storeId,
updated_at: new Date().toISOString(),
file_name: fileName,
image_url: `/flyer-images/${fileName}`,
icon_url: `/flyer-images/icons/icon-${fileName.replace(/\.[^/.]+$/, ".webp")}`,
checksum: generateMockChecksum(fileName),
store_id: store.store_id,
valid_from: new Date().toISOString().split('T')[0],
valid_to: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], // 7 days from now
store_address: '123 Main St, Anytown, USA',
item_count: Math.floor(Math.random() * 100) + 10,
item_count: 50,
uploaded_by: null,
store: {
store_id: storeId,
created_at: new Date().toISOString(),
name: 'Mock Store',
logo_url: null,
},
store,
};
// Deep merge the store object and then merge the top-level properties.
return { ...defaultFlyer, ...overrides, store: { ...defaultFlyer.store, ...overrides.store } as Store };
const { store: _, ...flyerOverrides } = overrides;
// Apply overrides. If checksum, file_name, etc., are in overrides, they will correctly replace the generated defaults.
return { ...defaultFlyer, ...flyerOverrides };
};
/**
@@ -74,13 +160,14 @@ export const createMockFlyer = (overrides: Partial<Flyer & { store: Partial<Stor
*/
export const createMockSuggestedCorrection = (overrides: Partial<SuggestedCorrection> = {}): SuggestedCorrection => {
const defaultCorrection: SuggestedCorrection = {
suggested_correction_id: Math.floor(Math.random() * 1000),
flyer_item_id: Math.floor(Math.random() * 10000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
suggested_correction_id: getNextId(),
flyer_item_id: getNextId(),
user_id: `user-${getNextId()}`,
correction_type: 'price',
suggested_value: '$9.99',
status: 'pending',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return { ...defaultCorrection, ...overrides };
@@ -92,10 +179,12 @@ export const createMockSuggestedCorrection = (overrides: Partial<SuggestedCorrec
* @returns A complete and type-safe Brand object.
*/
export const createMockBrand = (overrides: Partial<Brand> = {}): Brand => {
const brandId = overrides.brand_id ?? Math.floor(Math.random() * 100);
const brandId = overrides.brand_id ?? getNextId();
const defaultBrand: Brand = {
brand_id: brandId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
name: `Brand ${brandId}`,
logo_url: null,
store_id: null,
@@ -105,15 +194,62 @@ export const createMockBrand = (overrides: Partial<Brand> = {}): Brand => {
return { ...defaultBrand, ...overrides };
};
/**
* Creates a mock Category object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Category object.
*/
export const createMockCategory = (overrides: Partial<Category> = {}): Category => {
const categoryId = overrides.category_id ?? getNextId();
const defaultCategory: Category = {
category_id: categoryId,
name: `Category ${categoryId}`,
};
return { ...defaultCategory, ...overrides };
};
/**
* Creates a mock Product object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* Can optionally include `master_item` and `brand` to link IDs.
* @returns A complete and type-safe Product object.
*/
export const createMockProduct = (overrides: Partial<Product> & { master_item?: Partial<MasterGroceryItem>, brand?: Partial<Brand> } = {}): Product => {
const productId = overrides.product_id ?? getNextId();
const masterItemId = overrides.master_item_id ?? overrides.master_item?.master_grocery_item_id ?? getNextId();
const brandId = overrides.brand_id ?? overrides.brand?.brand_id; // brand is optional
const defaultProduct: Product = {
product_id: productId,
master_item_id: masterItemId,
brand_id: brandId,
name: `Mock Product ${productId}`,
description: 'A mock product description.',
size: '100g',
upc_code: `0000${productId}`.slice(-12),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
const { master_item: _, brand: __, ...itemOverrides } = overrides;
return { ...defaultProduct, ...itemOverrides };
};
/**
* Creates a mock FlyerItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe FlyerItem object.
*/
export const createMockFlyerItem = (overrides: Partial<FlyerItem> = {}): FlyerItem => {
export const createMockFlyerItem = (overrides: Partial<FlyerItem> & { flyer?: Partial<Flyer> } = {}): FlyerItem => {
const flyerItemId = overrides.flyer_item_id ?? getNextId();
const flyerId = overrides.flyer_id ?? overrides.flyer?.flyer_id ?? getNextId();
const defaultItem: FlyerItem = {
flyer_item_id: Math.floor(Math.random() * 10000),
flyer_id: Math.floor(Math.random() * 1000),
flyer_item_id: flyerItemId,
flyer_id: flyerId,
created_at: new Date().toISOString(),
item: 'Mock Item',
price_display: '$1.99',
@@ -124,7 +260,9 @@ export const createMockFlyerItem = (overrides: Partial<FlyerItem> = {}): FlyerIt
updated_at: new Date().toISOString(),
};
return { ...defaultItem, ...overrides };
const { flyer: _, ...itemOverrides } = overrides;
return { ...defaultItem, ...itemOverrides };
};
/**
@@ -132,26 +270,71 @@ export const createMockFlyerItem = (overrides: Partial<FlyerItem> = {}): FlyerIt
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Recipe object.
*/
export const createMockRecipe = (overrides: Partial<Recipe> = {}): Recipe => {
const recipeId = overrides.recipe_id ?? Math.floor(Math.random() * 1000);
export const createMockRecipe = (overrides: Omit<Partial<Recipe>, 'comments' | 'ingredients'> & { comments?: Partial<RecipeComment>[], ingredients?: Partial<RecipeIngredient>[] } = {}): Recipe => {
const recipeId = overrides.recipe_id ?? getNextId();
const defaultRecipe: Recipe = {
recipe_id: recipeId,
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
user_id: `user-${getNextId()}`,
name: `Mock Recipe ${recipeId}`,
description: 'A delicious mock recipe.',
instructions: '1. Mock the ingredients. 2. Mock the cooking. 3. Enjoy!',
avg_rating: Math.random() * 5,
rating_count: Math.floor(Math.random() * 100),
fork_count: Math.floor(Math.random() * 20),
avg_rating: 4.5,
rating_count: 50,
fork_count: 10,
status: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
prep_time_minutes: 15,
cook_time_minutes: 30,
servings: 4,
};
return { ...defaultRecipe, ...overrides };
const { comments: commentsOverrides, ingredients: ingredientsOverrides, ...recipeOverrides } = overrides;
const recipe = { ...defaultRecipe, ...recipeOverrides };
if (commentsOverrides) {
recipe.comments = commentsOverrides.map((comment) =>
createMockRecipeComment({
recipe_id: recipeId,
...comment,
})
);
}
if (ingredientsOverrides) {
recipe.ingredients = ingredientsOverrides.map((ingredient) =>
createMockRecipeIngredient({
recipe_id: recipeId,
...ingredient,
})
);
}
return recipe;
};
/**
* Creates a mock RecipeIngredient object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* Can optionally include a `master_item` to link IDs.
* @returns A complete and type-safe RecipeIngredient object.
*/
export const createMockRecipeIngredient = (overrides: Partial<RecipeIngredient> & { master_item?: Partial<MasterGroceryItem> } = {}): RecipeIngredient => {
const recipeIngredientId = overrides.recipe_ingredient_id ?? getNextId();
const masterItemId = overrides.master_item_id ?? overrides.master_item?.master_grocery_item_id ?? getNextId();
const defaultIngredient: RecipeIngredient = {
recipe_ingredient_id: recipeIngredientId,
recipe_id: getNextId(),
master_item_id: masterItemId,
quantity: 1,
unit: 'cup',
};
const { master_item: _, ...itemOverrides } = overrides;
return { ...defaultIngredient, ...itemOverrides };
};
/**
@@ -161,9 +344,9 @@ export const createMockRecipe = (overrides: Partial<Recipe> = {}): Recipe => {
*/
export const createMockRecipeComment = (overrides: Partial<RecipeComment> = {}): RecipeComment => {
const defaultComment: RecipeComment = {
recipe_comment_id: Math.floor(Math.random() * 10000),
recipe_id: Math.floor(Math.random() * 1000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
recipe_comment_id: getNextId(),
recipe_id: getNextId(),
user_id: `user-${getNextId()}`,
content: 'This is a mock comment.',
status: 'visible',
created_at: new Date().toISOString(),
@@ -174,6 +357,56 @@ export const createMockRecipeComment = (overrides: Partial<RecipeComment> = {}):
return { ...defaultComment, ...overrides };
};
/**
* Creates a mock PlannedMeal object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe PlannedMeal object.
*/
export const createMockPlannedMeal = (overrides: Partial<PlannedMeal> = {}): PlannedMeal => {
const defaultMeal: PlannedMeal = {
planned_meal_id: getNextId(),
menu_plan_id: getNextId(),
recipe_id: getNextId(),
plan_date: new Date().toISOString().split('T')[0],
meal_type: 'dinner',
servings_to_cook: 4,
};
return { ...defaultMeal, ...overrides };
};
/**
* Creates a mock MenuPlan object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe MenuPlan object.
*/
export const createMockMenuPlan = (overrides: Omit<Partial<MenuPlan>, 'planned_meals'> & { planned_meals?: Partial<PlannedMeal>[] } = {}): MenuPlan => {
const menuPlanId = overrides.menu_plan_id ?? getNextId();
const defaultPlan: MenuPlan = {
menu_plan_id: menuPlanId,
user_id: `user-${getNextId()}`,
name: 'Weekly Plan',
start_date: new Date().toISOString().split('T')[0],
end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
created_at: new Date().toISOString(),
};
const { planned_meals: mealsOverrides, ...planOverrides } = overrides;
const menuPlan = { ...defaultPlan, ...planOverrides };
if (mealsOverrides) {
menuPlan.planned_meals = mealsOverrides.map((meal) =>
createMockPlannedMeal({
menu_plan_id: menuPlanId,
...meal,
})
);
}
return menuPlan;
};
/**
* Creates a mock ActivityLogItem object for use in tests.
* This factory handles the discriminated union nature of the ActivityLogItem type.
@@ -188,9 +421,10 @@ export const createMockActivityLogItem = (overrides: Partial<ActivityLogItem> =
const action = overrides.action ?? 'flyer_processed';
const baseLog = {
activity_log_id: Math.floor(Math.random() * 10000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
activity_log_id: getNextId(),
user_id: `user-${getNextId()}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
let specificLog: ActivityLogItem;
@@ -248,11 +482,12 @@ export const createMockActivityLogItem = (overrides: Partial<ActivityLogItem> =
*/
export const createMockAchievement = (overrides: Partial<Achievement> = {}): Achievement => {
const defaultAchievement: Achievement = {
achievement_id: Math.floor(Math.random() * 100),
achievement_id: getNextId(),
name: 'Mock Achievement',
description: 'A great accomplishment.',
icon: 'star',
points_value: 10,
created_at: new Date().toISOString(),
};
return { ...defaultAchievement, ...overrides };
};
@@ -263,9 +498,9 @@ export const createMockAchievement = (overrides: Partial<Achievement> = {}): Ach
* @returns A complete and type-safe object representing the joined achievement data.
*/
export const createMockUserAchievement = (overrides: Partial<UserAchievement & Achievement> = {}): UserAchievement & Achievement => {
const achievementId = overrides.achievement_id ?? Math.floor(Math.random() * 100);
const achievementId = overrides.achievement_id ?? getNextId();
const defaultUserAchievement: UserAchievement & Achievement = {
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
user_id: `user-${getNextId()}`,
achievement_id: achievementId,
achieved_at: new Date().toISOString(),
// from Achievement
@@ -273,6 +508,7 @@ export const createMockUserAchievement = (overrides: Partial<UserAchievement & A
description: 'An achievement someone earned.',
icon: 'award',
points_value: 20,
created_at: new Date().toISOString(),
};
return { ...defaultUserAchievement, ...overrides };
};
@@ -284,12 +520,14 @@ export const createMockUserAchievement = (overrides: Partial<UserAchievement & A
*/
export const createMockBudget = (overrides: Partial<Budget> = {}): Budget => {
const defaultBudget: Budget = {
budget_id: Math.floor(Math.random() * 100),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
budget_id: getNextId(),
user_id: `user-${getNextId()}`,
name: 'Monthly Groceries',
amount_cents: 50000,
period: 'monthly',
start_date: new Date().toISOString().split('T')[0],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return { ...defaultBudget, ...overrides };
};
@@ -301,9 +539,9 @@ export const createMockBudget = (overrides: Partial<Budget> = {}): Budget => {
*/
export const createMockSpendingByCategory = (overrides: Partial<SpendingByCategory> = {}): SpendingByCategory => {
const defaultSpending: SpendingByCategory = {
category_id: Math.floor(Math.random() * 20) + 1,
category_id: getNextId(),
category_name: 'Produce',
total_spent_cents: Math.floor(Math.random() * 20000) + 1000,
total_spent_cents: 15000,
};
return { ...defaultSpending, ...overrides };
};
@@ -313,16 +551,55 @@ export const createMockSpendingByCategory = (overrides: Partial<SpendingByCatego
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe MasterGroceryItem object.
*/
export const createMockMasterGroceryItem = (overrides: Partial<MasterGroceryItem> = {}): MasterGroceryItem => {
export const createMockMasterGroceryItem = (overrides: Partial<MasterGroceryItem> & { category?: Partial<Category> } = {}): MasterGroceryItem => {
// Ensure category_id is consistent between the item and the nested category object
const categoryOverrides = overrides.category || {};
if (overrides.category_id && !categoryOverrides.category_id) {
categoryOverrides.category_id = overrides.category_id;
}
const category = createMockCategory(categoryOverrides);
const defaultItem: MasterGroceryItem = {
master_grocery_item_id: Math.floor(Math.random() * 10000),
master_grocery_item_id: getNextId(),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
name: 'Mock Master Item',
category_id: 1,
category_name: 'Pantry & Dry Goods',
category_id: category.category_id,
category_name: category.name,
is_allergen: false,
allergy_info: null,
created_by: null,
};
return { ...defaultItem, ...overrides };
const { category: _, ...itemOverrides } = overrides;
return { ...defaultItem, ...itemOverrides };
};
/**
* Creates a mock PantryItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* Can optionally include a `master_item` to link IDs.
* @returns A complete and type-safe PantryItem object.
*/
export const createMockPantryItem = (overrides: Partial<PantryItem> & { master_item?: Partial<MasterGroceryItem> } = {}): PantryItem => {
const pantryItemId = overrides.pantry_item_id ?? getNextId();
const masterItemId = overrides.master_item_id ?? overrides.master_item?.master_grocery_item_id ?? getNextId();
const defaultItem: PantryItem = {
pantry_item_id: pantryItemId,
user_id: `user-${getNextId()}`,
master_item_id: masterItemId,
quantity: 1,
unit: 'each',
updated_at: new Date().toISOString(),
};
const { master_item: _, ...itemOverrides } = overrides;
return { ...defaultItem, ...itemOverrides };
};
/**
@@ -330,36 +607,173 @@ export const createMockMasterGroceryItem = (overrides: Partial<MasterGroceryItem
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe ShoppingList object.
*/
export const createMockShoppingList = (overrides: Partial<ShoppingList> = {}): ShoppingList => {
export const createMockShoppingList = (overrides: Omit<Partial<ShoppingList>, 'items'> & { items?: (Partial<ShoppingListItem> & { master_item?: Partial<MasterGroceryItem> | null })[] } = {}): ShoppingList => {
const shoppingListId = overrides.shopping_list_id ?? getNextId();
const defaultList: ShoppingList = {
shopping_list_id: Math.floor(Math.random() * 100),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
shopping_list_id: shoppingListId,
user_id: `user-${getNextId()}`,
name: 'My Mock List',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
items: [],
};
return { ...defaultList, ...overrides };
if (overrides.items) {
defaultList.items = overrides.items.map((item) =>
createMockShoppingListItem({
shopping_list_id: shoppingListId,
...item,
})
);
}
const { items: _, ...listOverrides } = overrides;
return { ...defaultList, ...listOverrides };
};
/**
* Creates a mock ShoppingListItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* Can optionally include a `master_item` to link IDs and populate joined data.
* @returns A complete and type-safe ShoppingListItem object.
*/
export const createMockShoppingListItem = (overrides: Partial<ShoppingListItem> = {}): ShoppingListItem => {
export const createMockShoppingListItem = (overrides: Partial<ShoppingListItem> & { master_item?: Partial<MasterGroceryItem> | null } = {}): ShoppingListItem => {
const shoppingListItemId = overrides.shopping_list_item_id ?? getNextId();
const shoppingListId = overrides.shopping_list_id ?? getNextId();
const masterItemId = overrides.master_item_id ?? overrides.master_item?.master_grocery_item_id;
const defaultItem: ShoppingListItem = {
shopping_list_item_id: Math.floor(Math.random() * 100000),
shopping_list_id: Math.floor(Math.random() * 100),
shopping_list_item_id: shoppingListItemId,
shopping_list_id: shoppingListId,
custom_item_name: 'Mock Shopping List Item',
quantity: 1,
is_purchased: false,
added_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
if (masterItemId) {
defaultItem.master_item_id = masterItemId;
}
const { master_item: masterItemOverride, ...itemOverrides } = overrides;
const result = { ...defaultItem, ...itemOverrides };
if (masterItemOverride && !result.master_item && masterItemOverride.name) {
result.master_item = { name: masterItemOverride.name };
}
return result;
};
/**
* Creates a mock ShoppingTripItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe ShoppingTripItem object.
*/
export const createMockShoppingTripItem = (overrides: Partial<ShoppingTripItem> & { master_item?: Partial<MasterGroceryItem> } = {}): ShoppingTripItem => {
const tripItemId = overrides.shopping_trip_item_id ?? getNextId();
const masterItemId = overrides.master_item_id ?? overrides.master_item?.master_grocery_item_id;
const defaultItem: ShoppingTripItem = {
shopping_trip_item_id: tripItemId,
shopping_trip_id: getNextId(),
master_item_id: masterItemId,
custom_item_name: masterItemId ? null : 'Custom Trip Item',
master_item_name: masterItemId ? (overrides.master_item?.name ?? 'Mock Master Item') : null,
quantity: 1,
price_paid_cents: 199,
};
const { master_item: _, ...itemOverrides } = overrides;
return { ...defaultItem, ...itemOverrides };
};
/**
* Creates a mock ShoppingTrip object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe ShoppingTrip object.
*/
export const createMockShoppingTrip = (overrides: Omit<Partial<ShoppingTrip>, 'items'> & { items?: Partial<ShoppingTripItem>[] } = {}): ShoppingTrip => {
const tripId = overrides.shopping_trip_id ?? getNextId();
const defaultTrip: ShoppingTrip = {
shopping_trip_id: tripId,
user_id: `user-${getNextId()}`,
shopping_list_id: null,
completed_at: new Date().toISOString(),
total_spent_cents: 0,
items: [],
};
const { items: itemsOverrides, ...tripOverrides } = overrides;
const trip = { ...defaultTrip, ...tripOverrides };
if (itemsOverrides) {
trip.items = itemsOverrides.map((item) => createMockShoppingTripItem({ shopping_trip_id: tripId, ...item }));
if (overrides.total_spent_cents === undefined) {
trip.total_spent_cents = trip.items.reduce((total, item) => total + (item.price_paid_cents ?? 0), 0);
}
}
return trip;
};
/**
* Creates a mock ReceiptItem object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe ReceiptItem object.
*/
export const createMockReceiptItem = (overrides: Partial<ReceiptItem> = {}): ReceiptItem => {
const defaultItem: ReceiptItem = {
receipt_item_id: getNextId(),
receipt_id: getNextId(),
raw_item_description: 'Mock Receipt Item',
quantity: 1,
price_paid_cents: 199,
master_item_id: null,
product_id: null,
status: 'unmatched',
};
return { ...defaultItem, ...overrides };
};
/**
* Creates a mock Receipt object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* @returns A complete and type-safe Receipt object.
*/
export const createMockReceipt = (overrides: Omit<Partial<Receipt>, 'items'> & { items?: Partial<ReceiptItem>[] } = {}): Receipt => {
const receiptId = overrides.receipt_id ?? getNextId();
const defaultReceipt: Receipt = {
receipt_id: receiptId,
user_id: `user-${getNextId()}`,
store_id: null,
receipt_image_url: `/receipts/mock-receipt-${receiptId}.jpg`,
transaction_date: new Date().toISOString(),
total_amount_cents: null,
status: 'pending',
raw_text: null,
created_at: new Date().toISOString(),
processed_at: null,
};
const { items: itemsOverrides, ...receiptOverrides } = overrides;
const receipt = { ...defaultReceipt, ...receiptOverrides };
if (itemsOverrides) {
receipt.items = itemsOverrides.map((item) => createMockReceiptItem({ receipt_id: receiptId, ...item }));
}
return receipt;
};
/**
* Creates a mock DietaryRestriction object for testing.
* @param overrides - Optional properties to override the defaults.
@@ -374,6 +788,46 @@ export const createMockDietaryRestriction = (overrides: Partial<DietaryRestricti
};
};
/**
* Creates a mock UserDietaryRestriction object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* Can optionally include `user` and `restriction` objects to link IDs.
* @returns A complete and type-safe UserDietaryRestriction object.
*/
export const createMockUserDietaryRestriction = (
overrides: Partial<UserDietaryRestriction> & { user?: Partial<User>; restriction?: Partial<DietaryRestriction> } = {}
): UserDietaryRestriction => {
const userId = overrides.user_id ?? overrides.user?.user_id ?? `user-${getNextId()}`;
const restrictionId = overrides.restriction_id ?? overrides.restriction?.dietary_restriction_id ?? getNextId();
const defaultUserRestriction: UserDietaryRestriction = {
user_id: userId,
restriction_id: restrictionId,
};
return { ...defaultUserRestriction, ...overrides };
};
/**
* Creates a mock UserAppliance object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
* Can optionally include `user` and `appliance` objects to link IDs.
* @returns A complete and type-safe UserAppliance object.
*/
export const createMockUserAppliance = (
overrides: Partial<UserAppliance> & { user?: Partial<User>; appliance?: Partial<Appliance> } = {}
): UserAppliance => {
const userId = overrides.user_id ?? overrides.user?.user_id ?? `user-${getNextId()}`;
const applianceId = overrides.appliance_id ?? overrides.appliance?.appliance_id ?? getNextId();
const defaultUserAppliance: UserAppliance = {
user_id: userId,
appliance_id: applianceId,
};
return { ...defaultUserAppliance, ...overrides };
};
/**
* Creates a mock Address object for use in tests.
* @param overrides - An object containing properties to override the default mock values.
@@ -381,7 +835,7 @@ export const createMockDietaryRestriction = (overrides: Partial<DietaryRestricti
*/
export const createMockAddress = (overrides: Partial<Address> = {}): Address => {
const defaultAddress: Address = {
address_id: Math.floor(Math.random() * 1000),
address_id: getNextId(),
address_line_1: '123 Mock St',
city: 'Mockville',
province_state: 'BC',
@@ -405,7 +859,7 @@ export const createMockAddress = (overrides: Partial<Address> = {}): Address =>
* @returns A complete and type-safe UserWithPasswordHash object.
*/
export const createMockUserWithPasswordHash = (overrides: Partial<UserWithPasswordHash> = {}): UserWithPasswordHash => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const userId = overrides.user_id ?? `user-${getNextId()}`;
const defaultUser: UserWithPasswordHash = {
user_id: userId,
@@ -413,6 +867,9 @@ export const createMockUserWithPasswordHash = (overrides: Partial<UserWithPasswo
password_hash: 'hashed_password',
failed_login_attempts: 0,
last_failed_login: null,
last_login_ip: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return { ...defaultUser, ...overrides };
@@ -424,10 +881,11 @@ export const createMockUserWithPasswordHash = (overrides: Partial<UserWithPasswo
* @returns A complete and type-safe Profile object.
*/
export const createMockProfile = (overrides: Partial<Profile> = {}): Profile => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const userId = overrides.user_id ?? `user-${getNextId()}`;
const defaultProfile: Profile = {
user_id: userId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
full_name: 'Mock Profile User',
avatar_url: null,
@@ -435,6 +893,8 @@ export const createMockProfile = (overrides: Partial<Profile> = {}): Profile =>
points: 0,
role: 'user',
preferences: {},
created_by: null,
updated_by: null,
};
return { ...defaultProfile, ...overrides };
@@ -447,11 +907,11 @@ export const createMockProfile = (overrides: Partial<Profile> = {}): Profile =>
*/
export const createMockWatchedItemDeal = (overrides: Partial<WatchedItemDeal> = {}): WatchedItemDeal => {
const defaultDeal: WatchedItemDeal = {
master_item_id: Math.floor(Math.random() * 1000),
master_item_id: getNextId(),
item_name: 'Mock Deal Item',
best_price_in_cents: Math.floor(Math.random() * 1000) + 100,
best_price_in_cents: 599,
store_name: 'Mock Store',
flyer_id: Math.floor(Math.random() * 100),
flyer_id: getNextId(),
valid_to: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), // 5 days from now
};
@@ -464,14 +924,14 @@ export const createMockWatchedItemDeal = (overrides: Partial<WatchedItemDeal> =
* @returns A complete and type-safe LeaderboardUser object.
*/
export const createMockLeaderboardUser = (overrides: Partial<LeaderboardUser> = {}): LeaderboardUser => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const userId = overrides.user_id ?? `user-${getNextId()}`;
const defaultUser: LeaderboardUser = {
user_id: userId,
full_name: 'Leaderboard User',
avatar_url: null,
points: Math.floor(Math.random() * 1000),
rank: String(Math.floor(Math.random() * 100) + 1),
points: 500,
rank: '10',
};
return { ...defaultUser, ...overrides };
@@ -484,13 +944,15 @@ export const createMockLeaderboardUser = (overrides: Partial<LeaderboardUser> =
*/
export const createMockUnmatchedFlyerItem = (overrides: Partial<UnmatchedFlyerItem> = {}): UnmatchedFlyerItem => {
const defaultItem: UnmatchedFlyerItem = {
unmatched_flyer_item_id: Math.floor(Math.random() * 1000),
unmatched_flyer_item_id: getNextId(),
status: 'pending',
created_at: new Date().toISOString(),
flyer_item_id: Math.floor(Math.random() * 10000),
updated_at: new Date().toISOString(),
reviewed_at: null,
flyer_item_id: getNextId(),
flyer_item_name: 'Mystery Product',
price_display: '$?.??',
flyer_id: Math.floor(Math.random() * 100),
flyer_id: getNextId(),
store_name: 'Random Store',
};
@@ -503,7 +965,7 @@ export const createMockUnmatchedFlyerItem = (overrides: Partial<UnmatchedFlyerIt
* @returns A complete and type-safe AdminUserView object.
*/
export const createMockAdminUserView = (overrides: Partial<AdminUserView> = {}): AdminUserView => {
const userId = overrides.user_id ?? `user-${Math.random().toString(36).substring(2, 9)}`;
const userId = overrides.user_id ?? `user-${getNextId()}`;
const defaultUserView: AdminUserView = {
user_id: userId,
@@ -524,12 +986,13 @@ export const createMockAdminUserView = (overrides: Partial<AdminUserView> = {}):
*/
export const createMockNotification = (overrides: Partial<Notification> = {}): Notification => {
const defaultNotification: Notification = {
notification_id: Math.floor(Math.random() * 1000),
user_id: `user-${Math.random().toString(36).substring(2, 9)}`,
notification_id: getNextId(),
user_id: `user-${getNextId()}`,
content: 'This is a mock notification.',
link_url: null,
is_read: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return { ...defaultNotification, ...overrides };

View File

@@ -2,13 +2,16 @@
export interface Store {
store_id: number;
created_at: string;
updated_at: string;
name: string;
logo_url?: string | null;
created_by?: string | null;
}
export interface Flyer {
flyer_id: number;
created_at: string;
updated_at: string;
file_name: string;
image_url: string;
icon_url?: string | null; // URL for the 64x64 icon version of the flyer
@@ -79,9 +82,13 @@ export interface FlyerItem {
export interface MasterGroceryItem {
master_grocery_item_id: number;
created_at: string;
updated_at: string;
name: string;
category_id?: number | null;
category_name?: string | null;
is_allergen?: boolean;
allergy_info?: any | null; // JSONB
created_by?: string | null;
}
export interface Category {
@@ -91,6 +98,8 @@ export interface Category {
export interface Brand {
brand_id: number;
created_at: string;
updated_at: string;
name: string;
logo_url?: string | null;
store_id?: number | null;
@@ -99,6 +108,8 @@ export interface Brand {
export interface Product {
product_id: number;
created_at: string;
updated_at: string;
master_item_id: number;
brand_id?: number | null;
name: string;
@@ -131,10 +142,14 @@ export interface UserWithPasswordHash extends User {
password_hash: string | null;
failed_login_attempts: number;
last_failed_login: string | null; // TIMESTAMPTZ
last_login_ip?: string | null;
created_at: string;
updated_at: string;
}
export interface Profile {
user_id: string; // UUID
updated_at?: string;
created_at: string;
updated_at: string;
full_name?: string | null;
avatar_url?: string | null;
address_id?: number | null;
@@ -144,6 +159,8 @@ export interface Profile {
darkMode?: boolean;
unitSystem?: 'metric' | 'imperial';
} | null;
created_by?: string | null;
updated_by?: string | null;
}
@@ -165,6 +182,7 @@ export interface SuggestedCorrection {
suggested_value: string;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
updated_at: string;
reviewed_at?: string | null;
reviewed_notes?: string | null;
// Joined data
@@ -201,6 +219,7 @@ export interface Notification {
link_url?: string | null;
is_read: boolean;
created_at: string;
updated_at: string;
}
export interface ShoppingList {
@@ -208,6 +227,7 @@ export interface ShoppingList {
user_id: string; // UUID
name: string;
created_at: string;
updated_at: string;
items: ShoppingListItem[]; // Nested items
}
@@ -220,6 +240,7 @@ export interface ShoppingListItem {
is_purchased: boolean;
notes?: string | null;
added_at: string;
updated_at: string;
// Joined data for display
master_item?: {
name: string;
@@ -284,6 +305,9 @@ export interface Recipe {
rating_count: number;
fork_count: number;
created_at: string;
updated_at: string;
comments?: RecipeComment[];
ingredients?: RecipeIngredient[];
}
export interface RecipeIngredient {
@@ -341,6 +365,7 @@ export interface MenuPlan {
start_date: string; // DATE
end_date: string; // DATE
created_at: string;
planned_meals?: PlannedMeal[];
}
export interface SharedMenuPlan {
@@ -476,6 +501,7 @@ interface ActivityLogItemBase {
action: string;
display_text: string;
created_at: string;
updated_at: string;
icon?: string | null;
}
@@ -587,6 +613,7 @@ export interface Receipt {
raw_text?: string | null;
created_at: string;
processed_at?: string | null;
items?: ReceiptItem[];
}
export interface ReceiptItem {
@@ -852,6 +879,8 @@ export interface UnmatchedFlyerItem {
unmatched_flyer_item_id: number;
status: 'pending' | 'resolved' | 'ignored'; // 'resolved' is used instead of 'reviewed' from the DB for clarity
created_at: string; // Date string
updated_at: string;
reviewed_at?: string | null;
flyer_item_id: number;
flyer_item_name: string;
price_display: string;
@@ -869,6 +898,8 @@ export interface Budget {
amount_cents: number;
period: 'weekly' | 'monthly';
start_date: string; // DATE
created_at: string;
updated_at: string;
}
/**
@@ -890,6 +921,7 @@ export interface Achievement {
description: string;
icon?: string | null;
points_value: number;
created_at: string;
}
/**

View File

@@ -6,7 +6,11 @@ export default defineConfig({
globals: true,
environment: 'jsdom',
// This setup file is where we can add global test configurations
setupFiles: './src/tests/setup/tests-setup-unit.ts',
setupFiles: [
'./src/tests/setup/tests-setup-unit.ts',
'./src/tests/setup/mockHooks.ts',
'./src/tests/setup/mockComponents.tsx'
],
// This line is the key fix: it tells Vitest to include the type definitions
include: ['src/**/*.test.tsx'],
},