Files
flyer-crawler.projectium.com/src/hooks/useActiveDeals.test.tsx
Torben Sorensen 63a0dde0f8
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m21s
fix unit tests after frontend tests ran
2026-01-18 02:56:25 -08:00

533 lines
16 KiB
TypeScript

import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useActiveDeals } from './useActiveDeals';
import type { Flyer, MasterGroceryItem, FlyerItem } from '../types';
import {
createMockFlyer,
createMockFlyerItem,
createMockMasterGroceryItem,
createMockDealItem,
} from '../tests/utils/mockFactories';
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
import { useFlyerItemsForFlyersQuery } from './queries/useFlyerItemsForFlyersQuery';
import { useFlyerItemCountQuery } from './queries/useFlyerItemCountQuery';
// Mock the hooks to avoid Missing Context errors
vi.mock('./useFlyers', () => ({
useFlyers: () => mockUseFlyers(),
}));
vi.mock('../hooks/useUserData', () => ({
useUserData: () => mockUseUserData(),
}));
// 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');
describe('useActiveDeals Hook', () => {
// Use fake timers to control the current date in tests
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(TODAY);
vi.clearAllMocks();
// Set up default successful mocks for the new data hooks
mockUseFlyers.mockReturnValue({
flyers: mockFlyers,
isLoadingFlyers: false,
flyersError: null,
fetchNextFlyersPage: vi.fn(),
hasNextFlyersPage: false,
isRefetchingFlyers: false,
refetchFlyers: vi.fn(),
});
mockUseUserData.mockReturnValue({
watchedItems: mockWatchedItems,
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
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(() => {
vi.useRealTimers();
});
const mockFlyers: Flyer[] = [
// A currently valid flyer
createMockFlyer({
flyer_id: 1,
file_name: 'valid.pdf',
item_count: 10,
valid_from: '2024-01-10',
valid_to: '2024-01-20',
store: { store_id: 1, name: 'Valid Store' },
}),
// An expired flyer
createMockFlyer({
flyer_id: 2,
file_name: 'expired.pdf',
item_count: 5,
valid_from: '2024-01-01',
valid_to: '2024-01-05',
store: { store_id: 2, name: 'Expired Store' },
}),
// A future flyer
createMockFlyer({
flyer_id: 3,
file_name: 'future.pdf',
item_count: 8,
valid_from: '2024-02-01',
valid_to: '2024-02-10',
store: { store_id: 3, name: 'Future Store' },
}),
];
const mockWatchedItems: MasterGroceryItem[] = [
createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Apples' }),
createMockMasterGroceryItem({ master_grocery_item_id: 102, name: 'Milk' }),
];
const mockFlyerItems: FlyerItem[] = [
// A deal for a watched item in a valid flyer
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 1,
item: 'Red Apples',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'lb',
master_item_id: 101,
master_item_name: 'Apples',
}),
// An item that is not a deal
createMockFlyerItem({
flyer_item_id: 2,
flyer_id: 1,
item: 'Oranges',
price_display: '$2.49',
price_in_cents: 249,
quantity: 'lb',
master_item_id: 201,
}),
];
it('should return loading state initially and then calculated data', async () => {
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 });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Red Apples');
});
});
it('should correctly filter for valid flyers and make API calls with their IDs', async () => {
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
// Only the valid flyer (id: 1) should be used in the API calls
// 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 () => {
mockUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
// 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);
});
});
it('should handle the case where there are no valid flyers', async () => {
// Override flyers mock to only include invalid ones
mockUseFlyers.mockReturnValue({
flyers: [mockFlyers[1], mockFlyers[2]],
isLoadingFlyers: false,
flyersError: null,
fetchNextFlyersPage: vi.fn(),
hasNextFlyersPage: false,
isRefetchingFlyers: false,
refetchFlyers: vi.fn(),
});
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(0);
expect(result.current.activeDeals).toEqual([]);
// No API calls should be made if there are no valid flyers
// 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');
mockedUseFlyerItemCountQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: apiError,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe('Could not fetch active deals or totals: Network Failure');
});
});
it('should set an error state if fetching items fails', async () => {
const apiError = new Error('Item fetch failed');
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: undefined,
isLoading: false,
error: apiError,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe(
'Could not fetch active deals or totals: Item fetch failed',
);
});
});
it('should correctly map flyer items to DealItem format', async () => {
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 });
await waitFor(() => {
const deal = result.current.activeDeals[0];
const expectedDeal = createMockDealItem({
item: 'Red Apples',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'lb',
storeName: 'Valid Store',
master_item_name: 'Apples',
unit_price: null,
});
expect(deal).toEqual(expectedDeal);
});
});
it('should use "Unknown Store" as a fallback if flyer has no store or store name', async () => {
// Create a flyer with a null store object
const flyerWithoutStore = createMockFlyer({
flyer_id: 4,
file_name: 'no-store.pdf',
item_count: 1,
valid_from: '2024-01-10',
valid_to: '2024-01-20',
});
(flyerWithoutStore as any).store = null;
const itemInFlyerWithoutStore = createMockFlyerItem({
flyer_item_id: 3,
flyer_id: 4,
item: 'Mystery Item',
price_display: '$5.00',
price_in_cents: 500,
master_item_id: 101,
master_item_name: 'Apples',
});
mockUseFlyers.mockReturnValue({ ...mockUseFlyers(), flyers: [flyerWithoutStore] });
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [itemInFlyerWithoutStore],
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(1);
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 () => {
const mixedItems: FlyerItem[] = [
// Watched item (Master ID 101 is in mockWatchedItems)
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 1,
item: 'Watched Item',
price_display: '$1.00',
price_in_cents: 100,
quantity: 'ea',
master_item_id: 101,
master_item_name: 'Apples',
}),
// Unwatched item (Master ID 999 is NOT in mockWatchedItems)
createMockFlyerItem({
flyer_item_id: 2,
flyer_id: 1,
item: 'Unwatched Item',
price_display: '$2.00',
price_in_cents: 200,
quantity: 'ea',
master_item_id: 999,
master_item_name: 'Unknown',
}),
// Item with no master ID
createMockFlyerItem({
flyer_item_id: 3,
flyer_id: 1,
item: 'No Master ID',
price_display: '$3.00',
price_in_cents: 300,
quantity: 'ea',
master_item_id: undefined,
}),
];
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: mixedItems,
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
// Should only contain the watched item
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Watched Item');
});
});
it('should return true for isLoading while API calls are pending', async () => {
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
expect(result.current.isLoading).toBe(true);
});
it('should re-filter active deals when watched items change (client-side filtering)', async () => {
const allFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 1,
item: 'Red Apples',
price_display: '$1.99',
price_in_cents: 199,
master_item_id: 101, // matches mockWatchedItems
master_item_name: 'Apples',
}),
createMockFlyerItem({
flyer_item_id: 2,
flyer_id: 1,
item: 'Fresh Bread',
price_display: '$2.99',
price_in_cents: 299,
master_item_id: 103, // NOT in initial mockWatchedItems
master_item_name: 'Bread',
}),
];
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: allFlyerItems,
isLoading: false,
error: null,
} as any);
const { result, rerender } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// Wait for initial data to load
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Initially, only Apples (master_item_id: 101) should be in activeDeals
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Red Apples');
// Now add Bread to watched items
const newWatchedItems = [
...mockWatchedItems,
createMockMasterGroceryItem({ master_grocery_item_id: 103, name: 'Bread' }),
];
mockUseUserData.mockReturnValue({
watchedItems: newWatchedItems,
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
// Rerender to pick up new watchedItems
rerender();
// After rerender, client-side filtering should now include both items
await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(2);
});
// Verify both items are present
const dealItems = result.current.activeDeals.map((d) => d.item);
expect(dealItems).toContain('Red Apples');
expect(dealItems).toContain('Fresh Bread');
});
it('should include flyers valid exactly on the start or end date', async () => {
// TODAY is 2024-01-15T12:00:00.000Z
const boundaryFlyers: Flyer[] = [
// Ends today
createMockFlyer({
flyer_id: 10,
file_name: 'ends-today.pdf',
item_count: 1,
valid_from: '2024-01-01',
valid_to: '2024-01-15',
store: { store_id: 1, name: 'Store A' },
}),
// Starts today
createMockFlyer({
flyer_id: 11,
file_name: 'starts-today.pdf',
item_count: 1,
valid_from: '2024-01-15',
valid_to: '2024-01-30',
store: { store_id: 1, name: 'Store B' },
}),
// Valid only today
createMockFlyer({
flyer_id: 12,
file_name: 'only-today.pdf',
item_count: 1,
valid_from: '2024-01-15',
valid_to: '2024-01-15',
store: { store_id: 1, name: 'Store C' },
}),
// Ends yesterday (invalid)
createMockFlyer({
flyer_id: 13,
file_name: 'ends-yesterday.pdf',
item_count: 1,
valid_from: '2024-01-01',
valid_to: '2024-01-14',
store: { store_id: 1, name: 'Store D' },
}),
];
mockUseFlyers.mockReturnValue({
flyers: boundaryFlyers,
isLoadingFlyers: false,
flyersError: null,
fetchNextFlyersPage: vi.fn(),
hasNextFlyersPage: false,
isRefetchingFlyers: false,
refetchFlyers: vi.fn(),
});
renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(mockedUseFlyerItemCountQuery).toHaveBeenCalledWith([10, 11, 12], true);
});
});
it('should handle missing price_in_cents and quantity in deal items', async () => {
const incompleteItem = createMockFlyerItem({
flyer_item_id: 99,
flyer_id: 1,
item: 'Incomplete Item',
price_display: '$1.99',
// price_in_cents and quantity are missing
master_item_id: 101,
master_item_name: 'Apples',
price_in_cents: null,
quantity: undefined,
});
mockedUseFlyerItemsForFlyersQuery.mockReturnValue({
data: [incompleteItem],
isLoading: false,
error: null,
} as any);
const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(1);
const deal = result.current.activeDeals[0];
expect(deal.price_in_cents).toBeNull();
expect(deal.quantity).toBe('');
});
});
});