complete project using prettier!

This commit is contained in:
2025-12-22 09:45:14 -08:00
parent 621d30b84f
commit a10f84aa48
339 changed files with 18041 additions and 8969 deletions

View File

@@ -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('');
});
});
});
});

View File

@@ -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 };
};
};

View File

@@ -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);
});
});
});
});

View File

@@ -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],
);
};

View File

@@ -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();
});
});
});
});

View File

@@ -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 };
}
}

View File

@@ -75,4 +75,4 @@ describe('useApiOnMount', () => {
expect(result.current.error).toEqual(mockError);
});
});
});
});

View File

@@ -41,4 +41,4 @@ export function useApiOnMount<T, TArgs extends unknown[]>(
}, [execute, enabled, ...deps, ...args]);
return rest;
}
}

View File

@@ -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();
});
});
});
});

View File

@@ -16,4 +16,4 @@ export const useAuth = (): AuthContextType => {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
};

View File

@@ -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();
});
});
});

View File

@@ -18,4 +18,4 @@ export function useDebounce<T>(value: T, delay: number): T {
}, [value, delay]);
return debouncedValue;
}
}

View File

@@ -135,4 +135,4 @@ describe('useDragAndDrop Hook', () => {
expect(onFilesDropped).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -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,
},
};
};
};

View File

@@ -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);
});
});
});
});

View File

@@ -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 };
};
};

View File

@@ -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);
});
});
});

View File

@@ -8,4 +8,4 @@ export const useFlyers = (): FlyersContextType => {
throw new Error('useFlyers must be used within a FlyersProvider');
}
return context;
};
};

View File

@@ -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.');
});
});
});
});

View File

@@ -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