complete project using prettier!
This commit is contained in:
@@ -4,7 +4,12 @@ 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, createMockFlyerItem, createMockMasterGroceryItem, createMockDealItem } from '../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockFlyer,
|
||||
createMockFlyerItem,
|
||||
createMockMasterGroceryItem,
|
||||
createMockDealItem,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
|
||||
|
||||
// Explicitly mock apiClient to ensure stable spies are used
|
||||
@@ -39,8 +44,8 @@ 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
|
||||
// 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);
|
||||
@@ -130,13 +135,17 @@ 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)));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 10 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify(mockFlyerItems)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
|
||||
// The hook runs the effect almost immediately. We shouldn't strictly assert false
|
||||
// because depending on render timing, it might already be true.
|
||||
// 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
|
||||
@@ -149,7 +158,9 @@ 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.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 0 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
@@ -164,7 +175,9 @@ describe('useActiveDeals Hook', () => {
|
||||
});
|
||||
|
||||
it('should not fetch flyer items if there are no watched items', async () => {
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 10 })),
|
||||
);
|
||||
mockUseUserData.mockReturnValue({
|
||||
watchedItems: [],
|
||||
shoppingLists: [],
|
||||
@@ -223,7 +236,9 @@ 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.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 10 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockRejectedValue(apiError);
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
@@ -231,13 +246,19 @@ describe('useActiveDeals Hook', () => {
|
||||
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');
|
||||
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 () => {
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify(mockFlyerItems)));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 10 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify(mockFlyerItems)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
|
||||
@@ -278,8 +299,12 @@ 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])));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 1 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify([itemInFlyerWithoutStore])),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
|
||||
@@ -291,18 +316,48 @@ describe('useActiveDeals Hook', () => {
|
||||
});
|
||||
|
||||
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 })));
|
||||
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 5 })),
|
||||
);
|
||||
|
||||
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' }),
|
||||
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' }),
|
||||
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 }),
|
||||
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,
|
||||
}),
|
||||
];
|
||||
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify(mixedItems)));
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify(mixedItems)),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
|
||||
@@ -317,10 +372,14 @@ 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; });
|
||||
|
||||
const countPromise = new Promise<Response>((resolve) => {
|
||||
resolveCount = resolve;
|
||||
});
|
||||
|
||||
let resolveItems: (value: Response) => void;
|
||||
const itemsPromise = new Promise<Response>((resolve) => { resolveItems = resolve; });
|
||||
const itemsPromise = new Promise<Response>((resolve) => {
|
||||
resolveItems = resolve;
|
||||
});
|
||||
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockReturnValue(countPromise);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockReturnValue(itemsPromise);
|
||||
@@ -343,7 +402,9 @@ describe('useActiveDeals Hook', () => {
|
||||
|
||||
it('should re-fetch data when watched items change', async () => {
|
||||
// Initial render
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 1 })));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 1 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
|
||||
|
||||
const { rerender } = renderHook(() => useActiveDeals());
|
||||
@@ -353,7 +414,10 @@ describe('useActiveDeals Hook', () => {
|
||||
});
|
||||
|
||||
// Change watched items
|
||||
const newWatchedItems = [...mockWatchedItems, createMockMasterGroceryItem({ master_grocery_item_id: 103, name: 'Bread' })];
|
||||
const newWatchedItems = [
|
||||
...mockWatchedItems,
|
||||
createMockMasterGroceryItem({ master_grocery_item_id: 103, name: 'Bread' }),
|
||||
];
|
||||
mockUseUserData.mockReturnValue({
|
||||
watchedItems: newWatchedItems,
|
||||
shoppingLists: [],
|
||||
@@ -376,13 +440,41 @@ describe('useActiveDeals Hook', () => {
|
||||
// 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' } }),
|
||||
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' } }),
|
||||
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' } }),
|
||||
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' } }),
|
||||
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({
|
||||
@@ -395,14 +487,19 @@ describe('useActiveDeals Hook', () => {
|
||||
refetchFlyers: vi.fn(),
|
||||
});
|
||||
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 0 })));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 0 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
|
||||
|
||||
renderHook(() => useActiveDeals());
|
||||
|
||||
await waitFor(() => {
|
||||
// Should call with IDs 10, 11, 12. Should NOT include 13.
|
||||
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([10, 11, 12], expect.anything());
|
||||
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith(
|
||||
[10, 11, 12],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,8 +516,12 @@ describe('useActiveDeals Hook', () => {
|
||||
quantity: undefined,
|
||||
});
|
||||
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 1 })));
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([incompleteItem])));
|
||||
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify({ count: 1 })),
|
||||
);
|
||||
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
|
||||
new Response(JSON.stringify([incompleteItem])),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useActiveDeals());
|
||||
|
||||
@@ -431,4 +532,4 @@ describe('useActiveDeals Hook', () => {
|
||||
expect(deal.quantity).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,18 +20,33 @@ export const useActiveDeals = () => {
|
||||
const { flyers } = useFlyers();
|
||||
const { watchedItems } = useUserData();
|
||||
// Centralize API call state management with the useApi hook. We can ignore isRefetching here.
|
||||
const { execute: executeCount, loading: loadingCount, error: errorCount, data: countData, reset: resetCount } = useApi<FlyerItemCount, [number[]]>(apiClient.countFlyerItemsForFlyers);
|
||||
const { execute: executeItems, loading: loadingItems, error: errorItems, data: itemsData, reset: resetItems } = useApi<FlyerItem[], [number[]]>(apiClient.fetchFlyerItemsForFlyers);
|
||||
const {
|
||||
execute: executeCount,
|
||||
loading: loadingCount,
|
||||
error: errorCount,
|
||||
data: countData,
|
||||
reset: resetCount,
|
||||
} = useApi<FlyerItemCount, [number[]]>(apiClient.countFlyerItemsForFlyers);
|
||||
const {
|
||||
execute: executeItems,
|
||||
loading: loadingItems,
|
||||
error: errorItems,
|
||||
data: itemsData,
|
||||
reset: resetItems,
|
||||
} = useApi<FlyerItem[], [number[]]>(apiClient.fetchFlyerItemsForFlyers);
|
||||
|
||||
// Consolidate loading and error states from both API calls.
|
||||
const isLoading = loadingCount || loadingItems;
|
||||
const error = errorCount || errorItems ? `Could not fetch active deals or totals: ${errorCount?.message || errorItems?.message}` : null;
|
||||
const error =
|
||||
errorCount || errorItems
|
||||
? `Could not fetch active deals or totals: ${errorCount?.message || errorItems?.message}`
|
||||
: null;
|
||||
|
||||
// Memoize the calculation of valid flyers to avoid re-computing on every render.
|
||||
const validFlyers = useMemo(() => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return flyers.filter(flyer => {
|
||||
return flyers.filter((flyer) => {
|
||||
const from = new Date(`${flyer.valid_from}T00:00:00`);
|
||||
const to = new Date(`${flyer.valid_to}T00:00:00`);
|
||||
const isValid = from <= today && today <= to;
|
||||
@@ -52,7 +67,7 @@ export const useActiveDeals = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const validFlyerIds = validFlyers.map(f => f.flyer_id);
|
||||
const validFlyerIds = validFlyers.map((f) => f.flyer_id);
|
||||
|
||||
// Execute API calls using the hooks.
|
||||
if (watchedItems.length > 0) {
|
||||
@@ -68,9 +83,13 @@ export const useActiveDeals = () => {
|
||||
const activeDeals = useMemo(() => {
|
||||
if (!itemsData || watchedItems.length === 0) return [];
|
||||
|
||||
const watchedItemIds = new Set(watchedItems.map(item => item.master_grocery_item_id));
|
||||
const dealItemsRaw = itemsData.filter(item => item.master_item_id && watchedItemIds.has(item.master_item_id));
|
||||
const flyerIdToStoreName = new Map(validFlyers.map(f => [f.flyer_id, f.store?.name || 'Unknown Store']));
|
||||
const watchedItemIds = new Set(watchedItems.map((item) => item.master_grocery_item_id));
|
||||
const dealItemsRaw = itemsData.filter(
|
||||
(item) => item.master_item_id && watchedItemIds.has(item.master_item_id),
|
||||
);
|
||||
const flyerIdToStoreName = new Map(
|
||||
validFlyers.map((f) => [f.flyer_id, f.store?.name || 'Unknown Store']),
|
||||
);
|
||||
|
||||
return dealItemsRaw.map((item): DealItem => {
|
||||
const deal: DealItem = {
|
||||
@@ -80,16 +99,16 @@ export const useActiveDeals = () => {
|
||||
quantity: item.quantity ?? '',
|
||||
storeName: flyerIdToStoreName.get(item.flyer_id!) || 'Unknown Store',
|
||||
master_item_name: item.master_item_name,
|
||||
unit_price: item.unit_price ?? null
|
||||
unit_price: item.unit_price ?? null,
|
||||
};
|
||||
|
||||
|
||||
// Logging the mapped deal for debugging purposes to trace data integrity issues
|
||||
logger.info('Mapped DealItem:', deal);
|
||||
|
||||
|
||||
return deal;
|
||||
});
|
||||
}, [itemsData, watchedItems, validFlyers]);
|
||||
|
||||
const totalActiveItems = countData?.count ?? 0;
|
||||
return { activeDeals, totalActiveItems, isLoading, error };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,12 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
|
||||
import { useAiAnalysis, aiAnalysisReducer } from './useAiAnalysis';
|
||||
import { AnalysisType, Flyer, FlyerItem, MasterGroceryItem } from '../types';
|
||||
import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockSource } from '../tests/utils/mockFactories';
|
||||
import {
|
||||
createMockFlyer,
|
||||
createMockFlyerItem,
|
||||
createMockMasterGroceryItem,
|
||||
createMockSource,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { AiAnalysisService } from '../services/aiAnalysisService';
|
||||
|
||||
@@ -22,7 +27,14 @@ vi.mock('../services/logger.client', () => ({
|
||||
|
||||
// 2. Mock data
|
||||
const mockFlyerItems: FlyerItem[] = [
|
||||
createMockFlyerItem({ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1 }),
|
||||
createMockFlyerItem({
|
||||
flyer_item_id: 1,
|
||||
item: 'Apples',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: '1lb',
|
||||
flyer_id: 1,
|
||||
}),
|
||||
];
|
||||
const mockWatchedItems: MasterGroceryItem[] = [
|
||||
createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Bananas' }),
|
||||
@@ -106,7 +118,10 @@ describe('useAiAnalysis Hook', () => {
|
||||
|
||||
it('should handle grounded responses for WEB_SEARCH', async () => {
|
||||
console.log('TEST: should handle grounded responses for WEB_SEARCH');
|
||||
const mockResult = { text: 'Web search text', sources: [createMockSource({ uri: 'http://a.com', title: 'Source A' })] };
|
||||
const mockResult = {
|
||||
text: 'Web search text',
|
||||
sources: [createMockSource({ uri: 'http://a.com', title: 'Source A' })],
|
||||
};
|
||||
mockService.searchWeb.mockResolvedValue(mockResult);
|
||||
const { result } = renderHook(() => useAiAnalysis(defaultParams));
|
||||
|
||||
@@ -120,23 +135,32 @@ describe('useAiAnalysis Hook', () => {
|
||||
});
|
||||
|
||||
it('should handle PLAN_TRIP and its specific arguments', async () => {
|
||||
console.log('TEST: should handle PLAN_TRIP');
|
||||
const mockResult = { text: 'Trip plan text', sources: [createMockSource({ uri: 'http://maps.com', title: 'Map' })] };
|
||||
mockService.planTripWithMaps.mockResolvedValue(mockResult);
|
||||
const { result } = renderHook(() => useAiAnalysis(defaultParams));
|
||||
console.log('TEST: should handle PLAN_TRIP');
|
||||
const mockResult = {
|
||||
text: 'Trip plan text',
|
||||
sources: [createMockSource({ uri: 'http://maps.com', title: 'Map' })],
|
||||
};
|
||||
mockService.planTripWithMaps.mockResolvedValue(mockResult);
|
||||
const { result } = renderHook(() => useAiAnalysis(defaultParams));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.runAnalysis(AnalysisType.PLAN_TRIP);
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.runAnalysis(AnalysisType.PLAN_TRIP);
|
||||
});
|
||||
|
||||
expect(mockService.planTripWithMaps).toHaveBeenCalledWith(mockFlyerItems, mockSelectedFlyer.store);
|
||||
expect(result.current.results[AnalysisType.PLAN_TRIP]).toBe(mockResult.text);
|
||||
expect(result.current.sources[AnalysisType.PLAN_TRIP]).toEqual(mockResult.sources);
|
||||
expect(mockService.planTripWithMaps).toHaveBeenCalledWith(
|
||||
mockFlyerItems,
|
||||
mockSelectedFlyer.store,
|
||||
);
|
||||
expect(result.current.results[AnalysisType.PLAN_TRIP]).toBe(mockResult.text);
|
||||
expect(result.current.sources[AnalysisType.PLAN_TRIP]).toEqual(mockResult.sources);
|
||||
});
|
||||
|
||||
it('should handle COMPARE_PRICES and its specific arguments', async () => {
|
||||
console.log('TEST: should handle COMPARE_PRICES');
|
||||
const mockResult = { text: 'Price comparison text', sources: [createMockSource({ uri: 'http://prices.com', title: 'Prices' })] };
|
||||
const mockResult = {
|
||||
text: 'Price comparison text',
|
||||
sources: [createMockSource({ uri: 'http://prices.com', title: 'Prices' })],
|
||||
};
|
||||
mockService.compareWatchedItemPrices.mockResolvedValue(mockResult);
|
||||
const { result } = renderHook(() => useAiAnalysis(defaultParams));
|
||||
|
||||
@@ -186,7 +210,7 @@ describe('useAiAnalysis Hook', () => {
|
||||
console.log('TEST: should clear previous error when starting a new analysis');
|
||||
const apiError = new Error('First failure');
|
||||
mockService.getQuickInsights.mockRejectedValueOnce(apiError);
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAiAnalysis(defaultParams));
|
||||
|
||||
// 1. Trigger error
|
||||
@@ -215,7 +239,9 @@ describe('useAiAnalysis Hook', () => {
|
||||
await result.current.generateImage();
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith('generateImage called but no meal plan text available.');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'generateImage called but no meal plan text available.',
|
||||
);
|
||||
expect(mockService.generateImageFromText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -266,7 +292,7 @@ describe('useAiAnalysis Hook', () => {
|
||||
console.log('TEST: should preserve existing results');
|
||||
mockService.getQuickInsights.mockResolvedValue('Insight 1');
|
||||
mockService.getDeepDiveAnalysis.mockResolvedValue('Insight 2');
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAiAnalysis(defaultParams));
|
||||
|
||||
// 1. Run first analysis
|
||||
@@ -295,7 +321,7 @@ describe('useAiAnalysis Hook', () => {
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[aiAnalysisReducer] Dispatched action: FETCH_START'),
|
||||
expect.objectContaining({ payload: { analysisType: AnalysisType.QUICK_INSIGHTS } })
|
||||
expect.objectContaining({ payload: { analysisType: AnalysisType.QUICK_INSIGHTS } }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -358,10 +384,16 @@ describe('useAiAnalysis Hook', () => {
|
||||
|
||||
describe('aiAnalysisReducer', () => {
|
||||
it('should return current state for unknown action', () => {
|
||||
const initialState = { loadingAnalysis: null, error: null, results: {}, sources: {}, generatedImageUrl: null };
|
||||
const initialState = {
|
||||
loadingAnalysis: null,
|
||||
error: null,
|
||||
results: {},
|
||||
sources: {},
|
||||
generatedImageUrl: null,
|
||||
};
|
||||
const action = { type: 'UNKNOWN_ACTION' } as any;
|
||||
const newState = aiAnalysisReducer(initialState, action);
|
||||
expect(newState).toBe(initialState);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
MasterGroceryItem,
|
||||
AnalysisType,
|
||||
AiAnalysisState,
|
||||
AiAnalysisAction
|
||||
AiAnalysisAction,
|
||||
} from '../types';
|
||||
import { AiAnalysisService } from '../services/aiAnalysisService';
|
||||
import { logger } from '../services/logger.client';
|
||||
@@ -28,7 +28,10 @@ const initialState: AiAnalysisState = {
|
||||
* @param action - The action to perform.
|
||||
* @returns The new state.
|
||||
*/
|
||||
export function aiAnalysisReducer(state: AiAnalysisState, action: AiAnalysisAction): AiAnalysisState {
|
||||
export function aiAnalysisReducer(
|
||||
state: AiAnalysisState,
|
||||
action: AiAnalysisAction,
|
||||
): AiAnalysisState {
|
||||
// Safely log the payload only if it exists on the action.
|
||||
const payload = 'payload' in action ? action.payload : {};
|
||||
logger.info(`[aiAnalysisReducer] Dispatched action: ${action.type}`, { payload });
|
||||
@@ -90,47 +93,56 @@ interface UseAiAnalysisParams {
|
||||
service: AiAnalysisService;
|
||||
}
|
||||
|
||||
export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems, service }: UseAiAnalysisParams) => {
|
||||
export const useAiAnalysis = ({
|
||||
flyerItems,
|
||||
selectedFlyer,
|
||||
watchedItems,
|
||||
service,
|
||||
}: UseAiAnalysisParams) => {
|
||||
const [state, dispatch] = useReducer(aiAnalysisReducer, initialState);
|
||||
|
||||
const runAnalysis = useCallback(async (analysisType: AnalysisType) => {
|
||||
dispatch({ type: 'FETCH_START', payload: { analysisType } });
|
||||
try {
|
||||
// Delegate the call to the injected service.
|
||||
switch (analysisType) {
|
||||
case AnalysisType.QUICK_INSIGHTS: {
|
||||
const data = await service.getQuickInsights(flyerItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_TEXT', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.DEEP_DIVE: {
|
||||
const data = await service.getDeepDiveAnalysis(flyerItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_TEXT', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.WEB_SEARCH: {
|
||||
const data = await service.searchWeb(flyerItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.PLAN_TRIP: {
|
||||
if (!selectedFlyer?.store) throw new Error("Store information is not available for trip planning.");
|
||||
const data = await service.planTripWithMaps(flyerItems, selectedFlyer.store);
|
||||
dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.COMPARE_PRICES: {
|
||||
const data = await service.compareWatchedItemPrices(watchedItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } });
|
||||
break;
|
||||
const runAnalysis = useCallback(
|
||||
async (analysisType: AnalysisType) => {
|
||||
dispatch({ type: 'FETCH_START', payload: { analysisType } });
|
||||
try {
|
||||
// Delegate the call to the injected service.
|
||||
switch (analysisType) {
|
||||
case AnalysisType.QUICK_INSIGHTS: {
|
||||
const data = await service.getQuickInsights(flyerItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_TEXT', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.DEEP_DIVE: {
|
||||
const data = await service.getDeepDiveAnalysis(flyerItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_TEXT', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.WEB_SEARCH: {
|
||||
const data = await service.searchWeb(flyerItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.PLAN_TRIP: {
|
||||
if (!selectedFlyer?.store)
|
||||
throw new Error('Store information is not available for trip planning.');
|
||||
const data = await service.planTripWithMaps(flyerItems, selectedFlyer.store);
|
||||
dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
case AnalysisType.COMPARE_PRICES: {
|
||||
const data = await service.compareWatchedItemPrices(watchedItems);
|
||||
dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error(`runAnalysis failed for type ${analysisType}`, { error: err });
|
||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred.';
|
||||
dispatch({ type: 'FETCH_ERROR', payload: { error: message } });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logger.error(`runAnalysis failed for type ${analysisType}`, { error: err });
|
||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred.';
|
||||
dispatch({ type: 'FETCH_ERROR', payload: { error: message } });
|
||||
}
|
||||
}, [service, flyerItems, watchedItems, selectedFlyer]);
|
||||
},
|
||||
[service, flyerItems, watchedItems, selectedFlyer],
|
||||
);
|
||||
|
||||
const generateImage = useCallback(async () => {
|
||||
const mealPlanText = state.results[AnalysisType.DEEP_DIVE];
|
||||
@@ -144,7 +156,10 @@ export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems, service
|
||||
dispatch({ type: 'FETCH_SUCCESS_IMAGE', payload: { data } });
|
||||
} catch (err: unknown) {
|
||||
logger.error('generateImage failed', { error: err });
|
||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred during image generation.';
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'An unexpected error occurred during image generation.';
|
||||
dispatch({ type: 'FETCH_ERROR', payload: { error: message } });
|
||||
}
|
||||
}, [service, state.results]);
|
||||
@@ -157,11 +172,14 @@ export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems, service
|
||||
dispatch({ type: 'RESET_STATE' });
|
||||
}, []);
|
||||
|
||||
return useMemo(() => ({
|
||||
...state,
|
||||
runAnalysis,
|
||||
generateImage,
|
||||
clearError,
|
||||
resetAnalysis,
|
||||
}), [state, runAnalysis, generateImage, clearError, resetAnalysis]);
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
runAnalysis,
|
||||
generateImage,
|
||||
clearError,
|
||||
resetAnalysis,
|
||||
}),
|
||||
[state, runAnalysis, generateImage, clearError, resetAnalysis],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('useApi Hook', () => {
|
||||
it('should clear previous error when execute is called again', async () => {
|
||||
console.log('Test: should clear previous error when execute is called again');
|
||||
mockApiFunction.mockRejectedValueOnce(new Error('Fail'));
|
||||
|
||||
|
||||
// We use a controlled promise for the second call to assert state while it is pending
|
||||
let resolveSecondCall: (value: Response) => void;
|
||||
const secondCallPromise = new Promise<Response>((resolve) => {
|
||||
@@ -129,11 +129,11 @@ describe('useApi Hook', () => {
|
||||
// Error should be cleared immediately upon execution start
|
||||
console.log('Step: Second call started. Error state (should be null):', result.current.error);
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
|
||||
// Resolve the second call
|
||||
console.log('Step: Resolving second call promise');
|
||||
resolveSecondCall!(new Response(JSON.stringify({ success: true })));
|
||||
|
||||
|
||||
await act(async () => {
|
||||
await executePromise;
|
||||
});
|
||||
@@ -186,7 +186,9 @@ describe('useApi Hook', () => {
|
||||
console.log('Test: isRefetching state - success path');
|
||||
// First call setup
|
||||
let resolveFirst: (val: Response) => void;
|
||||
const firstPromise = new Promise<Response>((resolve) => { resolveFirst = resolve; });
|
||||
const firstPromise = new Promise<Response>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
mockApiFunction.mockReturnValueOnce(firstPromise);
|
||||
|
||||
const { result } = renderHook(() => useApi<{ data: string }, []>(mockApiFunction));
|
||||
@@ -199,25 +201,39 @@ describe('useApi Hook', () => {
|
||||
});
|
||||
|
||||
// During the first call, loading is true, but isRefetching is false
|
||||
console.log('Check: First call in flight. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
console.log(
|
||||
'Check: First call in flight. loading:',
|
||||
result.current.loading,
|
||||
'isRefetching:',
|
||||
result.current.isRefetching,
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
|
||||
console.log('Step: Resolving first call');
|
||||
resolveFirst!(new Response(JSON.stringify({ data: 'first call' })));
|
||||
await act(async () => { await firstCallPromise; });
|
||||
await act(async () => {
|
||||
await firstCallPromise;
|
||||
});
|
||||
|
||||
// After the first call, both are false
|
||||
console.log('Check: First call done. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
console.log(
|
||||
'Check: First call done. loading:',
|
||||
result.current.loading,
|
||||
'isRefetching:',
|
||||
result.current.isRefetching,
|
||||
);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
expect(result.current.data).toEqual({ data: 'first call' });
|
||||
|
||||
// --- Second call ---
|
||||
let resolveSecond: (val: Response) => void;
|
||||
const secondPromise = new Promise<Response>((resolve) => { resolveSecond = resolve; });
|
||||
const secondPromise = new Promise<Response>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
mockApiFunction.mockReturnValueOnce(secondPromise);
|
||||
|
||||
|
||||
let secondCallPromise: Promise<any>;
|
||||
console.log('Step: Starting second call');
|
||||
act(() => {
|
||||
@@ -225,16 +241,28 @@ describe('useApi Hook', () => {
|
||||
});
|
||||
|
||||
// During the second call, both loading and isRefetching are true
|
||||
console.log('Check: Second call in flight. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
console.log(
|
||||
'Check: Second call in flight. loading:',
|
||||
result.current.loading,
|
||||
'isRefetching:',
|
||||
result.current.isRefetching,
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(true);
|
||||
|
||||
console.log('Step: Resolving second call');
|
||||
resolveSecond!(new Response(JSON.stringify({ data: 'second call' })));
|
||||
await act(async () => { await secondCallPromise; });
|
||||
await act(async () => {
|
||||
await secondCallPromise;
|
||||
});
|
||||
|
||||
// After the second call, both are false again
|
||||
console.log('Check: Second call done. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
console.log(
|
||||
'Check: Second call done. loading:',
|
||||
result.current.loading,
|
||||
'isRefetching:',
|
||||
result.current.isRefetching,
|
||||
);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
expect(result.current.data).toEqual({ data: 'second call' });
|
||||
@@ -247,37 +275,50 @@ describe('useApi Hook', () => {
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
console.log('Step: Executing first call (fail)');
|
||||
await act(async () => {
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.execute();
|
||||
await result.current.execute();
|
||||
} catch {}
|
||||
});
|
||||
expect(result.current.error).not.toBeNull();
|
||||
|
||||
// Second call succeeds
|
||||
let resolveSecond: (val: Response) => void;
|
||||
const secondPromise = new Promise<Response>((resolve) => { resolveSecond = resolve; });
|
||||
const secondPromise = new Promise<Response>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
mockApiFunction.mockReturnValueOnce(secondPromise);
|
||||
|
||||
|
||||
let secondCallPromise: Promise<any>;
|
||||
console.log('Step: Starting second call');
|
||||
act(() => { secondCallPromise = result.current.execute(); });
|
||||
act(() => {
|
||||
secondCallPromise = result.current.execute();
|
||||
});
|
||||
|
||||
// Should still be loading (initial load behavior) because first load never succeeded
|
||||
console.log('Check: Second call in flight. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
console.log(
|
||||
'Check: Second call in flight. loading:',
|
||||
result.current.loading,
|
||||
'isRefetching:',
|
||||
result.current.isRefetching,
|
||||
);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
|
||||
|
||||
console.log('Step: Resolving second call');
|
||||
resolveSecond!(new Response(JSON.stringify({ data: 'success' })));
|
||||
await act(async () => { await secondCallPromise; });
|
||||
await act(async () => {
|
||||
await secondCallPromise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Response Handling', () => {
|
||||
it('should parse a simple JSON error message from a non-ok response', async () => {
|
||||
const errorPayload = { message: 'Server is on fire' };
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 500 }));
|
||||
mockApiFunction.mockResolvedValue(
|
||||
new Response(JSON.stringify(errorPayload), { status: 500 }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
@@ -296,7 +337,9 @@ describe('useApi Hook', () => {
|
||||
{ path: ['body', 'password'], message: 'Password too short' },
|
||||
],
|
||||
};
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 400 }));
|
||||
mockApiFunction.mockResolvedValue(
|
||||
new Response(JSON.stringify(errorPayload), { status: 400 }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
@@ -305,16 +348,18 @@ describe('useApi Hook', () => {
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('body.email: Invalid email; body.password: Password too short');
|
||||
expect(result.current.error?.message).toBe(
|
||||
'body.email: Invalid email; body.password: Password too short',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Zod-style error issues without a path', async () => {
|
||||
const errorPayload = {
|
||||
issues: [
|
||||
{ message: 'Global error' },
|
||||
],
|
||||
issues: [{ message: 'Global error' }],
|
||||
};
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 400 }));
|
||||
mockApiFunction.mockResolvedValue(
|
||||
new Response(JSON.stringify(errorPayload), { status: 400 }),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
@@ -327,10 +372,12 @@ describe('useApi Hook', () => {
|
||||
});
|
||||
|
||||
it('should fall back to status text if JSON parsing fails', async () => {
|
||||
mockApiFunction.mockResolvedValue(new Response('Gateway Timeout', {
|
||||
status: 504,
|
||||
statusText: 'Gateway Timeout',
|
||||
}));
|
||||
mockApiFunction.mockResolvedValue(
|
||||
new Response('Gateway Timeout', {
|
||||
status: 504,
|
||||
statusText: 'Gateway Timeout',
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
@@ -344,13 +391,17 @@ describe('useApi Hook', () => {
|
||||
|
||||
it('should fall back to status text if JSON response is valid but lacks error fields', async () => {
|
||||
// Valid JSON but no 'message' or 'issues'
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify({ foo: 'bar' }), {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
}));
|
||||
mockApiFunction.mockResolvedValue(
|
||||
new Response(JSON.stringify({ foo: 'bar' }), {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
await act(async () => { await result.current.execute(); });
|
||||
await act(async () => {
|
||||
await result.current.execute();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('Request failed with status 400: Bad Request');
|
||||
@@ -361,7 +412,9 @@ describe('useApi Hook', () => {
|
||||
mockApiFunction.mockRejectedValue('String Error');
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
await act(async () => { await result.current.execute(); });
|
||||
await act(async () => {
|
||||
await result.current.execute();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
// The hook wraps unknown errors
|
||||
@@ -405,7 +458,9 @@ describe('useApi Hook', () => {
|
||||
mockApiFunction.mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
await act(async () => { await result.current.execute(); });
|
||||
await act(async () => {
|
||||
await result.current.execute();
|
||||
});
|
||||
|
||||
expect(notifyError).toHaveBeenCalledWith('Boom');
|
||||
expect(logger.error).toHaveBeenCalledWith('API call failed in useApi hook', {
|
||||
@@ -434,7 +489,9 @@ describe('useApi Hook', () => {
|
||||
});
|
||||
|
||||
const { result, unmount } = renderHook(() => useApi(mockApiFunction));
|
||||
act(() => { result.current.execute(); });
|
||||
act(() => {
|
||||
result.current.execute();
|
||||
});
|
||||
unmount();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -445,4 +502,4 @@ describe('useApi Hook', () => {
|
||||
expect(notifyError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { notifyError } from '../services/notificationService';
|
||||
* - `reset`: A function to manually reset the hook's state to its initial values.
|
||||
*/
|
||||
export function useApi<T, TArgs extends unknown[]>(
|
||||
apiFunction: (...args: [...TArgs, AbortSignal?]) => Promise<Response>
|
||||
apiFunction: (...args: [...TArgs, AbortSignal?]) => Promise<Response>,
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -48,63 +48,74 @@ export function useApi<T, TArgs extends unknown[]>(
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (...args: TArgs): Promise<T | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (hasBeenExecuted.current) {
|
||||
setIsRefetching(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFunction(...args, abortControllerRef.current.signal);
|
||||
const execute = useCallback(
|
||||
async (...args: TArgs): Promise<T | null> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (hasBeenExecuted.current) {
|
||||
setIsRefetching(true);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Attempt to parse a JSON error response. This is aligned with ADR-003,
|
||||
// which standardizes on structured Zod errors.
|
||||
let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
// If the backend sends a Zod-like error array, format it.
|
||||
if (Array.isArray(errorData.issues) && errorData.issues.length > 0) {
|
||||
errorMessage = errorData.issues
|
||||
.map((issue: { path?: string[], message: string }) => `${issue.path?.join('.') || 'Error'}: ${issue.message}`)
|
||||
.join('; ');
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
try {
|
||||
const response = await apiFunction(...args, abortControllerRef.current.signal);
|
||||
|
||||
if (!response.ok) {
|
||||
// Attempt to parse a JSON error response. This is aligned with ADR-003,
|
||||
// which standardizes on structured Zod errors.
|
||||
let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
// If the backend sends a Zod-like error array, format it.
|
||||
if (Array.isArray(errorData.issues) && errorData.issues.length > 0) {
|
||||
errorMessage = errorData.issues
|
||||
.map(
|
||||
(issue: { path?: string[]; message: string }) =>
|
||||
`${issue.path?.join('.') || 'Error'}: ${issue.message}`,
|
||||
)
|
||||
.join('; ');
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
}
|
||||
} catch {
|
||||
/* Ignore JSON parsing errors and use the default status text message. */
|
||||
}
|
||||
} catch { /* Ignore JSON parsing errors and use the default status text message. */ }
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Handle successful responses with no content (e.g., HTTP 204).
|
||||
if (response.status === 204) {
|
||||
setData(null);
|
||||
return null;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result: T = await response.json();
|
||||
setData(result);
|
||||
if (!hasBeenExecuted.current) {
|
||||
hasBeenExecuted.current = true;
|
||||
// Handle successful responses with no content (e.g., HTTP 204).
|
||||
if (response.status === 204) {
|
||||
setData(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: T = await response.json();
|
||||
setData(result);
|
||||
if (!hasBeenExecuted.current) {
|
||||
hasBeenExecuted.current = true;
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
||||
// If the error is an AbortError, it's an intentional cancellation, so we don't set an error state.
|
||||
if (err.name === 'AbortError') {
|
||||
logger.info('API request was cancelled.', { functionName: apiFunction.name });
|
||||
return null;
|
||||
}
|
||||
logger.error('API call failed in useApi hook', {
|
||||
error: err.message,
|
||||
functionName: apiFunction.name,
|
||||
});
|
||||
setError(err);
|
||||
notifyError(err.message); // Optionally notify the user automatically.
|
||||
return null; // Return null on failure.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
||||
// If the error is an AbortError, it's an intentional cancellation, so we don't set an error state.
|
||||
if (err.name === 'AbortError') {
|
||||
logger.info('API request was cancelled.', { functionName: apiFunction.name });
|
||||
return null;
|
||||
}
|
||||
logger.error('API call failed in useApi hook', { error: err.message, functionName: apiFunction.name });
|
||||
setError(err);
|
||||
notifyError(err.message); // Optionally notify the user automatically.
|
||||
return null; // Return null on failure.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
}, [apiFunction]); // abortControllerRef is stable
|
||||
},
|
||||
[apiFunction],
|
||||
); // abortControllerRef is stable
|
||||
|
||||
return { execute, loading, isRefetching, error, data, reset };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,4 +75,4 @@ describe('useApiOnMount', () => {
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,4 +41,4 @@ export function useApiOnMount<T, TArgs extends unknown[]>(
|
||||
}, [execute, enabled, ...deps, ...args]);
|
||||
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,15 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
let storage: { [key: string]: string } = {};
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => storage[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
|
||||
removeItem: vi.fn((key: string) => { delete storage[key]; }),
|
||||
clear: vi.fn(() => { storage = {}; }),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
storage[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete storage[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
storage = {};
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -51,7 +57,7 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
storage = {};
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
|
||||
});
|
||||
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -60,19 +66,21 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
// Suppress console error for this expected failure
|
||||
const originalError = console.error;
|
||||
console.error = vi.fn();
|
||||
expect(() => renderHook(() => useAuth())).toThrow('useAuth must be used within an AuthProvider');
|
||||
expect(() => renderHook(() => useAuth())).toThrow(
|
||||
'useAuth must be used within an AuthProvider',
|
||||
);
|
||||
console.error = originalError;
|
||||
});
|
||||
it('initializes with a default state', async () => {
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
// We verify that it starts in a loading state or quickly resolves to signed out
|
||||
// depending on the execution speed.
|
||||
// depending on the execution speed.
|
||||
// To avoid flakiness, we just ensure it is in a valid state structure.
|
||||
expect(result.current.userProfile).toBeNull();
|
||||
// It should eventually settle
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,10 +112,12 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
|
||||
expect(result.current.authStatus).toBe('AUTHENTICATED');
|
||||
expect(result.current.userProfile).toEqual(mockProfile);
|
||||
|
||||
// Check that it was called at least once.
|
||||
|
||||
// Check that it was called at least once.
|
||||
// React 18 Strict Mode might call effects twice in dev/test environment.
|
||||
expect(mockedApiClient.getAuthenticatedUserProfile.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(mockedApiClient.getAuthenticatedUserProfile.mock.calls.length).toBeGreaterThanOrEqual(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets state to SIGNED_OUT and removes token if validation fails', async () => {
|
||||
@@ -134,9 +144,11 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
// as this is the return type of the actual function. The `useApi` hook then
|
||||
// processes this response. This mock is now type-safe.
|
||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
||||
ok: true, status: 200, json: () => Promise.resolve(mockProfile),
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockProfile),
|
||||
} as Response);
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
|
||||
// 1. Wait for the initial effect to complete and loading to be false
|
||||
@@ -144,10 +156,12 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
console.log('[TEST-DEBUG] Initial auth check complete. Current status:', result.current.authStatus);
|
||||
console.log(
|
||||
'[TEST-DEBUG] Initial auth check complete. Current status:',
|
||||
result.current.authStatus,
|
||||
);
|
||||
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
||||
|
||||
|
||||
// 2. Perform login
|
||||
await act(async () => {
|
||||
console.log('[TEST-DEBUG] Calling login function...');
|
||||
@@ -162,7 +176,9 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
|
||||
// 4. We must wait for the state update inside the hook to propagate
|
||||
await waitFor(() => {
|
||||
console.log(`[TEST-DEBUG] Checking authStatus in waitFor... Current status: ${result.current.authStatus}`);
|
||||
console.log(
|
||||
`[TEST-DEBUG] Checking authStatus in waitFor... Current status: ${result.current.authStatus}`,
|
||||
);
|
||||
expect(result.current.authStatus).toBe('AUTHENTICATED');
|
||||
});
|
||||
|
||||
@@ -180,11 +196,11 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
|
||||
// The login function should reject the promise it returns.
|
||||
await act(async () => {
|
||||
await expect(result.current.login('new-token')).rejects.toThrow(
|
||||
/Login succeeded, but failed to fetch your data/
|
||||
await expect(result.current.login('new-token')).rejects.toThrow(
|
||||
/Login succeeded, but failed to fetch your data/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// Should trigger the logout flow
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('authToken');
|
||||
expect(result.current.authStatus).toBe('SIGNED_OUT'); // This was a duplicate, fixed.
|
||||
@@ -201,7 +217,7 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockProfile),
|
||||
} as unknown as Response);
|
||||
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||
await waitFor(() => expect(result.current.authStatus).toBe('AUTHENTICATED'));
|
||||
|
||||
@@ -216,7 +232,7 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
expect(result.current.userProfile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('updateProfile function', () => {
|
||||
it('merges new data into the existing profile state', async () => {
|
||||
// Start in a logged-in state
|
||||
@@ -267,4 +283,4 @@ describe('useAuth Hook and AuthProvider', () => {
|
||||
expect(result.current.userProfile).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,4 +16,4 @@ export const useAuth = (): AuthContextType => {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -114,10 +114,10 @@ describe('useDebounce Hook', () => {
|
||||
const { unmount, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 500 },
|
||||
});
|
||||
|
||||
|
||||
// Trigger a change, which sets a timeout
|
||||
rerender({ value: 'new value', delay: 500 });
|
||||
|
||||
|
||||
// Unmount the component before the timeout completes
|
||||
unmount();
|
||||
|
||||
@@ -132,4 +132,4 @@ describe('useDebounce Hook', () => {
|
||||
// The value should not have updated (we can't check result.current, but the key is no errors were thrown)
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,4 +18,4 @@ export function useDebounce<T>(value: T, delay: number): T {
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,4 +135,4 @@ describe('useDragAndDrop Hook', () => {
|
||||
expect(onFilesDropped).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,16 +19,22 @@ interface UseDragAndDropOptions {
|
||||
* @param options - Configuration for the hook, including the onDrop callback and disabled state.
|
||||
* @returns An object containing the `isDragging` state and props to be spread onto the dropzone element.
|
||||
*/
|
||||
export const useDragAndDrop = <T extends HTMLElement>({ onFilesDropped, disabled = false }: UseDragAndDropOptions) => {
|
||||
export const useDragAndDrop = <T extends HTMLElement>({
|
||||
onFilesDropped,
|
||||
disabled = false,
|
||||
}: UseDragAndDropOptions) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent<T>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
[disabled],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault();
|
||||
@@ -36,14 +42,17 @@ export const useDragAndDrop = <T extends HTMLElement>({ onFilesDropped, disabled
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (!disabled && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
onFilesDropped(e.dataTransfer.files);
|
||||
}
|
||||
}, [disabled, onFilesDropped]);
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<T>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (!disabled && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
onFilesDropped(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[disabled, onFilesDropped],
|
||||
);
|
||||
|
||||
// onDragOver must also be handled to allow onDrop to fire.
|
||||
const handleDragOver = handleDragEnter;
|
||||
@@ -57,4 +66,4 @@ export const useDragAndDrop = <T extends HTMLElement>({ onFilesDropped, disabled
|
||||
onDragOver: handleDragOver,
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,7 +29,14 @@ describe('useFlyerItems Hook', () => {
|
||||
});
|
||||
|
||||
const mockFlyerItems = [
|
||||
createMockFlyerItem({ flyer_item_id: 1, flyer_id: 123, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb' }),
|
||||
createMockFlyerItem({
|
||||
flyer_item_id: 1,
|
||||
flyer_id: 123,
|
||||
item: 'Apples',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: '1lb',
|
||||
}),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -60,12 +67,18 @@ describe('useFlyerItems Hook', () => {
|
||||
expect.any(Function), // the wrapped fetcher function
|
||||
[null], // dependencies array
|
||||
{ enabled: false }, // options object
|
||||
undefined // flyer_id
|
||||
undefined, // flyer_id
|
||||
);
|
||||
});
|
||||
|
||||
it('should call useApiOnMount with enabled: true when a flyer is provided', () => {
|
||||
mockedUseApiOnMount.mockReturnValue({ data: null, loading: true, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
@@ -74,12 +87,18 @@ describe('useFlyerItems Hook', () => {
|
||||
expect.any(Function),
|
||||
[mockFlyer],
|
||||
{ enabled: true },
|
||||
mockFlyer.flyer_id
|
||||
mockFlyer.flyer_id,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return isLoading: true when the inner hook is loading', () => {
|
||||
mockedUseApiOnMount.mockReturnValue({ data: null, loading: true, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
@@ -104,7 +123,13 @@ describe('useFlyerItems Hook', () => {
|
||||
|
||||
it('should return an error when the inner hook returns an error', () => {
|
||||
const mockError = new Error('Failed to fetch');
|
||||
mockedUseApiOnMount.mockReturnValue({ data: null, loading: false, error: mockError, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: mockError,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
@@ -116,18 +141,32 @@ describe('useFlyerItems Hook', () => {
|
||||
describe('wrappedFetcher behavior', () => {
|
||||
it('should reject if called with undefined flyerId', async () => {
|
||||
// We need to trigger the hook to get access to the internal wrappedFetcher
|
||||
mockedUseApiOnMount.mockReturnValue({ data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
// The first argument passed to useApiOnMount is the wrappedFetcher function
|
||||
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
||||
|
||||
|
||||
// Verify the fetcher rejects when no ID is passed (which shouldn't happen in normal flow due to 'enabled')
|
||||
await expect(wrappedFetcher(undefined)).rejects.toThrow("Cannot fetch items for an undefined flyer ID.");
|
||||
await expect(wrappedFetcher(undefined)).rejects.toThrow(
|
||||
'Cannot fetch items for an undefined flyer ID.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call apiClient.fetchFlyerItems when called with a valid ID', async () => {
|
||||
mockedUseApiOnMount.mockReturnValue({ data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
||||
@@ -135,9 +174,9 @@ describe('useFlyerItems Hook', () => {
|
||||
vi.mocked(apiClient.fetchFlyerItems).mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await wrappedFetcher(123);
|
||||
|
||||
|
||||
expect(apiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useFlyerItems = (selectedFlyer: Flyer | null) => {
|
||||
// This should not be called with undefined due to the `enabled` flag,
|
||||
// but this wrapper satisfies the type checker.
|
||||
if (flyerId === undefined) {
|
||||
return Promise.reject(new Error("Cannot fetch items for an undefined flyer ID."));
|
||||
return Promise.reject(new Error('Cannot fetch items for an undefined flyer ID.'));
|
||||
}
|
||||
return apiClient.fetchFlyerItems(flyerId);
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const useFlyerItems = (selectedFlyer: Flyer | null) => {
|
||||
wrappedFetcher,
|
||||
[selectedFlyer],
|
||||
{ enabled: !!selectedFlyer },
|
||||
selectedFlyer?.flyer_id
|
||||
selectedFlyer?.flyer_id,
|
||||
);
|
||||
return { flyerItems: data?.items || [], isLoading: loading, error };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ const mockedUseInfiniteQuery = vi.mocked(useInfiniteQuery);
|
||||
|
||||
// 3. A simple wrapper component that renders our provider.
|
||||
// This is necessary because the useFlyers hook needs to be a child of FlyersProvider.
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <FlyersProvider>{children}</FlyersProvider>;
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<FlyersProvider>{children}</FlyersProvider>
|
||||
);
|
||||
|
||||
describe('useFlyers Hook and FlyersProvider', () => {
|
||||
// Create mock functions that we can spy on to see if they are called.
|
||||
@@ -32,10 +34,12 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
||||
// Suppress the expected console.error for this test to keep the output clean.
|
||||
const originalError = console.error;
|
||||
console.error = vi.fn();
|
||||
|
||||
|
||||
// Expecting renderHook to throw an error because there's no provider.
|
||||
expect(() => renderHook(() => useFlyers())).toThrow('useFlyers must be used within a FlyersProvider');
|
||||
|
||||
expect(() => renderHook(() => useFlyers())).toThrow(
|
||||
'useFlyers must be used within a FlyersProvider',
|
||||
);
|
||||
|
||||
// Restore the original console.error function.
|
||||
console.error = originalError;
|
||||
});
|
||||
@@ -65,7 +69,13 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
||||
it('should return flyers data and hasNextPage on successful fetch', () => {
|
||||
// Arrange: Mock a successful data fetch.
|
||||
const mockFlyers: Flyer[] = [
|
||||
createMockFlyer({ flyer_id: 1, file_name: 'flyer1.jpg', image_url: 'url1', item_count: 5, created_at: '2024-01-01' }),
|
||||
createMockFlyer({
|
||||
flyer_id: 1,
|
||||
file_name: 'flyer1.jpg',
|
||||
image_url: 'url1',
|
||||
item_count: 5,
|
||||
created_at: '2024-01-01',
|
||||
}),
|
||||
];
|
||||
mockedUseInfiniteQuery.mockReturnValue({
|
||||
data: mockFlyers,
|
||||
@@ -113,7 +123,12 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
||||
it('should call fetchNextFlyersPage when the context function is invoked', () => {
|
||||
// Arrange
|
||||
mockedUseInfiniteQuery.mockReturnValue({
|
||||
data: [], isLoading: false, error: null, hasNextPage: true, isRefetching: false, isFetchingNextPage: false,
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasNextPage: true,
|
||||
isRefetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: mockFetchNextPage, // Pass the mock function
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
@@ -130,13 +145,24 @@ describe('useFlyers Hook and FlyersProvider', () => {
|
||||
|
||||
it('should call refetchFlyers when the context function is invoked', () => {
|
||||
// Arrange
|
||||
mockedUseInfiniteQuery.mockReturnValue({ data: [], isLoading: false, error: null, hasNextPage: false, isRefetching: false, isFetchingNextPage: false, fetchNextPage: mockFetchNextPage, refetch: mockRefetch });
|
||||
mockedUseInfiniteQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
hasNextPage: false,
|
||||
isRefetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
refetch: mockRefetch,
|
||||
});
|
||||
const { result } = renderHook(() => useFlyers(), { wrapper });
|
||||
|
||||
// Act
|
||||
act(() => { result.current.refetchFlyers(); });
|
||||
act(() => {
|
||||
result.current.refetchFlyers();
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,4 +8,4 @@ export const useFlyers = (): FlyersContextType => {
|
||||
throw new Error('useFlyers must be used within a FlyersProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,7 +12,10 @@ describe('useInfiniteQuery Hook', () => {
|
||||
});
|
||||
|
||||
// Helper to create a mock paginated response
|
||||
const createMockResponse = <T>(items: T[], nextCursor: number | string | null | undefined): Response => {
|
||||
const createMockResponse = <T>(
|
||||
items: T[],
|
||||
nextCursor: number | string | null | undefined,
|
||||
): Response => {
|
||||
const paginatedResponse: PaginatedResponse<T> = { items, nextCursor };
|
||||
return new Response(JSON.stringify(paginatedResponse));
|
||||
};
|
||||
@@ -110,15 +113,15 @@ describe('useInfiniteQuery Hook', () => {
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
expect(result.current.error?.message).toBe('query.limit: Limit must be a positive number; query.offset: Offset must be non-negative');
|
||||
expect(result.current.error?.message).toBe(
|
||||
'query.limit: Limit must be a positive number; query.offset: Offset must be non-negative',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a Zod-style error message where path is missing', async () => {
|
||||
const errorPayload = {
|
||||
issues: [
|
||||
{ message: 'Global error' },
|
||||
],
|
||||
issues: [{ message: 'Global error' }],
|
||||
};
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify(errorPayload), { status: 400 }));
|
||||
|
||||
@@ -132,10 +135,12 @@ describe('useInfiniteQuery Hook', () => {
|
||||
});
|
||||
|
||||
it('should handle a non-ok response with a non-JSON body', async () => {
|
||||
mockApiFunction.mockResolvedValue(new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
}));
|
||||
mockApiFunction.mockResolvedValue(
|
||||
new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
statusText: 'Server Error',
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useInfiniteQuery(mockApiFunction));
|
||||
|
||||
@@ -192,14 +197,18 @@ describe('useInfiniteQuery Hook', () => {
|
||||
|
||||
// Load first two pages
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
act(() => { result.current.fetchNextPage(); });
|
||||
act(() => {
|
||||
result.current.fetchNextPage();
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
|
||||
expect(result.current.data).toEqual([...page1Items, ...page2Items]);
|
||||
expect(mockApiFunction).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Act: call refetch
|
||||
act(() => { result.current.refetch(); });
|
||||
act(() => {
|
||||
result.current.refetch();
|
||||
});
|
||||
|
||||
// Assert: data is cleared and then repopulated with the first page
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
@@ -233,12 +242,16 @@ describe('useInfiniteQuery Hook', () => {
|
||||
expect(result.current.data).toEqual(page1Items);
|
||||
|
||||
// Try fetch next page -> fails
|
||||
act(() => { result.current.fetchNextPage(); });
|
||||
act(() => {
|
||||
result.current.fetchNextPage();
|
||||
});
|
||||
await waitFor(() => expect(result.current.error).toEqual(error));
|
||||
expect(result.current.isFetchingNextPage).toBe(false);
|
||||
|
||||
// Try fetch next page again -> succeeds, error should be cleared
|
||||
act(() => { result.current.fetchNextPage(); });
|
||||
act(() => {
|
||||
result.current.fetchNextPage();
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.isFetchingNextPage).toBe(true);
|
||||
|
||||
@@ -255,7 +268,9 @@ describe('useInfiniteQuery Hook', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.error).toEqual(error));
|
||||
|
||||
act(() => { result.current.refetch(); });
|
||||
act(() => {
|
||||
result.current.refetch();
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
@@ -280,4 +295,4 @@ describe('useInfiniteQuery Hook', () => {
|
||||
expect(result.current.error?.message).toBe('An unknown error occurred.');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ interface UseInfiniteQueryOptions {
|
||||
*/
|
||||
export function useInfiniteQuery<T>(
|
||||
apiFunction: ApiFunction,
|
||||
options: UseInfiniteQueryOptions = {}
|
||||
options: UseInfiniteQueryOptions = {},
|
||||
) {
|
||||
const { initialCursor = 0 } = options;
|
||||
|
||||
@@ -48,55 +48,67 @@ export function useInfiniteQuery<T>(
|
||||
// Use a ref to store the cursor for the next page.
|
||||
const nextCursorRef = useRef<number | string | null | undefined>(initialCursor);
|
||||
|
||||
const fetchPage = useCallback(async (cursor?: number | string | null) => {
|
||||
// Determine which loading state to set
|
||||
const isInitialLoad = cursor === initialCursor && data.length === 0;
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
setIsRefetching(false);
|
||||
} else {
|
||||
setIsFetchingNextPage(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiFunction(cursor);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (Array.isArray(errorData.issues) && errorData.issues.length > 0) {
|
||||
errorMessage = errorData.issues
|
||||
.map((issue: { path?: string[], message: string }) => `${issue.path?.join('.') || 'Error'}: ${issue.message}`)
|
||||
.join('; ');
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
}
|
||||
} catch { /* Ignore JSON parsing errors */ }
|
||||
throw new Error(errorMessage);
|
||||
const fetchPage = useCallback(
|
||||
async (cursor?: number | string | null) => {
|
||||
// Determine which loading state to set
|
||||
const isInitialLoad = cursor === initialCursor && data.length === 0;
|
||||
if (isInitialLoad) {
|
||||
setIsLoading(true);
|
||||
setIsRefetching(false);
|
||||
} else {
|
||||
setIsFetchingNextPage(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const page: PaginatedResponse<T> = await response.json();
|
||||
try {
|
||||
const response = await apiFunction(cursor);
|
||||
|
||||
// Append new items to the existing data
|
||||
setData(prevData => (cursor === initialCursor ? page.items : [...prevData, ...page.items]));
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Request failed with status ${response.status}: ${response.statusText}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (Array.isArray(errorData.issues) && errorData.issues.length > 0) {
|
||||
errorMessage = errorData.issues
|
||||
.map(
|
||||
(issue: { path?: string[]; message: string }) =>
|
||||
`${issue.path?.join('.') || 'Error'}: ${issue.message}`,
|
||||
)
|
||||
.join('; ');
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
}
|
||||
} catch {
|
||||
/* Ignore JSON parsing errors */
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Update cursor and hasNextPage status
|
||||
nextCursorRef.current = page.nextCursor;
|
||||
setHasNextPage(page.nextCursor != null);
|
||||
const page: PaginatedResponse<T> = await response.json();
|
||||
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
||||
logger.error('API call failed in useInfiniteQuery hook', { error: err.message, functionName: apiFunction.name });
|
||||
setError(err);
|
||||
notifyError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsFetchingNextPage(false);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
}, [apiFunction, initialCursor]);
|
||||
// Append new items to the existing data
|
||||
setData((prevData) =>
|
||||
cursor === initialCursor ? page.items : [...prevData, ...page.items],
|
||||
);
|
||||
|
||||
// Update cursor and hasNextPage status
|
||||
nextCursorRef.current = page.nextCursor;
|
||||
setHasNextPage(page.nextCursor != null);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
||||
logger.error('API call failed in useInfiniteQuery hook', {
|
||||
error: err.message,
|
||||
functionName: apiFunction.name,
|
||||
});
|
||||
setError(err);
|
||||
notifyError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsFetchingNextPage(false);
|
||||
setIsRefetching(false);
|
||||
}
|
||||
},
|
||||
[apiFunction, initialCursor],
|
||||
);
|
||||
|
||||
// Fetch the initial page on mount
|
||||
useEffect(() => {
|
||||
@@ -117,5 +129,14 @@ export function useInfiniteQuery<T>(
|
||||
fetchPage(initialCursor);
|
||||
}, [fetchPage, initialCursor]);
|
||||
|
||||
return { data, error, isLoading, isFetchingNextPage, isRefetching, hasNextPage, fetchNextPage, refetch };
|
||||
}
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isRefetching,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user