add price history routes and implement modal hook; enhance MainLayout tests with default mock values
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 9m36s

This commit is contained in:
2025-12-11 03:26:05 -08:00
parent 6aa72dd90b
commit d6f0b446a5
12 changed files with 205 additions and 82 deletions

View File

@@ -17,6 +17,7 @@ import budgetRouter from './src/routes/budget.routes';
import flyerRouter from './src/routes/flyer.routes';
import recipeRouter from './src/routes/recipe.routes';
import personalizationRouter from './src/routes/personalization.routes';
import priceRouter from './src/routes/price.routes';
import statsRouter from './src/routes/stats.routes';
import gamificationRouter from './src/routes/gamification.routes';
import systemRouter from './src/routes/system.routes';
@@ -138,6 +139,8 @@ app.use('/api/flyers', flyerRouter);
app.use('/api/recipes', recipeRouter);
// 9. Public personalization data routes (master items, etc.).
app.use('/api/personalization', personalizationRouter);
// 9.5. Price history routes.
app.use('/api/price-history', priceRouter);
// 10. Public statistics routes.
app.use('/api/stats', statsRouter);

11
src/hooks/useModal.ts Normal file
View File

@@ -0,0 +1,11 @@
// src/hooks/useModal.ts
import { useState, useCallback } from 'react';
export const useModal = (initialState: boolean = false) => {
const [isOpen, setIsOpen] = useState(initialState);
const openModal = useCallback(() => setIsOpen(true), []);
const closeModal = useCallback(() => setIsOpen(false), []);
return { isOpen, openModal, closeModal };
};

View File

@@ -61,53 +61,66 @@ describe('MainLayout Component', () => {
const mockOnOpenProfile = vi.fn();
const mockSetActiveListId = vi.fn();
// Define default mock return values for each hook
const defaultUseAuthReturn = {
user: null,
authStatus: 'SIGNED_OUT' as const,
profile: null,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
};
const defaultUseDataReturn = {
flyers: [{
flyer_id: 1,
file_name: 'flyer.jpg',
created_at: new Date().toISOString(),
image_url: 'http://example.com/flyer.jpg',
item_count: 10,
}],
masterItems: [],
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
refetchFlyers: vi.fn(),
isLoading: false,
error: null,
};
const defaultUseShoppingListsReturn = {
shoppingLists: [],
activeListId: null,
setActiveListId: mockSetActiveListId,
createList: vi.fn(),
deleteList: vi.fn(),
addItemToList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
error: null,
};
const defaultUseWatchedItemsReturn = {
watchedItems: [],
addWatchedItem: vi.fn(),
removeWatchedItem: vi.fn(),
error: null,
};
const defaultUseActiveDealsReturn = {
activeDeals: [],
totalActiveItems: 0,
isLoading: false,
error: null
};
beforeEach(() => {
vi.clearAllMocks();
// Provide default mock implementations for all hooks
mockedUseAuth.mockReturnValue({
user: null,
authStatus: 'SIGNED_OUT',
profile: null,
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
mockedUseData.mockReturnValue({
flyers: [{ flyer_id: 1, file_name: 'flyer.jpg' }],
masterItems: [],
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
refetchFlyers: vi.fn(),
isLoading: false,
error: null,
} as any);
mockedUseShoppingLists.mockReturnValue({
shoppingLists: [],
activeListId: null,
setActiveListId: mockSetActiveListId,
createList: vi.fn(),
deleteList: vi.fn(),
addItemToList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
error: null,
} as any);
mockedUseWatchedItems.mockReturnValue({
watchedItems: [],
addWatchedItem: vi.fn(),
removeWatchedItem: vi.fn(),
error: null,
} as any);
mockedUseActiveDeals.mockReturnValue({
activeDeals: [],
totalActiveItems: 0,
isLoading: false,
error: null
} as any);
mockedUseAuth.mockReturnValue(defaultUseAuthReturn);
mockedUseData.mockReturnValue(defaultUseDataReturn as any);
mockedUseShoppingLists.mockReturnValue(defaultUseShoppingListsReturn as any);
mockedUseWatchedItems.mockReturnValue(defaultUseWatchedItemsReturn as any);
mockedUseActiveDeals.mockReturnValue(defaultUseActiveDealsReturn as any);
});
const defaultProps = {
@@ -137,7 +150,7 @@ describe('MainLayout Component', () => {
});
it('does not show the AnonymousUserBanner if there are no flyers', () => {
mockedUseData.mockReturnValueOnce({ ...mockedUseData.mock.results[0].value, flyers: [] });
mockedUseData.mockReturnValueOnce({ ...defaultUseDataReturn, flyers: [] });
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.queryByTestId('anonymous-banner')).not.toBeInTheDocument();
});
@@ -161,34 +174,34 @@ describe('MainLayout Component', () => {
describe('Error Handling', () => {
it('displays an error message if useData has an error', () => {
mockedUseData.mockReturnValueOnce({ ...mockedUseData.mock.results[0].value, error: 'Data Fetch Failed' });
mockedUseData.mockReturnValueOnce({ ...defaultUseDataReturn, error: 'Data Fetch Failed' });
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('error-display')).toHaveTextContent('Data Fetch Failed');
});
it('displays an error message if useShoppingLists has an error', () => {
mockedUseShoppingLists.mockReturnValueOnce({ ...mockedUseShoppingLists.mock.results[0].value, error: 'Shopping List Failed' });
mockedUseShoppingLists.mockReturnValueOnce({ ...defaultUseShoppingListsReturn, error: 'Shopping List Failed' });
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('error-display')).toHaveTextContent('Shopping List Failed');
});
it('displays an error message if useWatchedItems has an error', () => {
mockedUseWatchedItems.mockReturnValueOnce({ ...mockedUseWatchedItems.mock.results[0].value, error: 'Watched Items Failed' });
mockedUseWatchedItems.mockReturnValueOnce({ ...defaultUseWatchedItemsReturn, error: 'Watched Items Failed' });
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('error-display')).toHaveTextContent('Watched Items Failed');
});
it('displays an error message if useActiveDeals has an error', () => {
mockedUseActiveDeals.mockReturnValueOnce({ ...mockedUseActiveDeals.mock.results[0].value, error: 'Active Deals Failed' });
mockedUseActiveDeals.mockReturnValueOnce({ ...defaultUseActiveDealsReturn, error: 'Active Deals Failed' });
renderWithRouter(<MainLayout {...defaultProps} />);
expect(screen.getByTestId('error-display')).toHaveTextContent('Active Deals Failed');
});
});
describe('Event Handlers', () => {
it('calls setActiveListId when a list is shared via ActivityLog', () => {
it('calls setActiveListId when a list is shared via ActivityLog and the list exists', () => {
mockedUseShoppingLists.mockReturnValueOnce({
...mockedUseShoppingLists.mock.results[0].value,
...defaultUseShoppingListsReturn,
shoppingLists: [{ shopping_list_id: 1, name: 'My List', items: [] } as any],
} as any);

View File

@@ -1,5 +1,5 @@
// src/layouts/MainLayout.tsx
import React from 'react';
import React, { useCallback } from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useData } from '../hooks/useData';
@@ -25,7 +25,7 @@ interface MainLayoutProps {
}
export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedFlyerId, onOpenProfile }) => {
const { user, authStatus } = useAuth();
const { user, authStatus, profile } = useAuth();
const { flyers, masterItems, refetchFlyers, error: dataError } = useData();
const {
shoppingLists, activeListId, setActiveListId,
@@ -38,31 +38,46 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedF
} = useWatchedItems();
const { activeDeals, totalActiveItems, isLoading: activeDealsLoading, error: activeDealsError } = useActiveDeals(flyers, watchedItems);
const handleActivityLogClick: ActivityLogClickHandler = (log) => {
const handleActivityLogClick: ActivityLogClickHandler = useCallback((log) => {
if (log.action === 'list_shared') {
const listId = log.details.shopping_list_id;
if (shoppingLists.some(list => list.shopping_list_id === listId)) {
setActiveListId(listId);
}
}
};
}, [shoppingLists, setActiveListId]);
const handleAddItemToShoppingList = useCallback(async (item: { masterItemId?: number; customItemName?: string; }) => {
if (activeListId) {
await addItemToList(activeListId, item);
}
}, [activeListId, addItemToList]);
const handleAddItemFromWatchedList = useCallback((masterItemId: number) => {
if (activeListId) {
addItemToList(activeListId, { masterItemId });
}
}, [activeListId, addItemToList]);
// Consolidate error states into a single variable for cleaner display logic.
const combinedError = dataError || shoppingListError || watchedItemsError || activeDealsError;
return (
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
{authStatus === 'SIGNED_OUT' && flyers.length > 0 && (
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0"> {/* This div was missing a closing tag in the original code, but it's outside the diff scope. */}
<AnonymousUserBanner onOpenProfile={onOpenProfile} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
<div className="lg:col-span-1 flex flex-col space-y-6">
<FlyerList flyers={flyers} onFlyerSelect={onFlyerSelect} selectedFlyerId={selectedFlyerId} profile={null} />
<FlyerList flyers={flyers} onFlyerSelect={onFlyerSelect} selectedFlyerId={selectedFlyerId} profile={profile} />
<FlyerUploader onProcessingComplete={refetchFlyers} />
</div>
<div className="lg:col-span-2 flex flex-col space-y-6">
{(dataError || shoppingListError || watchedItemsError || activeDealsError) && (
<ErrorDisplay message={dataError || shoppingListError || watchedItemsError || activeDealsError || 'An unknown error occurred.'} />
{combinedError && (
<ErrorDisplay message={combinedError} />
)}
{/* The Outlet will render the specific page content (e.g., FlyerDisplay or Welcome message) */}
<Outlet context={{ totalActiveItems, masterItems, addWatchedItem, shoppingLists, activeListId, addItemToList }} />
@@ -77,11 +92,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedF
onSelectList={setActiveListId}
onCreateList={createList}
onDeleteList={deleteList}
onAddItem={async (item) => {
if (activeListId) {
await addItemToList(activeListId, item);
}
}}
onAddItem={handleAddItemToShoppingList}
onUpdateItem={updateItemInList}
onRemoveItem={removeItemFromList}
/>
@@ -91,7 +102,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedF
onRemoveItem={removeWatchedItem}
user={user}
activeListId={activeListId}
onAddItemToList={(masterItemId) => activeListId && addItemToList(activeListId, { masterItemId })}
onAddItemToList={handleAddItemFromWatchedList}
/>
<PriceChart
deals={activeDeals}

View File

@@ -26,8 +26,21 @@ vi.mock('../lib/queue', () => ({
cleanupQueue: {},
}));
// Mock dependencies
vi.mock('../services/db/index.db'); // Mock the entire module
// Mock dependencies - specifically for adminRepo and userRepo
const { mockedDb } = vi.hoisted(() => {
return {
mockedDb: {
adminRepo: {
logActivity: vi.fn(),
},
},
};
});
vi.mock('../services/db/index.db', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/db/index.db')>();
return { ...actual, adminRepo: mockedDb.adminRepo };
});
vi.mock('node:fs/promises');
vi.mock('../services/backgroundJobService', () => ({

View File

@@ -15,6 +15,8 @@ import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { render, screen } from '@testing-library/react';
import type { Queue } from 'bullmq';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, flyerWorker, emailWorker, analyticsWorker, cleanupWorker, weeklyAnalyticsWorker } from '../services/queueService.server'; // Import your queues
@@ -47,6 +49,11 @@ createBullBoard({
new BullMQAdapter(cleanupQueue),
new BullMQAdapter(weeklyAnalyticsQueue), // Add the weekly analytics queue to the board
],
options: {
uiConfig: {
boardTitle: 'Bull Dashboard',
},
},
serverAdapter: serverAdapter,
});

View File

@@ -53,19 +53,18 @@ const passportMocks = vi.hoisted(() => {
// --- 2. Module Mocks ---
// Mock 'passport' to bypass actual strategies
vi.mock('passport', () => {
return {
default: {
authenticate: vi.fn().mockImplementation(passportMocks.authenticateMock),
use: vi.fn(),
initialize: () => (req: any, res: any, next: any) => next(),
session: () => (req: any, res: any, next: any) => next(),
},
// Mock the local passport.routes module to control its behavior.
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn().mockImplementation(passportMocks.authenticateMock),
use: vi.fn(),
};
});
initialize: () => (req: any, res: any, next: any) => next(),
session: () => (req: any, res: any, next: any) => next(),
},
// Also mock named exports if they were used in auth.routes.ts, though they are not currently.
isAdmin: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
optionalAuth: vi.fn((req: Request, res: Response, next: NextFunction) => next()),
}));
// Mock the DB connection pool to control transactional behavior
const { mockPool, mockClient } = vi.hoisted(() => {

View File

@@ -0,0 +1,23 @@
// src/routes/price.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { logger } from '../services/logger.server';
const router = Router();
/**
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
* This is a placeholder implementation.
*/
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
const { masterItemIds } = req.body;
if (!Array.isArray(masterItemIds)) {
return res.status(400).json({ message: 'masterItemIds must be an array.' });
}
logger.info('[API /price-history] Received request for historical price data.', { itemCount: masterItemIds.length });
res.status(200).json([]);
});
export default router;

View File

@@ -136,6 +136,42 @@ describe('API Client', () => {
// The apiFetch call should ultimately reject.
await expect(apiClient.apiFetch('/users/profile')).rejects.toThrow('Failed to refresh token.');
});
it('should handle 401 on initial call, refresh token, and then poll until completed', async () => {
localStorage.setItem('authToken', 'expired-token');
// Mock the sequence of events using MSW
server.use(
// 1. Initial call fails with 401
http.get('http://localhost/api/ai/jobs/polling-job/status', () => {
return new HttpResponse(null, { status: 401 });
}, { once: true }),
// 2. Token refresh succeeds
http.post('http://localhost/api/auth/refresh-token', () => {
return HttpResponse.json({ token: 'new-refreshed-token' });
}, { once: true }),
// 3. First poll (after refresh) shows 'active'
http.get('http://localhost/api/ai/jobs/polling-job/status', () => {
return HttpResponse.json({ state: 'active' });
}, { once: true }),
// 4. Second poll shows 'completed'
http.get('http://localhost/api/ai/jobs/polling-job/status', () => {
return HttpResponse.json({ state: 'completed', returnValue: { flyerId: 777 } });
})
);
// This test now correctly simulates a scenario where a component might poll getJobStatus.
// The key is that the mock server (`server.use(...)`) is configured to eventually
// return a 'completed' state, which prevents infinite loops in polling components.
const finalResponse = await apiClient.getJobStatus('polling-job');
const finalData = await finalResponse.json();
expect(finalData.state).toBe('completed');
expect(localStorage.getItem('authToken')).toBe('new-refreshed-token');
});
});
describe('apiFetch (with FormData)', () => {

View File

@@ -3,6 +3,9 @@ import path from 'path';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
// Ensure NODE_ENV is set to 'test' for all Vitest runs.
process.env.NODE_ENV = 'test';
/**
* This is the main configuration file for Vite and the Vitest 'unit' test project.
* When running `vitest`, it is orchestrated by `vitest.workspace.ts`, which
@@ -35,15 +38,14 @@ export default defineConfig({
// The onConsoleLog hook is only needed if you want to conditionally filter specific logs.
// Keeping the default behavior is often safer to avoid missing important warnings.
// Disable file parallelism to run tests sequentially (replaces --no-threads)
fileParallelism: false,
environment: 'jsdom',
// Explicitly point Vitest to the correct tsconfig and enable globals.
globals: true, // tsconfig is auto-detected, so the explicit property is not needed and causes an error.
globalSetup: './src/tests/setup/global-setup.ts',
setupFiles: ['./src/tests/setup/tests-setup-unit.ts'],
// Explicitly include all test files that are NOT integration tests.
include: ['src/**/*.test.{ts,tsx}', 'src/vite-env.d.ts'],
// Explicitly include only test files.
// We remove 'src/vite-env.d.ts' which was causing it to be run as a test.
include: ['src/**/*.test.{ts,tsx}'],
// Exclude integration tests and other non-test files from the unit test runner.
exclude: [
'**/node_modules/**',
@@ -51,6 +53,8 @@ export default defineConfig({
'src/tests/integration/**', // Exclude the entire integration test directory
'**/*.e2e.test.ts'
],
// Disable file parallelism to run tests sequentially (replaces --no-threads)
fileParallelism: false,
coverage: {
provider: 'v8',
// We remove 'text' here. The final text report will be generated by `nyc` after merging.

View File

@@ -3,6 +3,9 @@ import { defineConfig, mergeConfig } from 'vitest/config';
import type { UserConfig } from 'vite';
import viteConfig from './vite.config';
// Ensure NODE_ENV is set to 'test' for all Vitest runs.
process.env.NODE_ENV = 'test';
// 1. Separate the 'test' config (which has Unit Test settings)
// from the rest of the general Vite config (plugins, aliases, etc.)
// DEBUG: Use console.error to ensure logs appear in CI/CD output

View File

@@ -8,6 +8,6 @@ export default defineConfig({
// This setup file is where we can add global test configurations
setupFiles: './src/tests/setup/tests-setup-unit.ts',
// This line is the key fix: it tells Vitest to include the type definitions
include: ['src/**/*.test.tsx', 'src/vite-env.d.ts'],
include: ['src/**/*.test.tsx'],
},
});