fix unit tests after frontend tests ran
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m21s

This commit is contained in:
2026-01-18 02:56:25 -08:00
parent 94f45d9726
commit 63a0dde0f8
8 changed files with 458 additions and 725 deletions

View File

@@ -3,15 +3,15 @@ import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Leaderboard from './Leaderboard';
import * as apiClient from '../services/apiClient';
import { LeaderboardUser } from '../types';
import { createMockLeaderboardUser } from '../tests/utils/mockFactories';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import { useLeaderboardQuery } from '../hooks/queries/useLeaderboardQuery';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
// Mock the hook directly
vi.mock('../hooks/queries/useLeaderboardQuery');
const mockedApiClient = vi.mocked(apiClient);
const mockedUseLeaderboardQuery = vi.mocked(useLeaderboardQuery);
// Mock lucide-react icons to prevent rendering errors in the test environment
vi.mock('lucide-react', () => ({
@@ -36,29 +36,38 @@ const mockLeaderboardData: LeaderboardUser[] = [
describe('Leaderboard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: loading state
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
} as any);
});
it('should display a loading message initially', () => {
// Mock a pending promise that never resolves to keep it in the loading state
mockedApiClient.fetchLeaderboard.mockReturnValue(new Promise(() => {}));
renderWithProviders(<Leaderboard />);
expect(screen.getByText('Loading Leaderboard...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(null, { status: 500 }));
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Request failed with status 500'),
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
// The query hook throws an error with the status code when JSON parsing fails
expect(screen.getByText('Error: Request failed with status 500')).toBeInTheDocument();
});
});
it('should display a generic error for unknown error types', async () => {
// Use an actual Error object since the component displays error.message
mockedApiClient.fetchLeaderboard.mockRejectedValue(new Error('A string error'));
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('A string error'),
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -68,7 +77,11 @@ describe('Leaderboard', () => {
});
it('should display a message when the leaderboard is empty', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseLeaderboardQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -79,9 +92,11 @@ describe('Leaderboard', () => {
});
it('should render the leaderboard with user data on successful fetch', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
mockedUseLeaderboardQuery.mockReturnValue({
data: mockLeaderboardData,
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -104,9 +119,11 @@ describe('Leaderboard', () => {
});
it('should render the correct rank icons', async () => {
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(mockLeaderboardData)),
);
mockedUseLeaderboardQuery.mockReturnValue({
data: mockLeaderboardData,
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {
@@ -123,9 +140,11 @@ describe('Leaderboard', () => {
const dataWithMissingNames: LeaderboardUser[] = [
createMockLeaderboardUser({ user_id: 'user-anon', full_name: null, points: 500, rank: '5' }),
];
mockedApiClient.fetchLeaderboard.mockResolvedValue(
new Response(JSON.stringify(dataWithMissingNames)),
);
mockedUseLeaderboardQuery.mockReturnValue({
data: dataWithMissingNames,
isLoading: false,
error: null,
} as any);
renderWithProviders(<Leaderboard />);
await waitFor(() => {

View File

@@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { PriceHistoryChart } from './PriceHistoryChart';
import { useUserData } from '../../hooks/useUserData';
import * as apiClient from '../../services/apiClient';
import { usePriceHistoryQuery } from '../../hooks/queries/usePriceHistoryQuery';
import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types';
import {
createMockMasterGroceryItem,
@@ -12,13 +12,14 @@ import {
} from '../../tests/utils/mockFactories';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the apiClient
vi.mock('../../services/apiClient');
// Mock the useUserData hook
vi.mock('../../hooks/useUserData');
const mockedUseUserData = useUserData as Mock;
// Mock the usePriceHistoryQuery hook
vi.mock('../../hooks/queries/usePriceHistoryQuery');
const mockedUsePriceHistoryQuery = usePriceHistoryQuery as Mock;
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
// Mock the logger
@@ -108,6 +109,13 @@ describe('PriceHistoryChart', () => {
isLoading: false,
error: null,
});
// Default mock for usePriceHistoryQuery (empty/loading false)
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
});
it('should render a placeholder when there are no watched items', () => {
@@ -126,13 +134,21 @@ describe('PriceHistoryChart', () => {
});
it('should display a loading state while fetching data', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {}));
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('API is down'));
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('API is down'),
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -142,9 +158,11 @@ describe('PriceHistoryChart', () => {
});
it('should display a message if no historical data is returned', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify([])),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -157,14 +175,16 @@ describe('PriceHistoryChart', () => {
});
it('should render the chart with data on successful fetch', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: mockPriceHistory,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
// Check that the API was called with the correct item IDs
expect(apiClient.fetchHistoricalPriceData).toHaveBeenCalledWith([1, 2]);
// Check that the hook was called with the correct item IDs
expect(mockedUsePriceHistoryQuery).toHaveBeenCalledWith([1, 2], true);
// Check that the chart components are rendered
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
@@ -188,15 +208,17 @@ describe('PriceHistoryChart', () => {
isLoading: true, // Test the isLoading state from the useUserData hook
error: null,
});
vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {}));
// Even if price history is loading or not, user data loading takes precedence in UI
renderWithQuery(<PriceHistoryChart />);
expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
});
it('should clear the chart when the watchlist becomes empty', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: mockPriceHistory,
isLoading: false,
error: null,
});
const { rerender } = renderWithQuery(<PriceHistoryChart />);
// Initial render with items
@@ -225,7 +247,7 @@ describe('PriceHistoryChart', () => {
});
it('should filter out items with only one data point', async () => {
const dataWithSinglePoint: HistoricalPriceDataPoint[] = [
const dataWithSinglePoint = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -242,9 +264,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 350,
}), // Almond Milk only has one point
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithSinglePoint)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithSinglePoint,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -254,7 +278,7 @@ describe('PriceHistoryChart', () => {
});
it('should process data to only keep the lowest price for a given day', async () => {
const dataWithDuplicateDate: HistoricalPriceDataPoint[] = [
const dataWithDuplicateDate = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -271,9 +295,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 99,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithDuplicateDate)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithDuplicateDate,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -288,7 +314,7 @@ describe('PriceHistoryChart', () => {
});
it('should filter out data points with a price of zero', async () => {
const dataWithZeroPrice: HistoricalPriceDataPoint[] = [
const dataWithZeroPrice = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -305,9 +331,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 105,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithZeroPrice)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithZeroPrice,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -330,9 +358,11 @@ describe('PriceHistoryChart', () => {
{ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: null }, // Missing price
{ master_item_id: 999, summary_date: '2024-10-01', avg_price_in_cents: 100 }, // ID not in watchlist
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(malformedData)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: malformedData,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -346,7 +376,7 @@ describe('PriceHistoryChart', () => {
});
it('should ignore higher prices for the same day', async () => {
const dataWithHigherPrice: HistoricalPriceDataPoint[] = [
const dataWithHigherPrice = [
createMockHistoricalPriceDataPoint({
master_item_id: 1,
summary_date: '2024-10-01',
@@ -363,9 +393,11 @@ describe('PriceHistoryChart', () => {
avg_price_in_cents: 100,
}),
];
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithHigherPrice)),
);
mockedUsePriceHistoryQuery.mockReturnValue({
data: dataWithHigherPrice,
isLoading: false,
error: null,
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {
@@ -377,8 +409,11 @@ describe('PriceHistoryChart', () => {
});
it('should handle non-Error objects thrown during fetch', async () => {
// Use an actual Error object since the component displays error.message
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('Fetch failed'));
mockedUsePriceHistoryQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Fetch failed'),
});
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => {

View File

@@ -1,8 +1,6 @@
// src/hooks/useActiveDeals.test.tsx
import { renderHook, waitFor, act } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useActiveDeals } from './useActiveDeals';
import * as apiClient from '../services/apiClient';
import type { Flyer, MasterGroceryItem, FlyerItem } from '../types';
import {
createMockFlyer,
@@ -12,9 +10,8 @@ import {
} from '../tests/utils/mockFactories';
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
import { useFlyerItemsForFlyersQuery } from './queries/useFlyerItemsForFlyersQuery';
import { useFlyerItemCountQuery } from './queries/useFlyerItemCountQuery';
// Mock the hooks to avoid Missing Context errors
vi.mock('./useFlyers', () => ({
@@ -25,7 +22,12 @@ vi.mock('../hooks/useUserData', () => ({
useUserData: () => mockUseUserData(),
}));
const mockedApiClient = vi.mocked(apiClient);
// Mock the query hooks
vi.mock('./queries/useFlyerItemsForFlyersQuery');
vi.mock('./queries/useFlyerItemCountQuery');
const mockedUseFlyerItemsForFlyersQuery = vi.mocked(useFlyerItemsForFlyersQuery);
const mockedUseFlyerItemCountQuery = vi.mocked(useFlyerItemCountQuery);
// Set a consistent "today" for testing flyer validity to make tests deterministic
const TODAY = new Date('2024-01-15T12:00:00.000Z');
@@ -33,9 +35,6 @@ const TODAY = new Date('2024-01-15T12:00:00.000Z');
describe('useActiveDeals Hook', () => {
// Use fake timers to control the current date in tests
beforeEach(() => {
// FIX: Only fake the 'Date' object.
// This allows `new Date()` to be mocked (via setSystemTime) while keeping
// `setTimeout`/`setInterval` native so `waitFor` doesn't hang.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(TODAY);
vi.clearAllMocks();
@@ -58,6 +57,18 @@ describe('useActiveDeals Hook', () => {
isLoading: false,
error: null,
});
// Default mocks for query hooks
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
mockedUseFlyerItemCountQuery.mockReturnValue({
data: { count: 0 },
isLoading: false,
error: null,
} as any);
});
afterEach(() => {
@@ -124,20 +135,18 @@ describe('useActiveDeals Hook', () => {
];
it('should return loading state initially and then calculated data', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(mockFlyerItems)),
);
mockedUseFlyerItemCountQuery.mockReturnValue({
data: { count: 10 },
isLoading: false,
error: null,
} as any);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mockFlyerItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// The hook runs the effect almost immediately. We shouldn't strictly assert false
// because depending on render timing, it might already be true.
// We mainly care that it eventually resolves.
// Wait for the hook's useEffect to run and complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
@@ -147,25 +156,18 @@ describe('useActiveDeals Hook', () => {
});
it('should correctly filter for valid flyers and make API calls with their IDs', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 0 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
// Only the valid flyer (id: 1) should be used in the API calls
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
// The second argument is `enabled` which should be true
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([1], true);
expect(mockedUseFlyerItemsForFlyersQuery).toHaveBeenCalledWith([1], true);
expect(result.current.isLoading).toBe(false);
});
});
it('should not fetch flyer items if there are no watched items', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
@@ -173,16 +175,16 @@ describe('useActiveDeals Hook', () => {
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
}); // Override for this test
});
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
expect(result.current.activeDeals).toEqual([]);
// The key assertion: fetchFlyerItemsForFlyers should not be called
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
// The enabled flag (2nd arg) should be false for items query
expect(mockedUseFlyerItemsForFlyersQuery).toHaveBeenCalledWith([1], false);
// Count query should still be enabled if there are valid flyers
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([1], true);
});
});
@@ -204,16 +206,20 @@ describe('useActiveDeals Hook', () => {
expect(result.current.totalActiveItems).toBe(0);
expect(result.current.activeDeals).toEqual([]);
// No API calls should be made if there are no valid flyers
expect(mockedApiClient.countFlyerItemsForFlyers).not.toHaveBeenCalled();
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
// API calls should be made with empty array, or enabled=false depending on implementation
// In useActiveDeals.tsx: validFlyerIds.length > 0 is the condition
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([], false);
expect(mockedUseFlyerItemsForFlyersQuery).toHaveBeenCalledWith([], false);
});
});
it('should set an error state if counting items fails', async () => {
const apiError = new Error('Network Failure');
mockedApiClient.countFlyerItemsForFlyers.mockRejectedValue(apiError);
// Also mock fetchFlyerItemsForFlyers to avoid interference from the other query
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseFlyerItemCountQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: apiError,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -225,17 +231,16 @@ describe('useActiveDeals Hook', () => {
it('should set an error state if fetching items fails', async () => {
const apiError = new Error('Item fetch failed');
// Mock the count to succeed but the item fetch to fail
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockRejectedValue(apiError);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: apiError,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
// This covers the `|| errorItems?.message` part of the error logic
expect(result.current.error).toBe(
'Could not fetch active deals or totals: Item fetch failed',
);
@@ -243,12 +248,16 @@ describe('useActiveDeals Hook', () => {
});
it('should correctly map flyer items to DealItem format', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 10 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(mockFlyerItems)),
);
mockedUseFlyerItemCountQuery.mockReturnValue({
data: { count: 10 },
isLoading: false,
error: null,
} as any);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mockFlyerItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -261,7 +270,7 @@ describe('useActiveDeals Hook', () => {
quantity: 'lb',
storeName: 'Valid Store',
master_item_name: 'Apples',
unit_price: null, // Expect null as the hook ensures undefined is converted to null
unit_price: null,
});
expect(deal).toEqual(expectedDeal);
});
@@ -276,7 +285,7 @@ describe('useActiveDeals Hook', () => {
valid_from: '2024-01-10',
valid_to: '2024-01-20',
});
(flyerWithoutStore as any).store = null; // Explicitly set to null
(flyerWithoutStore as any).store = null;
const itemInFlyerWithoutStore = createMockFlyerItem({
flyer_item_id: 3,
@@ -289,27 +298,21 @@ describe('useActiveDeals Hook', () => {
});
mockUseFlyers.mockReturnValue({ ...mockUseFlyers(), flyers: [flyerWithoutStore] });
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 1 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify([itemInFlyerWithoutStore])),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [itemInFlyerWithoutStore],
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(1);
// This covers the `|| 'Unknown Store'` fallback logic
expect(result.current.activeDeals[0].storeName).toBe('Unknown Store');
});
});
it('should filter out items that do not match watched items or have no master ID', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 5 })),
);
const mixedItems: FlyerItem[] = [
// Watched item (Master ID 101 is in mockWatchedItems)
createMockFlyerItem({
@@ -345,9 +348,11 @@ describe('useActiveDeals Hook', () => {
}),
];
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(mixedItems)),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mixedItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -360,40 +365,18 @@ describe('useActiveDeals Hook', () => {
});
it('should return true for isLoading while API calls are pending', async () => {
// Create promises we can control
let resolveCount: (value: Response) => void;
const countPromise = new Promise<Response>((resolve) => {
resolveCount = resolve;
});
let resolveItems: (value: Response) => void;
const itemsPromise = new Promise<Response>((resolve) => {
resolveItems = resolve;
});
mockedApiClient.countFlyerItemsForFlyers.mockReturnValue(countPromise);
mockedApiClient.fetchFlyerItemsForFlyers.mockReturnValue(itemsPromise);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// Wait for the effect to trigger the API call and set loading to true
await waitFor(() => expect(result.current.isLoading).toBe(true));
// Resolve promises
await act(async () => {
resolveCount!(new Response(JSON.stringify({ count: 5 })));
resolveItems!(new Response(JSON.stringify([])));
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isLoading).toBe(true);
});
it('should re-filter active deals when watched items change (client-side filtering)', async () => {
// With TanStack Query, changing watchedItems does NOT trigger a new API call
// because the query key is based on flyerIds, not watchedItems.
// The filtering happens client-side via useMemo. This is more efficient.
const allFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
@@ -415,12 +398,11 @@ describe('useActiveDeals Hook', () => {
}),
];
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 2 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(allFlyerItems)),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: allFlyerItems,
isLoading: false,
error: null,
} as any);
const { result, rerender } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
@@ -433,9 +415,6 @@ describe('useActiveDeals Hook', () => {
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Red Apples');
// API should have been called exactly once
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
// Now add Bread to watched items
const newWatchedItems = [
...mockWatchedItems,
@@ -462,9 +441,6 @@ describe('useActiveDeals Hook', () => {
const dealItems = result.current.activeDeals.map((d) => d.item);
expect(dealItems).toContain('Red Apples');
expect(dealItems).toContain('Fresh Bread');
// The API should NOT be called again - data is already cached
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
});
it('should include flyers valid exactly on the start or end date', async () => {
@@ -518,16 +494,10 @@ describe('useActiveDeals Hook', () => {
refetchFlyers: vi.fn(),
});
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 0 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
// Should call with IDs 10, 11, 12. Should NOT include 13.
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([10, 11, 12]);
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([10, 11, 12], true);
});
});
@@ -544,12 +514,11 @@ describe('useActiveDeals Hook', () => {
quantity: undefined,
});
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 1 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify([incompleteItem])),
);
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [incompleteItem],
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });

View File

@@ -153,7 +153,7 @@ describe('useAuth Hook and AuthProvider', () => {
expect(result.current.userProfile).toBeNull();
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
'[AuthProvider] Token was present but profile is null. Signing out.',
'[AuthProvider] Token was present but validation failed. Signing out.',
);
});

View File

@@ -1,17 +1,15 @@
// src/pages/MyDealsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import MyDealsPage from './MyDealsPage';
import * as apiClient from '../services/apiClient';
import { useBestSalePricesQuery } from '../hooks/queries/useBestSalePricesQuery';
import type { WatchedItemDeal } from '../types';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
const mockedApiClient = vi.mocked(apiClient);
vi.mock('../hooks/queries/useBestSalePricesQuery');
const mockedUseBestSalePricesQuery = useBestSalePricesQuery as Mock;
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
@@ -26,66 +24,65 @@ vi.mock('lucide-react', () => ({
describe('MyDealsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: loading false, empty data
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
});
});
it('should display a loading message initially', () => {
// Mock a pending promise
mockedApiClient.fetchBestSalePrices.mockReturnValue(new Promise(() => {}));
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: true,
error: null,
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Loading your deals...')).toBeInTheDocument();
});
it('should display an error message if the API call fails', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(null, { status: 500, statusText: 'Server Error' }),
);
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
// The query hook throws an error with status code when JSON parsing fails on non-ok response
expect(screen.getByText('Request failed with status 500')).toBeInTheDocument();
it('should display an error message if the API call fails', () => {
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Request failed with status 500'),
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Request failed with status 500')).toBeInTheDocument();
});
it('should handle network errors and log them', async () => {
const networkError = new Error('Network connection failed');
mockedApiClient.fetchBestSalePrices.mockRejectedValue(networkError);
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Network connection failed')).toBeInTheDocument();
it('should handle network errors and log them', () => {
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Network connection failed'),
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Network connection failed')).toBeInTheDocument();
});
it('should handle unknown errors and log them', async () => {
// Mock a rejection with an Error object - TanStack Query passes through Error objects
mockedApiClient.fetchBestSalePrices.mockRejectedValue(new Error('Unknown failure'));
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Unknown failure')).toBeInTheDocument();
it('should handle unknown errors and log them', () => {
mockedUseBestSalePricesQuery.mockReturnValue({
data: [],
isLoading: false,
error: new Error('Unknown failure'),
});
renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Unknown failure')).toBeInTheDocument();
});
it('should display a message when no deals are found', async () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(JSON.stringify([]), {
headers: { 'Content-Type': 'application/json' },
}),
);
it('should display a message when no deals are found', () => {
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(
screen.getByText('No deals found for your watched items right now.'),
).toBeInTheDocument();
});
expect(
screen.getByText('No deals found for your watched items right now.'),
).toBeInTheDocument();
});
it('should render the list of deals on successful fetch', async () => {
it('should render the list of deals on successful fetch', () => {
const mockDeals: WatchedItemDeal[] = [
createMockWatchedItemDeal({
master_item_id: 1,
@@ -104,20 +101,18 @@ describe('MyDealsPage', () => {
valid_to: '2024-10-22',
}),
];
mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(JSON.stringify(mockDeals), {
headers: { 'Content-Type': 'application/json' },
}),
);
mockedUseBestSalePricesQuery.mockReturnValue({
data: mockDeals,
isLoading: false,
error: null,
});
renderWithQuery(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Organic Bananas')).toBeInTheDocument();
expect(screen.getByText('$0.99')).toBeInTheDocument();
expect(screen.getByText('Almond Milk')).toBeInTheDocument();
expect(screen.getByText('$3.49')).toBeInTheDocument();
expect(screen.getByText('Green Grocer')).toBeInTheDocument();
});
expect(screen.getByText('Organic Bananas')).toBeInTheDocument();
expect(screen.getByText('$0.99')).toBeInTheDocument();
expect(screen.getByText('Almond Milk')).toBeInTheDocument();
expect(screen.getByText('$3.49')).toBeInTheDocument();
expect(screen.getByText('Green Grocer')).toBeInTheDocument();
});
});

View File

@@ -11,20 +11,33 @@ import {
createMockUser,
} from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
import { useUserProfileData } from '../hooks/useUserProfileData';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
vi.mock('../hooks/useUserProfileData');
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
vi.mock('../services/notificationService', () => ({
notifySuccess: vi.fn(),
notifyError: vi.fn(),
}));
import { notifyError } from '../services/notificationService';
vi.mock('../components/AchievementsList', () => ({
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
<div data-testid="achievements-list-mock">Achievements Count: {achievements.length}</div>
AchievementsList: ({
achievements,
}: {
achievements: (UserAchievement & Achievement)[] | null;
}) => (
<div data-testid="achievements-list-mock">Achievements Count: {achievements?.length || 0}</div>
),
}));
const mockedApiClient = vi.mocked(apiClient);
const mockedUseUserProfileData = vi.mocked(useUserProfileData);
const mockedNotifyError = vi.mocked(notifyError);
// --- Mock Data ---
const mockProfile: UserProfile = createMockUserProfile({
@@ -47,206 +60,109 @@ const mockAchievements: (UserAchievement & Achievement)[] = [
}),
];
const mockSetProfile = vi.fn();
describe('UserProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock implementation: Success state
mockedUseUserProfileData.mockReturnValue({
profile: mockProfile,
setProfile: mockSetProfile,
achievements: mockAchievements,
isLoading: false,
error: null,
});
});
// ... (Keep existing tests for loading message, error handling, rendering, etc.) ...
it('should display a loading message initially', () => {
mockedApiClient.getAuthenticatedUserProfile.mockReturnValue(new Promise(() => {}));
mockedApiClient.getUserAchievements.mockReturnValue(new Promise(() => {}));
mockedUseUserProfileData.mockReturnValue({
profile: null,
setProfile: mockSetProfile,
achievements: [],
isLoading: true,
error: null,
});
renderWithQuery(<UserProfilePage />);
expect(screen.getByText('Loading profile...')).toBeInTheDocument();
});
it('should display an error message if fetching profile fails', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Network Error'));
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
it('should display an error message if fetching profile fails', () => {
mockedUseUserProfileData.mockReturnValue({
profile: null,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: 'Network Error',
});
renderWithQuery(<UserProfilePage />);
expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
});
it('should display an error message if fetching profile returns a non-ok response', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify({ message: 'Auth Failed' }), { status: 401 }),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
it('should render the profile and achievements on successful fetch', () => {
renderWithQuery(<UserProfilePage />);
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('150 Points')).toBeInTheDocument();
expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', mockProfile.avatar_url);
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent('Achievements Count: 1');
});
await waitFor(() => {
// The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Auth Failed')).toBeInTheDocument();
it('should render a fallback message if profile is null after loading', () => {
mockedUseUserProfileData.mockReturnValue({
profile: null,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: null,
});
});
it('should display an error message if fetching achievements returns a non-ok response', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify({ message: 'Server Busy' }), { status: 503 }),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
// The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Server Busy')).toBeInTheDocument();
});
expect(screen.getByText('Could not load user profile.')).toBeInTheDocument();
});
it('should display an error message if fetching achievements fails', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockRejectedValue(new Error('Achievements service down'));
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument();
});
});
it('should handle unknown errors during fetch', async () => {
// Use an actual Error object since the hook extracts error.message
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Unknown error'));
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Unknown error')).toBeInTheDocument();
});
});
it('should handle null achievements data gracefully on fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
// Mock a successful response but with a null body for achievements
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
// The mock achievements list should show 0 achievements because the component
// should handle the null response and pass an empty array to the list.
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 0',
);
});
});
it('should render the profile and achievements on successful fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('150 Points')).toBeInTheDocument();
expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', mockProfile.avatar_url);
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 1',
);
});
});
it('should render a fallback message if profile is null after loading', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(null)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
expect(await screen.findByText('Could not load user profile.')).toBeInTheDocument();
});
it('should display a fallback avatar if the user has no avatar_url', async () => {
// Create a mock profile with a null avatar_url and a specific name for the seed
it('should display a fallback avatar if the user has no avatar_url', () => {
const profileWithoutAvatar = { ...mockProfile, avatar_url: null, full_name: 'No Avatar User' };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(profileWithoutAvatar)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify([])));
mockedUseUserProfileData.mockReturnValue({
profile: profileWithoutAvatar,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: null,
});
renderWithQuery(<UserProfilePage />);
// Wait for the component to render with the fetched data
await waitFor(() => {
const avatarImage = screen.getByAltText('User Avatar');
// JSDOM might not URL-encode spaces in the src attribute in the same way a browser does.
// We adjust the expectation to match the literal string returned by getAttribute.
const expectedSrc = 'https://api.dicebear.com/8.x/initials/svg?seed=No Avatar User';
console.log('[TEST LOG] Actual Avatar Src:', avatarImage.getAttribute('src'));
expect(avatarImage).toHaveAttribute('src', expectedSrc);
});
const avatarImage = screen.getByAltText('User Avatar');
const expectedSrc = 'https://api.dicebear.com/8.x/initials/svg?seed=No Avatar User';
expect(avatarImage).toHaveAttribute('src', expectedSrc);
});
it('should use email for avatar seed if full_name is missing', async () => {
it('should use email for avatar seed if full_name is missing', () => {
const profileNoName = { ...mockProfile, full_name: null, avatar_url: null };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(profileNoName)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
mockedUseUserProfileData.mockReturnValue({
profile: profileNoName,
setProfile: mockSetProfile,
achievements: [],
isLoading: false,
error: null,
});
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
const avatar = screen.getByAltText('User Avatar');
// seed should be the email
expect(avatar.getAttribute('src')).toContain(`seed=${profileNoName.user.email}`);
});
const avatar = screen.getByAltText('User Avatar');
expect(avatar.getAttribute('src')).toContain(`seed=${profileNoName.user.email}`);
});
it('should trigger file input click when avatar is clicked', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
it('should trigger file input click when avatar is clicked', () => {
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const clickSpy = vi.spyOn(fileInput, 'click');
const avatarContainer = screen.getByAltText('User Avatar');
fireEvent.click(avatarContainer);
expect(clickSpy).toHaveBeenCalled();
});
describe('Name Editing', () => {
beforeEach(() => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
});
it('should allow editing and saving the user name', async () => {
const updatedProfile = { ...mockProfile, full_name: 'Updated Name' };
mockedApiClient.updateUserProfile.mockResolvedValue(
@@ -254,8 +170,6 @@ describe('UserProfilePage', () => {
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Updated Name' } });
@@ -265,17 +179,14 @@ describe('UserProfilePage', () => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({
full_name: 'Updated Name',
});
expect(screen.getByRole('heading', { name: 'Updated Name' })).toBeInTheDocument();
expect(mockSetProfile).toHaveBeenCalled();
});
});
it('should allow canceling the name edit', async () => {
it('should allow canceling the name edit', () => {
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
});
@@ -285,7 +196,6 @@ describe('UserProfilePage', () => {
new Response(JSON.stringify({ message: 'Validation failed' }), { status: 400 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
@@ -293,136 +203,33 @@ describe('UserProfilePage', () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('Validation failed');
});
});
it('should show a default error if saving the name fails with a non-ok response and no message', async () => {
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({}), { status: 400 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Invalid Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// This covers the `|| 'Failed to update name.'` part of the error throw
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle non-ok response with null body when saving name', async () => {
// This tests the case where the server returns an error status but an empty/null body.
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// The component should fall back to the default error message.
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle unknown errors when saving name', async () => {
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'An unknown error occurred.',
);
expect(mockedNotifyError).toHaveBeenCalledWith('Validation failed');
});
});
});
describe('Avatar Upload', () => {
beforeEach(() => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
});
it('should upload a new avatar and update the image source', async () => {
it('should upload a new avatar and update the profile', async () => {
const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
// Log when the mock is called
mockedApiClient.uploadAvatar.mockImplementation((file) => {
console.log('[TEST LOG] uploadAvatar mock called with:', file.name);
// Add a slight delay to ensure "isUploading" state can be observed
return new Promise((resolve) => {
setTimeout(() => {
console.log('[TEST LOG] uploadAvatar mock resolving...');
resolve(new Response(JSON.stringify(updatedProfile)));
}, 100);
});
});
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(JSON.stringify(updatedProfile)));
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
// Mock the hidden file input
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
console.log('[TEST LOG] Firing file change event...');
fireEvent.change(fileInput, { target: { files: [file] } });
// DEBUG: Print current DOM state if spinner is not found immediately
// const spinner = screen.queryByTestId('avatar-upload-spinner');
// if (!spinner) {
// console.log('[TEST LOG] Spinner NOT found immediately after event.');
// // screen.debug(); // Uncomment to see DOM
// } else {
// console.log('[TEST LOG] Spinner FOUND immediately.');
// }
// Wait for the spinner to appear
console.log('[TEST LOG] Waiting for spinner...');
await screen.findByTestId('avatar-upload-spinner');
console.log('[TEST LOG] Spinner found.');
// Wait for the upload to complete and the UI to update.
await waitFor(() => {
expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(file);
expect(screen.getByAltText('User Avatar')).toHaveAttribute(
'src',
updatedProfile.avatar_url,
);
expect(screen.queryByTestId('avatar-upload-spinner')).not.toBeInTheDocument();
expect(mockSetProfile).toHaveBeenCalled();
});
});
it('should not attempt to upload if no file is selected', async () => {
it('should not attempt to upload if no file is selected', () => {
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
// Simulate user canceling the file dialog
fireEvent.change(fileInput, { target: { files: null } });
// Assert that no API call was made
expect(mockedApiClient.uploadAvatar).not.toHaveBeenCalled();
});
@@ -431,96 +238,13 @@ describe('UserProfilePage', () => {
new Response(JSON.stringify({ message: 'File too large' }), { status: 413 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'large.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('File too large');
});
});
it('should show a default error if avatar upload returns a non-ok response and no message', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(JSON.stringify({}), { status: 413 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'large.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
// This covers the `|| 'Failed to upload avatar.'` part of the error throw
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle non-ok response with null body when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle unknown errors when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'error.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'An unknown error occurred.',
);
});
});
it('should show an error if a non-image file is selected for upload', async () => {
// Mock the API client to return a non-OK response, simulating server-side validation failure
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(
JSON.stringify({
message: 'Invalid file type. Only images (png, jpeg, gif) are allowed.',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
// Create a mock file that is NOT an image (e.g., a PDF)
const nonImageFile = new File(['some text content'], 'document.pdf', {
type: 'application/pdf',
});
fireEvent.change(fileInput, { target: { files: [nonImageFile] } });
await waitFor(() => {
expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(nonImageFile);
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Invalid file type. Only images (png, jpeg, gif) are allowed.',
);
expect(screen.queryByTestId('avatar-upload-spinner')).not.toBeInTheDocument();
expect(mockedNotifyError).toHaveBeenCalledWith('File too large');
});
});
});

View File

@@ -5,14 +5,18 @@ 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 { useBrandsQuery } from '../../../hooks/queries/useBrandsQuery';
import { createMockBrand } from '../../../tests/utils/mockFactories';
import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
// Must explicitly call vi.mock() for apiClient and the hook
vi.mock('../../../services/apiClient');
vi.mock('../../../hooks/queries/useBrandsQuery');
const mockedApiClient = vi.mocked(apiClient);
const mockedUseBrandsQuery = vi.mocked(useBrandsQuery);
const mockedToast = vi.mocked(toast, true);
const mockBrands = [
createMockBrand({ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null }),
createMockBrand({
@@ -26,70 +30,66 @@ const mockBrands = [
describe('AdminBrandManager', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock: loading false, empty data
mockedUseBrandsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: vi.fn(),
} as any);
});
it('should render a loading state initially', () => {
console.log('TEST START: should render a loading state initially');
// Mock a promise that never resolves to keep the component in a loading state.
console.log('TEST SETUP: Mocking fetchAllBrands with a non-resolving promise.');
mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {}));
mockedUseBrandsQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Checking for the loading text.');
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
console.log('TEST SUCCESS: Loading text is visible.');
console.log('TEST END: should render a loading state initially');
});
it('should render an error message if fetching brands fails', async () => {
console.log('TEST START: should render an error message if fetching brands fails');
const errorMessage = 'Network Error';
console.log(`TEST SETUP: Mocking fetchAllBrands to reject with: ${errorMessage}`);
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
mockedUseBrandsQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Network Error'),
} as any);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for error message to be displayed.');
await waitFor(() => {
expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument();
console.log('TEST SUCCESS: Error message found in the document.');
});
console.log('TEST END: should render an error message if fetching brands fails');
});
it('should render the list of brands when data is fetched successfully', async () => {
console.log('TEST START: should render the list of brands when data is fetched successfully');
// Use mockImplementation to return a new Response object on each call,
// preventing "Body has already been read" errors.
console.log('TEST SETUP: Mocking fetchAllBrands to resolve with mockBrands.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for brand list to render.');
await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
expect(screen.getByText('No Frills')).toBeInTheDocument();
expect(screen.getByText('(Sobeys)')).toBeInTheDocument();
expect(screen.getByAltText('Compliments logo')).toBeInTheDocument();
expect(screen.getByText('No Logo')).toBeInTheDocument();
console.log('TEST SUCCESS: All brand elements found in the document.');
});
console.log('TEST END: should render the list of brands when data is fetched successfully');
});
it('should handle successful logo upload', async () => {
console.log('TEST START: should handle successful logo upload');
console.log('TEST SETUP: Mocking fetchAllBrands and uploadBrandLogo for success.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () =>
new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), {
@@ -98,41 +98,34 @@ describe('AdminBrandManager', () => {
);
mockedToast.loading.mockReturnValue('toast-1');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
// Use the new accessible label to find the correct input.
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event on input for "No Frills".');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for upload to complete and UI to update.');
await waitFor(() => {
expect(mockedApiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file);
expect(mockedToast.loading).toHaveBeenCalledWith('Uploading logo...');
expect(mockedToast.success).toHaveBeenCalledWith('Logo updated successfully!', {
id: 'toast-1',
});
// Check if the UI updates with the new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
'src',
'https://example.com/new-logo.png',
);
console.log('TEST SUCCESS: All assertions for successful upload passed.');
});
console.log('TEST END: should handle successful logo upload');
});
it('should handle failed logo upload with a non-Error object', async () => {
console.log('TEST START: should handle failed logo upload with a non-Error object');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
// Reject with a string instead of an Error object to test the fallback error handling
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockRejectedValue('A string error');
mockedToast.loading.mockReturnValue('toast-non-error');
@@ -145,104 +138,88 @@ describe('AdminBrandManager', () => {
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
// This assertion verifies that the `String(e)` part of the catch block is executed.
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: A string error', {
id: 'toast-non-error',
});
});
console.log('TEST END: should handle failed logo upload with a non-Error object');
});
it('should handle failed logo upload', async () => {
console.log('TEST START: should handle failed logo upload');
console.log('TEST SETUP: Mocking fetchAllBrands for success and uploadBrandLogo for failure.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed'));
mockedToast.loading.mockReturnValue('toast-2');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event on input for "No Frills".');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for error toast to be called.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed', {
id: 'toast-2',
});
console.log('TEST SUCCESS: Error toast was called with the correct message.');
});
console.log('TEST END: should handle failed logo upload');
});
it('should show an error toast for invalid file type', async () => {
console.log('TEST START: should show an error toast for invalid file type');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['text'], 'document.txt', { type: 'text/plain' });
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event with invalid file type.');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for validation error toast.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith(
'Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.',
);
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
console.log('TEST SUCCESS: Validation toast shown and upload API not called.');
});
console.log('TEST END: should show an error toast for invalid file type');
});
it('should show an error toast for oversized file', async () => {
console.log('TEST START: should show an error toast for oversized file');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['a'.repeat(3 * 1024 * 1024)], 'large.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event with oversized file.');
fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for size validation error toast.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.');
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
console.log('TEST SUCCESS: Size validation toast shown and upload API not called.');
});
console.log('TEST END: should show an error toast for oversized file');
});
it('should show an error toast if upload fails with a non-ok response', async () => {
console.log('TEST START: should handle non-ok response from upload API');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
// Mock a failed response (e.g., 400 Bad Request)
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockResolvedValue(
new Response('Invalid image format', { status: 400 }),
);
@@ -260,51 +237,49 @@ describe('AdminBrandManager', () => {
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Invalid image format', {
id: 'toast-3',
});
console.log('TEST SUCCESS: Error toast shown for non-ok response.');
});
console.log('TEST END: should handle non-ok response from upload API');
});
it('should show an error toast if no file is selected', async () => {
console.log('TEST START: should show an error toast if no file is selected');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const input = screen.getByLabelText('Upload logo for No Frills');
// Simulate canceling the file picker by firing a change event with an empty file list.
console.log('TEST ACTION: Firing file change event with an empty file list.');
fireEvent.change(input, { target: { files: [] } });
console.log('TEST ASSERTION: Waiting for the "no file selected" error toast.');
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Please select a file to upload.');
console.log('TEST SUCCESS: Error toast shown when no file is selected.');
});
console.log('TEST END: should show an error toast if no file is selected');
});
it('should render an empty table if no brands are found', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify([]), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: [],
isLoading: false,
error: null,
} as any);
renderWithProviders(<AdminBrandManager />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
// Only the header row should be present
expect(screen.getAllByRole('row')).toHaveLength(1);
});
});
it('should use status code in error message if response body is empty on upload failure', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(null, { status: 500, statusText: 'Internal Server Error' }),
);
@@ -326,9 +301,12 @@ describe('AdminBrandManager', () => {
});
it('should only update the target brand logo and leave others unchanged', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 }),
);
mockedUseBrandsQuery.mockReturnValue({
data: mockBrands,
isLoading: false,
error: null,
} as any);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(JSON.stringify({ logoUrl: 'new-logo.png' }), { status: 200 }),
);
@@ -337,17 +315,12 @@ describe('AdminBrandManager', () => {
renderWithProviders(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
// Brand 1: No Frills (initially null logo)
// Brand 2: Compliments (initially has logo)
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills'); // Brand 1
const input = screen.getByLabelText('Upload logo for No Frills');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
// Brand 1 should have new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'new-logo.png');
// Brand 2 should still have original logo
expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
'src',
'https://example.com/compliments.png',

View File

@@ -20,12 +20,20 @@ describe('rateLimit utils', () => {
expect(shouldSkipRateLimit(req)).toBe(false);
});
it('should return false (do not skip) when NODE_ENV is "development"', async () => {
it('should return true (skip) when NODE_ENV is "development"', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(false);
expect(shouldSkipRateLimit(req)).toBe(true);
});
it('should return true (skip) when NODE_ENV is "staging"', async () => {
vi.stubEnv('NODE_ENV', 'staging');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = createMockRequest({ headers: {} });
expect(shouldSkipRateLimit(req)).toBe(true);
});
it('should return true (skip) when NODE_ENV is "test" and header is missing', async () => {
@@ -55,5 +63,15 @@ describe('rateLimit utils', () => {
});
expect(shouldSkipRateLimit(req)).toBe(true);
});
it('should return false (do not skip) when NODE_ENV is "development" and header is "true"', async () => {
vi.stubEnv('NODE_ENV', 'development');
const { shouldSkipRateLimit } = await import('./rateLimit');
const req = createMockRequest({
headers: { 'x-test-rate-limit-enable': 'true' },
});
expect(shouldSkipRateLimit(req)).toBe(false);
});
});
});