All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m21s
533 lines
16 KiB
TypeScript
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('');
|
|
});
|
|
});
|
|
});
|