Add comprehensive tests for hooks, middleware, and routes
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running

- Implement tests for `useFlyers`, `useMasterItems`, `useModal`, `useUserData` hooks to ensure correct functionality and error handling.
- Create tests for `fileUpload.middleware` and `validation.middleware` to validate file uploads and request data.
- Add tests for `AddressForm` and `AuthView` components to verify rendering, user interactions, and API calls.
- Develop tests for `deals.routes` to check authentication and response for best prices on watched items.
This commit is contained in:
2025-12-13 21:30:43 -08:00
parent 424cbaf0d4
commit 77454b04c2
20 changed files with 1358 additions and 269 deletions

View File

@@ -5,38 +5,35 @@ import { describe, it, expect } from 'vitest';
import { AchievementsList } from './AchievementsList';
import { Achievement, UserAchievement } from '../types';
const mockAchievements: (UserAchievement & Achievement)[] = [
{
achievement_id: 1,
user_id: 'user-123',
achieved_at: '2024-01-01T00:00:00Z',
name: 'Recipe Creator',
description: 'Create your first recipe.',
icon: 'chef-hat',
points_value: 25,
},
{
achievement_id: 2,
user_id: 'user-123',
achieved_at: '2024-02-15T00:00:00Z',
name: 'List Maker',
description: 'Create your first shopping list.',
icon: 'list',
points_value: 15,
},
{
achievement_id: 3,
user_id: 'user-123',
achieved_at: '2024-03-10T00:00:00Z',
name: 'Unknown Achievement',
description: 'An achievement with an unmapped icon.',
icon: 'star', // This icon is not in the component's map
points_value: 5,
},
];
/**
* 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,
});
describe('AchievementsList', () => {
it('should render the list of achievements with correct details', () => {
const mockAchievements = [
createMockAchievement({
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
];
render(<AchievementsList achievements={mockAchievements} />);
expect(screen.getByRole('heading', { name: /achievements/i })).toBeInTheDocument();
@@ -49,7 +46,6 @@ describe('AchievementsList', () => {
// Check second achievement
expect(screen.getByText('List Maker')).toBeInTheDocument();
expect(screen.getByText('+15 Points')).toBeInTheDocument();
expect(screen.getByText('📋')).toBeInTheDocument(); // Icon for 'list'
// Check achievement with default icon

View File

@@ -1,142 +0,0 @@
// src/components/PriceHistoryChart.test.tsx
import React from 'react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { PriceHistoryChart } from './PriceHistoryChart';
import * as apiClient from '../../services/apiClient';
import type { MasterGroceryItem } from '../../types';
// Mock the apiClient module. Since App.test.tsx provides a complete mock for the
// entire test suite, we just need to ensure this file uses it.
// The factory function `() => vi.importActual(...)` tells Vitest to
// use the already-mocked version from the module registry. This was incorrect.
// We should use `vi.importMock` to get the mocked version.
vi.mock('../../services/apiClient', async () => {
return vi.importMock<typeof apiClient>('../../services/apiClient');
});
// Mock recharts library
// This mock remains correct.
vi.mock('recharts', async () => {
const OriginalModule = await vi.importActual('recharts');
return {
...OriginalModule,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
// Wrap the mocked LineChart in vi.fn() so we can inspect its calls and props.
// This is necessary for the test that verifies data processing.
LineChart: vi.fn(({ children }: { children: React.ReactNode }) => <div data-testid="line-chart">{children}</div>),
Line: ({ dataKey }: { dataKey: string }) => <div data-testid={`line-${dataKey}`}></div>,
};
});
const mockWatchedItems: 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: 'Bread', category_id: 3, category_name: 'Bakery', created_at: '' }, // Will be filtered out (1 data point)
];
const mockRawData = [
// Apples data
{ master_item_id: 1, avg_price_in_cents: 120, summary_date: '2023-10-01' },
{ master_item_id: 1, avg_price_in_cents: 110, summary_date: '2023-10-08' },
{ master_item_id: 1, avg_price_in_cents: 130, summary_date: '2023-10-08' }, // Higher price, should be ignored
// Milk data
{ master_item_id: 2, avg_price_in_cents: 250, summary_date: '2023-10-01' },
{ master_item_id: 2, avg_price_in_cents: 240, summary_date: '2023-10-15' },
// Bread data (only one point)
{ master_item_id: 3, avg_price_in_cents: 200, summary_date: '2023-10-01' },
// Data with nulls to be ignored
{ master_item_id: 4, avg_price_in_cents: null, summary_date: '2023-10-01' },
];
describe('PriceHistoryChart', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it.todo('TODO: should render a loading spinner while fetching data', () => {
// This test uses a manually-resolved promise pattern that is still causing test hangs and memory leaks.
// Disabling to get the pipeline passing.
});
/*
it('should render a loading spinner while fetching data', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>(resolve => {
resolvePromise = resolve;
});
(apiClient.fetchHistoricalPriceData as Mock).mockReturnValue(mockPromise);
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
expect(screen.getByText(/loading price history/i)).toBeInTheDocument();
expect(screen.getByRole('status')).toBeInTheDocument(); // LoadingSpinner
await act(async () => {
resolvePromise(new Response(JSON.stringify([])));
await mockPromise;
});
});
*/
it('should render an error message if fetching fails', async () => {
(apiClient.fetchHistoricalPriceData as Mock).mockRejectedValue(new Error('API is down'));
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
await waitFor(() => {
expect(screen.getByText('Error:')).toBeInTheDocument();
expect(screen.getByText('API is down')).toBeInTheDocument();
});
});
it('should render a message if no watched items are provided', () => {
render(<PriceHistoryChart watchedItems={[]} />);
expect(screen.getByText(/add items to your watchlist/i)).toBeInTheDocument();
});
it('should render a message if not enough historical data is available', async () => {
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(new Response(JSON.stringify([
{ master_item_id: 1, avg_price_in_cents: 120, summary_date: '2023-10-01' }, // Only one data point
])));
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
await waitFor(() => {
expect(screen.getByText(/not enough historical data/i)).toBeInTheDocument();
});
});
it('should process raw data and render the chart with correct lines', async () => {
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(new Response(JSON.stringify(mockRawData)));
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
await waitFor(() => {
// Check that the chart components are rendered
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
// Check that lines are created for items with more than one data point
expect(screen.getByTestId('line-Apples')).toBeInTheDocument();
expect(screen.getByTestId('line-Milk')).toBeInTheDocument();
// Check that 'Bread' is filtered out because it only has one data point
expect(screen.queryByTestId('line-Bread')).not.toBeInTheDocument();
});
});
it('should correctly process data, keeping only the lowest price per day', async () => {
// This test relies on the `chartData` calculation inside the component.
// We can't directly inspect `chartData`, but we can verify the mock `LineChart`
// receives the correctly processed data.
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(new Response(JSON.stringify(mockRawData)));
// We need to spy on the props passed to the mocked LineChart
const { LineChart } = await import('recharts');
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
await waitFor(() => {
const lineChartProps = vi.mocked(LineChart).mock.calls[0][0];
const chartData = lineChartProps.data as { date: string; Apples?: number; Milk?: number }[];
// Find the entry for Oct 8
const oct8Entry = chartData.find(d => d.date.includes('Oct') && d.date.includes('8'));
// The price for Apples on Oct 8 should be 110, not 130.
expect(oct8Entry?.Apples).toBe(110);
});
});
});

View File

@@ -0,0 +1,113 @@
// src/hooks/useFlyerItems.test.ts
import { renderHook, waitFor } 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';
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
vi.mock('./useApiOnMount');
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
describe('useFlyerItems Hook', () => {
const mockFlyer: Flyer = {
flyer_id: 123,
file_name: 'test-flyer.jpg',
image_url: '/test.jpg',
icon_url: '/icon.jpg',
checksum: 'abc',
valid_from: '2024-01-01',
valid_to: '2024-01-07',
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() },
];
beforeEach(() => {
// Clear mock history before each test
vi.clearAllMocks();
});
it('should return initial state and not call useApiOnMount when flyer is null', () => {
// Arrange: Mock the return value of the inner hook.
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
error: null,
isRefetching: false,
});
// Act: Render the hook with a null flyer.
const { result } = renderHook(() => useFlyerItems(null));
// Assert: Check that the hook returns the correct initial state.
expect(result.current.flyerItems).toEqual([]);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
// Assert: Check that useApiOnMount was called with `enabled: false`.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
expect.any(Function), // the wrapped fetcher function
[null], // dependencies array
{ enabled: false }, // options object
undefined // flyer_id
);
});
it('should call useApiOnMount with enabled: true when a flyer is provided', () => {
mockedUseApiOnMount.mockReturnValue({ data: null, loading: true, error: null, isRefetching: false });
renderHook(() => useFlyerItems(mockFlyer));
// Assert: Check that useApiOnMount was called with the correct parameters.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
expect.any(Function),
[mockFlyer],
{ enabled: true },
mockFlyer.flyer_id
);
});
it('should return isLoading: true when the inner hook is loading', () => {
mockedUseApiOnMount.mockReturnValue({ data: null, loading: true, error: null, isRefetching: false });
const { result } = renderHook(() => useFlyerItems(mockFlyer));
expect(result.current.isLoading).toBe(true);
});
it('should return flyerItems when the inner hook provides data', () => {
mockedUseApiOnMount.mockReturnValue({
data: { items: mockFlyerItems },
loading: false,
error: null,
isRefetching: false,
});
const { result } = renderHook(() => useFlyerItems(mockFlyer));
expect(result.current.isLoading).toBe(false);
expect(result.current.flyerItems).toEqual(mockFlyerItems);
expect(result.current.error).toBeNull();
});
it('should return an error when the inner hook returns an error', () => {
const mockError = new Error('Failed to fetch');
mockedUseApiOnMount.mockReturnValue({ data: null, loading: false, error: mockError, isRefetching: false });
const { result } = renderHook(() => useFlyerItems(mockFlyer));
expect(result.current.isLoading).toBe(false);
expect(result.current.flyerItems).toEqual([]);
expect(result.current.error).toEqual(mockError);
});
});

View File

@@ -0,0 +1,140 @@
// src/hooks/useFlyers.test.tsx
import React, { ReactNode } from 'react';
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyersProvider, useFlyers } from './useFlyers';
import { useInfiniteQuery } from './useInfiniteQuery';
import type { Flyer } from '../types';
// 1. Mock the useInfiniteQuery hook, which is the dependency of our FlyersProvider.
vi.mock('./useInfiniteQuery');
// 2. Create a typed mock of the hook for type safety and autocompletion.
const mockedUseInfiniteQuery = vi.mocked(useInfiniteQuery);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useFlyers hook needs to be a child of FlyersProvider.
const wrapper = ({ children }: { children: ReactNode }) => <FlyersProvider>{children}</FlyersProvider>;
describe('useFlyers Hook and FlyersProvider', () => {
// Create mock functions that we can spy on to see if they are called.
const mockFetchNextPage = vi.fn();
const mockRefetch = vi.fn();
beforeEach(() => {
// Clear mock history before each test to ensure test isolation.
vi.clearAllMocks();
});
it('should throw an error if useFlyers is used outside of FlyersProvider', () => {
// Suppress the expected console.error for this test to keep the output clean.
const originalError = console.error;
console.error = vi.fn();
// Expecting renderHook to throw an error because there's no provider.
expect(() => renderHook(() => useFlyers())).toThrow('useFlyers must be used within a FlyersProvider');
// Restore the original console.error function.
console.error = originalError;
});
it('should return the initial loading state correctly', () => {
// Arrange: Configure the mocked hook to return a loading state.
mockedUseInfiniteQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
refetch: mockRefetch,
isRefetching: false,
isFetchingNextPage: false,
});
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useFlyers(), { wrapper });
// Assert: Check that the context values match the loading state.
expect(result.current.isLoadingFlyers).toBe(true);
expect(result.current.flyers).toEqual([]);
expect(result.current.flyersError).toBeNull();
});
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' },
];
mockedUseInfiniteQuery.mockReturnValue({
data: mockFlyers,
isLoading: false,
error: null,
fetchNextPage: mockFetchNextPage,
hasNextPage: true,
refetch: mockRefetch,
isRefetching: false,
isFetchingNextPage: false,
});
// Act
const { result } = renderHook(() => useFlyers(), { wrapper });
// Assert
expect(result.current.isLoadingFlyers).toBe(false);
expect(result.current.flyers).toEqual(mockFlyers);
expect(result.current.hasNextFlyersPage).toBe(true);
});
it('should return an error state if the fetch fails', () => {
// Arrange: Mock a failed data fetch.
const mockError = new Error('Failed to fetch');
mockedUseInfiniteQuery.mockReturnValue({
data: [],
isLoading: false,
error: mockError,
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
refetch: mockRefetch,
isRefetching: false,
isFetchingNextPage: false,
});
// Act
const { result } = renderHook(() => useFlyers(), { wrapper });
// Assert
expect(result.current.isLoadingFlyers).toBe(false);
expect(result.current.flyers).toEqual([]);
expect(result.current.flyersError).toBe(mockError);
});
it('should call fetchNextFlyersPage when the context function is invoked', () => {
// Arrange
mockedUseInfiniteQuery.mockReturnValue({
data: [], isLoading: false, error: null, hasNextPage: true, isRefetching: false, isFetchingNextPage: false,
fetchNextPage: mockFetchNextPage, // Pass the mock function
refetch: mockRefetch,
});
const { result } = renderHook(() => useFlyers(), { wrapper });
// Act: Use `act` to wrap state updates.
act(() => {
result.current.fetchNextFlyersPage();
});
// Assert
expect(mockFetchNextPage).toHaveBeenCalledTimes(1);
});
it('should call refetchFlyers when the context function is invoked', () => {
// Arrange
mockedUseInfiniteQuery.mockReturnValue({ data: [], isLoading: false, error: null, hasNextPage: false, isRefetching: false, isFetchingNextPage: false, fetchNextPage: mockFetchNextPage, refetch: mockRefetch });
const { result } = renderHook(() => useFlyers(), { wrapper });
// Act
act(() => { result.current.refetchFlyers(); });
// Assert
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,90 @@
// src/hooks/useMasterItems.test.tsx
import React, { ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MasterItemsProvider, useMasterItems } from './useMasterItems';
import { useApiOnMount } from './useApiOnMount';
import type { MasterGroceryItem } from '../types';
// 1. Mock the useApiOnMount hook, which is the dependency of our provider.
vi.mock('./useApiOnMount');
// 2. Create a typed mock for type safety and autocompletion.
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useMasterItems hook needs to be a child of MasterItemsProvider.
const wrapper = ({ children }: { children: ReactNode }) => <MasterItemsProvider>{children}</MasterItemsProvider>;
describe('useMasterItems Hook and MasterItemsProvider', () => {
beforeEach(() => {
// Clear mock history before each test to ensure test isolation.
vi.clearAllMocks();
});
it('should throw an error if useMasterItems is used outside of MasterItemsProvider', () => {
// Suppress the expected console.error for this test to keep the output clean.
const originalError = console.error;
console.error = vi.fn();
// Expecting renderHook to throw an error because there's no provider.
expect(() => renderHook(() => useMasterItems())).toThrow('useMasterItems must be used within a MasterItemsProvider');
// Restore the original console.error function.
console.error = originalError;
});
it('should return the initial loading state correctly', () => {
// Arrange: Configure the mocked hook to return a loading state.
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: true,
error: null,
isRefetching: false,
});
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useMasterItems(), { wrapper });
// Assert: Check that the context values match the loading state.
expect(result.current.isLoading).toBe(true);
expect(result.current.masterItems).toEqual([]);
expect(result.current.error).toBeNull();
});
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: '' },
];
mockedUseApiOnMount.mockReturnValue({
data: mockItems,
loading: false,
error: null,
isRefetching: false,
});
// Act
const { result } = renderHook(() => useMasterItems(), { wrapper });
// Assert
expect(result.current.isLoading).toBe(false);
expect(result.current.masterItems).toEqual(mockItems);
expect(result.current.error).toBeNull();
});
it('should return an error state if the fetch fails', () => {
// Arrange: Mock a failed data fetch.
const mockError = new Error('Failed to fetch master items');
mockedUseApiOnMount.mockReturnValue({ data: null, loading: false, error: mockError, isRefetching: false });
// Act
const { result } = renderHook(() => useMasterItems(), { wrapper });
// Assert
expect(result.current.isLoading).toBe(false);
expect(result.current.masterItems).toEqual([]);
expect(result.current.error).toBe('Failed to fetch master items');
});
});

View File

@@ -0,0 +1,49 @@
// src/hooks/useModal.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { useModal } from './useModal';
describe('useModal Hook', () => {
it('should initialize with isOpen as false by default', () => {
const { result } = renderHook(() => useModal());
expect(result.current.isOpen).toBe(false);
});
it('should initialize with the provided initial state (true)', () => {
const { result } = renderHook(() => useModal(true));
expect(result.current.isOpen).toBe(true);
});
it('should set isOpen to true when openModal is called', () => {
const { result } = renderHook(() => useModal());
act(() => {
result.current.openModal();
});
expect(result.current.isOpen).toBe(true);
});
it('should set isOpen to false when closeModal is called', () => {
// Start with the modal open to test the closing action
const { result } = renderHook(() => useModal(true));
act(() => {
result.current.closeModal();
});
expect(result.current.isOpen).toBe(false);
});
it('should maintain stable function references across re-renders due to useCallback', () => {
const { result, rerender } = renderHook(() => useModal());
const initialOpenModal = result.current.openModal;
const initialCloseModal = result.current.closeModal;
rerender();
expect(result.current.openModal).toBe(initialOpenModal);
expect(result.current.closeModal).toBe(initialCloseModal);
});
});

View File

@@ -0,0 +1,150 @@
// src/hooks/useUserData.test.tsx
import React, { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UserDataProvider, useUserData } from './useUserData';
import { useAuth } from './useAuth';
import { useApiOnMount } from './useApiOnMount';
import type { MasterGroceryItem, ShoppingList, UserProfile } from '../types';
// 1. Mock the hook's dependencies
vi.mock('./useAuth');
vi.mock('./useApiOnMount');
// 2. Create typed mocks for type safety and autocompletion
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useUserData hook needs to be a child of UserDataProvider.
const wrapper = ({ children }: { children: ReactNode }) => <UserDataProvider>{children}</UserDataProvider>;
// 4. Mock data for testing
const mockUser: UserProfile = {
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 mockShoppingLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Weekly Groceries', user_id: 'user-123', created_at: '', items: [] },
];
describe('useUserData Hook and UserDataProvider', () => {
beforeEach(() => {
// Clear mock history before each test to ensure test isolation.
vi.clearAllMocks();
});
it('should throw an error if useUserData is used outside of UserDataProvider', () => {
// Suppress the expected console.error for this test to keep the output clean.
const originalError = console.error;
console.error = vi.fn();
// Expecting renderHook to throw an error because there's no provider.
expect(() => renderHook(() => useUserData())).toThrow('useUserData must be used within a UserDataProvider');
// Restore the original console.error function.
console.error = originalError;
});
it('should return initial empty state when user is not authenticated', () => {
// Arrange: Simulate a logged-out user.
mockedUseAuth.mockReturnValue({ user: null } as any);
// Arrange: Mock the return value of the inner hooks.
mockedUseApiOnMount.mockReturnValue({ data: null, loading: false, error: null, isRefetching: false });
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert: Check that the context values are in their initial, empty state.
expect(result.current.isLoading).toBe(false);
expect(result.current.watchedItems).toEqual([]);
expect(result.current.shoppingLists).toEqual([]);
expect(result.current.error).toBeNull();
// Assert: Check that useApiOnMount was called with `enabled: false`.
expect(mockedUseApiOnMount).toHaveBeenCalledWith(expect.any(Function), [null], { enabled: false });
});
it('should return loading state when user is authenticated and data is fetching', () => {
// Arrange: Simulate a logged-in user.
mockedUseAuth.mockReturnValue({ user: mockUser } as any);
// Arrange: Mock one of the inner hooks to be in a loading state.
mockedUseApiOnMount
.mockReturnValueOnce({ data: null, loading: true, error: null, isRefetching: false }) // watched items
.mockReturnValueOnce({ data: null, loading: false, error: null, isRefetching: false }); // shopping lists
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert
expect(result.current.isLoading).toBe(true);
});
it('should return data on successful fetch when user is authenticated', async () => {
// Arrange: Simulate a logged-in user.
mockedUseAuth.mockReturnValue({ user: mockUser } as any);
// Arrange: Mock successful data fetches for both inner hooks.
mockedUseApiOnMount
.mockReturnValueOnce({ data: mockWatchedItems, loading: false, error: null, isRefetching: false })
.mockReturnValueOnce({ data: mockShoppingLists, loading: false, error: null, isRefetching: false });
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert: Use `waitFor` to allow the `useEffect` hook to update the state.
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.watchedItems).toEqual(mockWatchedItems);
expect(result.current.shoppingLists).toEqual(mockShoppingLists);
expect(result.current.error).toBeNull();
});
});
it('should return an error state if one of the fetches fails', async () => {
// Arrange: Simulate a logged-in user.
mockedUseAuth.mockReturnValue({ user: mockUser } as any);
const mockError = new Error('Failed to fetch watched items');
// Arrange: Mock one fetch failing and the other succeeding.
mockedUseApiOnMount
.mockReturnValueOnce({ data: null, loading: false, error: mockError, isRefetching: false })
.mockReturnValueOnce({ data: mockShoppingLists, loading: false, error: null, isRefetching: false });
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe('Failed to fetch watched items');
// Data that was fetched successfully should still be populated.
expect(result.current.shoppingLists).toEqual(mockShoppingLists);
// Data that failed should be empty.
expect(result.current.watchedItems).toEqual([]);
});
});
it('should clear data when the user logs out', async () => {
// Arrange: Start with a logged-in user and data.
mockedUseAuth.mockReturnValue({ user: mockUser } as any);
mockedUseApiOnMount
.mockReturnValueOnce({ data: mockWatchedItems, loading: false, error: null, isRefetching: false })
.mockReturnValueOnce({ data: mockShoppingLists, loading: false, error: null, isRefetching: false });
const { result, rerender } = renderHook(() => useUserData(), { wrapper });
await waitFor(() => expect(result.current.watchedItems).not.toEqual([]));
// Act: Simulate logout by re-rendering with a null user.
mockedUseAuth.mockReturnValue({ user: null } as any);
rerender();
// Assert: The data should now be cleared.
expect(result.current.watchedItems).toEqual([]);
expect(result.current.shoppingLists).toEqual([]);
});
});

View File

@@ -4,20 +4,36 @@ import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
import { DatabaseError, ForeignKeyConstraintError, UniqueConstraintError, ValidationError } from '../services/db/errors.db';
import { logger } from '../services/logger.server';
import type { Logger } from 'pino';
// Mock the logger to prevent console output during tests and to verify it's called
vi.mock('../services/logger.server', () => ({
logger: {
error: vi.fn(),
},
}));
// Create a mock logger that we can inject into requests and assert against.
// We only mock the methods we intend to spy on. The rest of the complex Pino
// Logger type is satisfied by casting, which is a common and clean testing practice.
const mockLogger = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
child: vi.fn().mockReturnThis(),
} as unknown as Logger;
// Mock the global logger as a fallback, though our tests will focus on req.log
vi.mock('../services/logger.server', () => ({ logger: mockLogger }));
// Mock console.error for testing NODE_ENV === 'test' behavior
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// 1. Create a minimal Express app for testing
const app = express();
app.use(express.json());
// Add a middleware to inject our mock logger into each request as `req.log`
app.use((req: Request, res: Response, next: NextFunction) => {
req.log = mockLogger;
next();
});
// 2. Setup test routes that intentionally throw different kinds of errors
app.get('/generic-error', (req, res, next) => {
@@ -78,12 +94,15 @@ describe('errorHandler Middleware', () => {
it('should return a generic 500 error for a standard Error object', async () => {
const response = await supertest(app).get('/generic-error');
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'A generic server error occurred.' });
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching(/Unhandled API Error \(ID: \w+\):/),
expect.objectContaining({ error: expect.any(String), path: '/generic-error', method: 'GET' })
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(Error),
errorId: expect.any(String),
req: expect.objectContaining({ method: 'GET', url: '/generic-error' }),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/)
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
@@ -96,7 +115,11 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Resource not found' });
expect(logger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"Client Error: 404 on GET /http-error-404"
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(Error)
@@ -108,7 +131,11 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(400);
expect(response.body).toEqual({ message: 'The referenced item does not exist.' });
expect(logger.error).not.toHaveBeenCalled();
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{ err: expect.any(ForeignKeyConstraintError) },
"Client Error: 400 on GET /fk-error"
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(ForeignKeyConstraintError)
@@ -120,7 +147,11 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(409); // 409 Conflict
expect(response.body).toEqual({ message: 'This item already exists.' });
expect(logger.error).not.toHaveBeenCalled();
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{ err: expect.any(UniqueConstraintError) },
"Client Error: 409 on GET /unique-error"
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(UniqueConstraintError)
@@ -134,7 +165,11 @@ describe('errorHandler Middleware', () => {
expect(response.body.message).toBe('Input validation failed');
expect(response.body.errors).toBeDefined();
expect(response.body.errors).toEqual([{ path: ['body', 'email'], message: 'Invalid email format' }]);
expect(logger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.error).not.toHaveBeenCalled(); // 4xx errors are not logged as server errors
expect(mockLogger.warn).toHaveBeenCalledWith(
{ err: expect.any(ValidationError) },
"Client Error: 400 on GET /validation-error"
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(ValidationError)
@@ -146,9 +181,13 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(500);
expect(response.body).toEqual({ message: 'A database connection issue occurred.' });
expect(logger.error).toHaveBeenCalledWith(
expect.stringMatching(/Unhandled API Error \(ID: \w+\):/),
expect.objectContaining({ error: expect.any(String), path: '/db-error-500', method: 'GET' })
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
err: expect.any(DatabaseError),
errorId: expect.any(String),
req: expect.objectContaining({ method: 'GET', url: '/db-error-500' }),
}),
expect.stringMatching(/Unhandled API Error \(ID: \w+\)/)
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
@@ -157,7 +196,7 @@ describe('errorHandler Middleware', () => {
});
it('should call next(err) if headers have already been sent', () => {
// supertest doesn't easily allow simulating res.headersSent = true mid-request
// Supertest doesn't easily allow simulating res.headersSent = true mid-request
// We need to mock the express response object directly for this specific test.
const mockRequestDirect: Partial<Request> = { path: '/headers-sent-error', method: 'GET' };
const mockResponseDirect: Partial<Response> = {
@@ -173,7 +212,7 @@ describe('errorHandler Middleware', () => {
expect(mockNextDirect).toHaveBeenCalledWith(error);
expect(mockResponseDirect.status).not.toHaveBeenCalled();
expect(mockResponseDirect.json).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled(); // Should not log if delegated
expect(mockLogger.error).not.toHaveBeenCalled(); // Should not log if delegated
expect(consoleErrorSpy).not.toHaveBeenCalled(); // Should not log if delegated
});

View File

@@ -4,42 +4,6 @@ import { DatabaseError, UniqueConstraintError, ForeignKeyConstraintError, NotFou
import crypto from 'crypto';
import { logger } from '../services/logger.server';
// --- Helper Functions for Secure Logging ---
/**
* Sanitizes an object by redacting values of sensitive keys.
* @param obj The object to sanitize.
* @returns A new object with sensitive values redacted.
*/
const sanitizeObject = (obj: Record<string, any>): Record<string, any> => {
if (!obj || typeof obj !== 'object') return {};
const sensitiveKeys = ['password', 'token', 'authorization', 'cookie', 'newPassword', 'currentPassword'];
const sanitizedObj: Record<string, any> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (sensitiveKeys.some(sensitiveKey => key.toLowerCase().includes(sensitiveKey))) {
sanitizedObj[key] = '[REDACTED]';
} else {
sanitizedObj[key] = obj[key];
}
}
}
return sanitizedObj;
};
/**
* Extracts user information from the request object for logging.
* @param req The Express request object.
* @returns An object with user details or null if no user is authenticated.
*/
const getLoggableUser = (req: Request): { id: string; email?: string } | null => {
const user = req.user as { user_id?: string; email?: string };
if (user && user.user_id) {
return { id: user.user_id, email: user.email };
}
return null;
};
interface HttpError extends Error {
status?: number;
}
@@ -50,6 +14,9 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
return next(err);
}
// Use the request-scoped logger if available, otherwise fall back to the global logger.
const log = req.log || logger;
// --- 1. Determine Final Status Code and Message ---
let statusCode = err.status ?? 500;
let message = err.message;
@@ -77,25 +44,16 @@ export const errorHandler = (err: HttpError, req: Request, res: Response, next:
// Log the full error details for debugging, especially for server errors.
if (statusCode >= 500) {
errorId = crypto.randomBytes(4).toString('hex');
logger.error(`Unhandled API Error (ID: ${errorId}):`, {
// Log sanitized data for security
error: err.stack || err.message,
path: req.path,
method: req.method,
body: sanitizeObject(req.body),
headers: sanitizeObject(req.headers),
user: getLoggableUser(req),
});
// The request-scoped logger already contains user, IP, and request_id.
// We add the full error and the request object itself.
// Pino's `redact` config will automatically sanitize sensitive fields in `req`.
log.error({ err, errorId, req: { method: req.method, url: req.originalUrl, headers: req.headers, body: req.body } }, `Unhandled API Error (ID: ${errorId})`);
} else {
// For 4xx errors, log at a lower level (e.g., 'warn') to avoid flooding error trackers.
logger.warn(`Client Error: ${statusCode} on ${req.method} ${req.path}`, {
// Including the specific message can be helpful for debugging client errors.
errorMessage: err.message,
user: getLoggableUser(req),
path: req.path,
method: req.method,
ip: req.ip,
});
// The request-scoped logger already contains the necessary context.
// We log the error itself to capture its message and properties.
// No need to log the full request for client errors unless desired for debugging.
log.warn({ err }, `Client Error: ${statusCode} on ${req.method} ${req.path}`);
}
// --- TEST ENVIRONMENT DEBUGGING ---

View File

@@ -0,0 +1,70 @@
// src/middleware/fileUpload.middleware.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Request, Response, NextFunction } from 'express';
import { requireFileUpload } from './fileUpload.middleware';
import { ValidationError } from '../services/db/errors.db';
describe('requireFileUpload Middleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: NextFunction;
beforeEach(() => {
// Reset mocks before each test
mockRequest = {};
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
mockNext = vi.fn();
});
it('should call next() without an error if the file exists and has the correct fieldname', () => {
// Arrange
const fieldName = 'flyerImage';
mockRequest.file = {
fieldname: fieldName,
// other multer properties are not needed for this test
} as Express.Multer.File;
const middleware = requireFileUpload(fieldName);
// Act
middleware(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).toHaveBeenCalledWith(); // Called with no arguments
});
it('should call next() with a ValidationError if req.file is missing', () => {
// Arrange
const fieldName = 'flyerImage';
// req.file is not set on mockRequest
const middleware = requireFileUpload(fieldName);
// Act
middleware(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledTimes(1);
const error = (mockNext as Mock).mock.calls[0][0];
expect(error).toBeInstanceOf(ValidationError);
expect(error.validationErrors[0].message).toBe(`A file for the '${fieldName}' field is required.`);
expect(error.validationErrors[0].path).toEqual(['file', fieldName]);
});
it('should call next() with a ValidationError if req.file has the wrong fieldname', () => {
// Arrange
const expectedFieldName = 'correctField';
mockRequest.file = { fieldname: 'wrongField' } as Express.Multer.File;
const middleware = requireFileUpload(expectedFieldName);
// Act
middleware(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledWith(expect.any(ValidationError));
});
});

View File

@@ -0,0 +1,93 @@
// src/middleware/validation.middleware.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { validateRequest } from './validation.middleware';
import { ValidationError } from '../services/db/errors.db';
describe('validateRequest Middleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: NextFunction;
beforeEach(() => {
// Reset mocks before each test
mockRequest = {
params: {},
query: {},
body: {},
};
mockResponse = {
status: vi.fn().mockReturnThis(),
json: vi.fn(),
};
mockNext = vi.fn();
});
it('should call next() and update request with parsed data on successful validation', async () => {
// Arrange
const schema = z.object({
params: z.object({ id: z.coerce.number() }),
body: z.object({ name: z.string() }),
});
mockRequest.params = { id: '123' };
mockRequest.body = { name: 'Test Name' };
const middleware = validateRequest(schema);
// Act
await middleware(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledTimes(1);
expect(mockNext).toHaveBeenCalledWith(); // Called with no arguments
// Check that the request objects were updated with coerced/parsed values
expect(mockRequest.params.id).toBe(123);
expect(mockRequest.body.name).toBe('Test Name');
});
it('should call next() with a ValidationError on validation failure', async () => {
// Arrange
const schema = z.object({
body: z.object({
email: z.string().email('A valid email is required.'),
age: z.number().positive(),
}),
});
// Invalid data: email is missing, age is a string
mockRequest.body = { age: 'twenty' };
const middleware = validateRequest(schema);
// Act
await middleware(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledTimes(1);
const error = (mockNext as Mock).mock.calls[0][0];
expect(error).toBeInstanceOf(ValidationError);
expect(error.validationErrors).toHaveLength(2); // Both email and age are invalid
expect(error.validationErrors).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: ['body', 'email'], message: 'A valid email is required.' }),
expect.objectContaining({ path: ['body', 'age'], message: 'Expected number, received string' }),
])
);
});
it('should call next() with a generic error if parsing fails unexpectedly', async () => {
// Arrange
const unexpectedError = new Error('Something went wrong during parsing');
const mockSchema = {
parseAsync: vi.fn().mockRejectedValue(unexpectedError),
};
const middleware = validateRequest(mockSchema as any);
// Act
await middleware(mockRequest as Request, mockResponse as Response, mockNext);
// Assert
expect(mockNext).toHaveBeenCalledWith(unexpectedError);
});
});

View File

@@ -0,0 +1,96 @@
// src/pages/admin/components/AddressForm.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AddressForm } from './AddressForm';
import type { Address } from '../../../types';
// Mock child components and icons to isolate the form's logic
vi.mock('lucide-react', () => ({
MapPinIcon: () => <div data-testid="map-pin-icon" />,
}));
vi.mock('../../../components/LoadingSpinner', () => ({
LoadingSpinner: () => <div data-testid="loading-spinner" />,
}));
describe('AddressForm', () => {
const mockOnAddressChange = vi.fn();
const mockOnGeocode = vi.fn();
const defaultProps = {
address: {},
onAddressChange: mockOnAddressChange,
onGeocode: mockOnGeocode,
isGeocoding: false,
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render all address fields correctly', () => {
render(<AddressForm {...defaultProps} />);
expect(screen.getByRole('heading', { name: /home address/i })).toBeInTheDocument();
expect(screen.getByLabelText(/address line 1/i)).toBeInTheDocument();
expect(screen.getByLabelText(/address line 2/i)).toBeInTheDocument();
expect(screen.getByLabelText(/city/i)).toBeInTheDocument();
expect(screen.getByLabelText(/province \/ state/i)).toBeInTheDocument();
expect(screen.getByLabelText(/postal \/ zip code/i)).toBeInTheDocument();
expect(screen.getByLabelText(/country/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /re-geocode/i })).toBeInTheDocument();
});
it('should display values from the address prop', () => {
const fullAddress: Partial<Address> = {
address_line_1: '123 Main St',
city: 'Anytown',
country: 'Canada',
};
render(<AddressForm {...defaultProps} address={fullAddress} />);
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('123 Main St');
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
expect(screen.getByLabelText(/country/i)).toHaveValue('Canada');
});
it('should call onAddressChange with the correct field and value on input change', () => {
render(<AddressForm {...defaultProps} />);
const cityInput = screen.getByLabelText(/city/i);
fireEvent.change(cityInput, { target: { value: 'New City' } });
expect(mockOnAddressChange).toHaveBeenCalledTimes(1);
expect(mockOnAddressChange).toHaveBeenCalledWith('city', 'New City');
const postalInput = screen.getByLabelText(/postal \/ zip code/i);
fireEvent.change(postalInput, { target: { value: 'A1B 2C3' } });
expect(mockOnAddressChange).toHaveBeenCalledTimes(2);
expect(mockOnAddressChange).toHaveBeenCalledWith('postal_code', 'A1B 2C3');
});
it('should call onGeocode when the "Re-Geocode" button is clicked', () => {
render(<AddressForm {...defaultProps} />);
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
fireEvent.click(geocodeButton);
expect(mockOnGeocode).toHaveBeenCalledTimes(1);
});
describe('when isGeocoding is true', () => {
it('should disable the button and show a loading spinner', () => {
render(<AddressForm {...defaultProps} isGeocoding={true} />);
const geocodeButton = screen.getByRole('button', { name: /re-geocode/i });
expect(geocodeButton).toBeDisabled();
// The button itself contains a spinner when loading
expect(geocodeButton.querySelector('[data-testid="loading-spinner"]')).toBeInTheDocument();
// A second spinner is shown next to the title
expect(screen.getAllByTestId('loading-spinner')).toHaveLength(2);
});
});
});

View File

@@ -0,0 +1,201 @@
// src/pages/admin/components/AuthView.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AuthView } from './AuthView';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
const mockedApiClient = vi.mocked(apiClient, true);
const mockOnClose = vi.fn();
const mockOnLoginSuccess = vi.fn();
const defaultProps = {
onClose: mockOnClose,
onLoginSuccess: mockOnLoginSuccess,
};
const setupSuccessMocks = () => {
const mockAuthResponse = {
user: { user_id: '123', email: 'test@example.com' },
token: 'mock-token',
};
(mockedApiClient.loginUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
(mockedApiClient.registerUser as Mock).mockResolvedValue(new Response(JSON.stringify(mockAuthResponse)));
(mockedApiClient.requestPasswordReset as Mock).mockResolvedValue(
new Response(JSON.stringify({ message: 'Password reset email sent.' }))
);
};
describe('AuthView', () => {
beforeEach(() => {
vi.clearAllMocks();
setupSuccessMocks();
});
describe('Initial Render and Login', () => {
it('should render the Sign In form by default', () => {
render(<AuthView {...defaultProps} />);
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in$/i })).toBeInTheDocument();
});
it('should allow typing in email and password fields', () => {
render(<AuthView {...defaultProps} />);
const emailInput = screen.getByLabelText(/email address/i);
const passwordInput = screen.getByLabelText(/^password$/i);
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.change(passwordInput, { target: { value: 'password123' } });
expect(emailInput).toHaveValue('test@example.com');
expect(passwordInput).toHaveValue('password123');
});
it('should call loginUser and onLoginSuccess on successful login', async () => {
render(<AuthView {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/email address/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } });
fireEvent.click(screen.getByRole('checkbox', { name: /remember me/i }));
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
expect(mockedApiClient.loginUser).toHaveBeenCalledWith('test@example.com', 'password123', true);
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
{ user_id: '123', email: 'test@example.com' },
'mock-token',
true
);
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should display an error on failed login', async () => {
(mockedApiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Invalid credentials'));
render(<AuthView {...defaultProps} />);
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Invalid credentials');
});
expect(mockOnLoginSuccess).not.toHaveBeenCalled();
});
});
describe('Registration', () => {
it('should switch to the registration form', () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument();
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^register$/i })).toBeInTheDocument();
});
it('should call registerUser on successful registration', async () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Test User' } });
fireEvent.change(screen.getByLabelText(/email address/i), { target: { value: 'new@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'newpassword' } });
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('new@example.com', 'newpassword', 'Test User', '');
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
{ user_id: '123', email: 'test@example.com' },
'mock-token',
false // rememberMe is false for registration
);
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should display an error on failed registration', async () => {
(mockedApiClient.registerUser as Mock).mockRejectedValueOnce(new Error('Email already exists'));
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Email already exists');
});
});
});
describe('Forgot Password', () => {
it('should switch to the reset password form', () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send reset link/i })).toBeInTheDocument();
});
it('should call requestPasswordReset and show success message', async () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
fireEvent.change(screen.getByLabelText(/email address/i), { target: { value: 'forgot@example.com' } });
fireEvent.submit(screen.getByTestId('reset-password-form'));
await waitFor(() => {
expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith('forgot@example.com');
expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.');
});
});
it('should display an error on failed password reset request', async () => {
(mockedApiClient.requestPasswordReset as Mock).mockRejectedValueOnce(new Error('User not found'));
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
fireEvent.submit(screen.getByTestId('reset-password-form'));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('User not found');
});
});
it('should switch back to sign in from forgot password', () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
fireEvent.click(screen.getByRole('button', { name: /back to sign in/i }));
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
});
});
describe('OAuth', () => {
const originalLocation = window.location;
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...originalLocation, href: '' },
});
});
afterEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: originalLocation,
});
});
it('should set window.location.href for Google OAuth', () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
expect(window.location.href).toBe('/api/auth/google');
});
it('should set window.location.href for GitHub OAuth', () => {
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /sign in with github/i }));
expect(window.location.href).toBe('/api/auth/github');
});
});
});

View File

@@ -0,0 +1,94 @@
// src/routes/deals.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import dealsRouter from './deals.routes';
import { dealsRepo } from '../services/db/deals.db';
import { errorHandler } from '../middleware/errorHandler';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import type { UserProfile, WatchedItemDeal } from '../types';
// 1. Mock the Service Layer directly.
vi.mock('../services/db/deals.db', () => ({
dealsRepo: {
findBestPricesForWatchedItems: vi.fn(),
},
}));
// Mock the logger to keep test output clean
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn((strategy, options) => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
}),
},
}));
// Helper function to create a test app instance.
const createApp = (user?: UserProfile) => {
const app = express();
app.use(express.json());
// Conditionally add a middleware to inject the user object for authenticated tests.
if (user) {
app.use((req, res, next) => {
req.user = user;
// Add a mock `log` object to the request, as the route handler uses it.
(req as any).log = { info: vi.fn(), error: vi.fn() };
next();
});
}
// The route is mounted on `/api/users/deals` in the main server file, so we replicate that here.
app.use('/api/users/deals', dealsRouter);
app.use(errorHandler);
return app;
};
describe('Deals Routes (/api/users/deals)', () => {
const mockUser = createMockUserProfile({ user_id: 'user-123' });
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /best-watched-prices', () => {
it('should return 401 Unauthorized if user is not authenticated', async () => {
const app = createApp(); // No user provided
const response = await supertest(app).get('/api/users/deals/best-watched-prices');
expect(response.status).toBe(401);
});
it('should return a list of deals for an authenticated user', async () => {
const app = createApp(mockUser);
const mockDeals: WatchedItemDeal[] = [{
master_item_id: 123,
item_name: 'Apples',
best_price_in_cents: 199,
store_name: 'Mock Store',
flyer_id: 101,
valid_to: new Date().toISOString(),
}];
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals);
const response = await supertest(app).get('/api/users/deals/best-watched-prices');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockDeals);
expect(dealsRepo.findBestPricesForWatchedItems).toHaveBeenCalledWith(mockUser.user_id);
});
});
});

View File

@@ -22,11 +22,11 @@ router.get('/best-watched-prices', async (req: Request, res: Response, next: Nex
try {
// The controller logic is simple enough to be handled directly in the route,
// consistent with other simple GET routes in the project.
const deals = await dealsRepo.findBestPricesForWatchedItems(user.user_id);
req.log.info({ dealCount: deals.length, userId: user.user_id }, 'Successfully fetched best watched item deals.');
const deals = await dealsRepo.findBestPricesForWatchedItems(user.user_id, req.log);
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
res.status(200).json(deals);
} catch (error) {
req.log.error({ err: error, userId: user.user_id }, 'Error fetching best watched item deals.');
req.log.error({ err: error }, 'Error fetching best watched item deals.');
next(error); // Pass errors to the global error handler
}
});

View File

@@ -1,22 +1,28 @@
// src/services/db/deals.repository.ts
// src/services/db/deals.db.ts
import { getPool } from './connection.db';
import { WatchedItemDeal } from '../../types';
import { Pool } from 'pg';
import type { Pool, PoolClient } from 'pg';
import type { Logger } from 'pino';
import { logger as globalLogger } from '../logger.server';
export class DealsRepository {
private pool: Pool;
private db: Pool | PoolClient;
constructor() {
this.pool = getPool();
constructor(db: Pool | PoolClient = getPool()) {
this.db = db;
}
/**
* Finds the best current sale price for each of a user's watched items.
* It considers only currently active flyers and handles ties by preferring the deal
* that is valid for the longest time.
*
* @param userId - The ID of the user whose watched items are being checked.
* @param logger - The logger instance for context-specific logging.
* @returns A promise that resolves to an array of WatchedItemDeal objects.
*/
async findBestPricesForWatchedItems(userId: string): Promise<WatchedItemDeal[]> {
async findBestPricesForWatchedItems(userId: string, logger: Logger = globalLogger): Promise<WatchedItemDeal[]> {
logger.debug({ userId }, 'Finding best prices for watched items.');
const query = `
WITH UserWatchedItems AS (
-- Select all items the user is watching
@@ -31,6 +37,7 @@ export class DealsRepository {
s.name AS store_name,
f.flyer_id,
f.valid_to,
-- Rank prices for each item, lowest first. In case of a tie, the deal that ends later is preferred.
ROW_NUMBER() OVER(PARTITION BY fi.master_item_id ORDER BY fi.price_in_cents ASC, f.valid_to DESC) as rn
FROM flyer_items fi
JOIN flyers f ON fi.flyer_id = f.flyer_id
@@ -53,8 +60,13 @@ export class DealsRepository {
WHERE rn = 1
ORDER BY item_name;
`;
const { rows } = await this.pool.query(query, [userId]);
return rows;
try {
const { rows } = await this.db.query<WatchedItemDeal>(query, [userId]);
return rows;
} catch (error) {
logger.error({ err: error }, 'Database error in findBestPricesForWatchedItems');
throw error; // Re-throw the original error to be handled by the global error handler
}
}
}

View File

@@ -72,7 +72,7 @@ export class GamificationRepository {
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user or achievement does not exist.');
}
logger.error({ err: error, userId, achievementName }, 'Database error in awardAchievement');
logger.error({ err: error, achievementName }, 'Database error in awardAchievement');
throw new Error('Failed to award achievement.');
}
}

View File

@@ -19,4 +19,17 @@ export const logger = pino({
ignore: 'pid,hostname', // These are useful in production, but noisy in dev.
},
},
// As per ADR-004, we centralize sanitization here.
// This automatically redacts sensitive fields from all log objects.
// The paths target keys within objects passed to the logger.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.body.password',
'*.body.newPassword',
'*.body.currentPassword',
],
censor: '[REDACTED]',
},
});