Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
790008ae0d | ||
|
|
b5b91eb968 | ||
| 38eb810e7a | |||
|
|
458588a6e7 | ||
| 0b4113417f | |||
|
|
b59d2a9533 | ||
| 6740b35f8a | |||
|
|
92ad82a012 | ||
| 672e4ca597 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
|
||||
@@ -268,13 +268,12 @@ describe('FlyerUploader', () => {
|
||||
|
||||
// We need at least one 'active' response to establish a timeout loop so we have something to clear
|
||||
// The second call should be a rejection, as this is how getJobStatus signals a failure.
|
||||
mockedAiApiClient.getJobStatus.mockImplementation(async () => {
|
||||
// Fail on the second call by checking the number of times the mock has been invoked.
|
||||
if (mockedAiApiClient.getJobStatus.mock.calls.length > 1) {
|
||||
throw new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR');
|
||||
}
|
||||
return { state: 'active', progress: { message: 'Working...' } } as aiApiClientModule.JobStatus;
|
||||
});
|
||||
mockedAiApiClient.getJobStatus
|
||||
.mockResolvedValueOnce({
|
||||
state: 'active',
|
||||
progress: { message: 'Working...' },
|
||||
} as aiApiClientModule.JobStatus)
|
||||
.mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR'));
|
||||
|
||||
renderComponent();
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
@@ -289,10 +288,10 @@ describe('FlyerUploader', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
|
||||
});
|
||||
|
||||
it('should clear the polling timeout when the component unmounts', async () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount timeout clearance.');
|
||||
it('should stop polling for job status when the component unmounts', async () => {
|
||||
console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount polling stop.');
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' });
|
||||
// Mock getJobStatus to always return 'active' to keep polling
|
||||
mockedAiApiClient.getJobStatus.mockResolvedValue({
|
||||
state: 'active',
|
||||
progress: { message: 'Polling...' },
|
||||
@@ -304,18 +303,29 @@ describe('FlyerUploader', () => {
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
// Wait for the first poll to complete and the UI to show the polling state
|
||||
// Wait for the first poll to complete and UI to update
|
||||
await screen.findByText('Polling...');
|
||||
|
||||
// Now that we are in a polling state (and a timeout is set), unmount the component
|
||||
console.log('--- [TEST LOG] ---: 2. Unmounting component to trigger cleanup effect.');
|
||||
// Wait for exactly one call to be sure polling has started.
|
||||
await waitFor(() => {
|
||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
console.log('--- [TEST LOG] ---: 2. First poll confirmed.');
|
||||
|
||||
// Record the number of calls before unmounting.
|
||||
const callsBeforeUnmount = mockedAiApiClient.getJobStatus.mock.calls.length;
|
||||
|
||||
// Now unmount the component, which should stop the polling.
|
||||
console.log('--- [TEST LOG] ---: 3. Unmounting component.');
|
||||
unmount();
|
||||
|
||||
// Verify that the cleanup function in the useEffect hook was called
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
console.log('--- [TEST LOG] ---: 3. clearTimeout confirmed.');
|
||||
// Wait for a duration longer than the polling interval (3s) to see if more calls are made.
|
||||
console.log('--- [TEST LOG] ---: 4. Waiting for 4 seconds to check for further polling.');
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 4000)));
|
||||
|
||||
clearTimeoutSpy.mockRestore();
|
||||
// Verify that getJobStatus was not called again after unmounting.
|
||||
console.log('--- [TEST LOG] ---: 5. Asserting no new polls occurred.');
|
||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBeforeUnmount);
|
||||
});
|
||||
|
||||
it('should handle a duplicate flyer error (409)', async () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { logger } from '../services/logger.client';
|
||||
import { notifyError } from '../services/notificationService';
|
||||
|
||||
|
||||
/**
|
||||
* A custom React hook to simplify API calls, including loading and error states.
|
||||
* It is designed to work with apiClient functions that return a `Promise<Response>`.
|
||||
|
||||
@@ -106,7 +106,10 @@ export const useFlyerUploader = () => {
|
||||
// Consolidate state derivation for the UI from the react-query hooks using useMemo.
|
||||
// This improves performance by memoizing the derived state and makes the logic easier to follow.
|
||||
const { processingState, errorMessage, duplicateFlyerId, flyerId, statusMessage } = useMemo(() => {
|
||||
// The order of these checks is critical. Errors must be checked first to override
|
||||
// any stale `jobStatus` from a previous successful poll.
|
||||
const state: ProcessingState = (() => {
|
||||
if (uploadMutation.isError || pollError) return 'error';
|
||||
if (uploadMutation.isPending) return 'uploading';
|
||||
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
|
||||
return 'polling';
|
||||
@@ -114,7 +117,6 @@ export const useFlyerUploader = () => {
|
||||
if (!jobStatus.returnValue?.flyerId) return 'error';
|
||||
return 'completed';
|
||||
}
|
||||
if (uploadMutation.isError || jobStatus?.state === 'failed' || pollError) return 'error';
|
||||
return 'idle';
|
||||
})();
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
// FIX: Stabilize the apiFunction passed to useApi.
|
||||
// By wrapping this in useCallback, we ensure the same function instance is passed to
|
||||
// useApi on every render. This prevents the `execute` function returned by `useApi`
|
||||
// from being recreated, which in turn breaks the infinite re-render loop in the useEffect below.
|
||||
// from being recreated, which in turn breaks the infinite re-render loop in the useEffect.
|
||||
const getProfileCallback = useCallback(() => apiClient.getAuthenticatedUserProfile(), []);
|
||||
|
||||
const { execute: checkTokenApi } = useApi<UserProfile, []>(getProfileCallback);
|
||||
|
||||
@@ -4,17 +4,21 @@ import { FlyersContext, FlyersContextType } from '../contexts/FlyersContext';
|
||||
import type { Flyer } from '../types';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { useInfiniteQuery } from '../hooks/useInfiniteQuery';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
// Memoize the fetch function to ensure stability for the useInfiniteQuery hook.
|
||||
const fetchFlyersFn = useCallback(apiClient.fetchFlyers, []);
|
||||
|
||||
const {
|
||||
data: flyers,
|
||||
isLoading: isLoadingFlyers,
|
||||
isLoading: isLoadingFlyers,
|
||||
error: flyersError,
|
||||
fetchNextPage: fetchNextFlyersPage,
|
||||
hasNextPage: hasNextFlyersPage,
|
||||
refetch: refetchFlyers,
|
||||
isRefetching: isRefetchingFlyers,
|
||||
} = useInfiniteQuery<Flyer>(apiClient.fetchFlyers);
|
||||
} = useInfiniteQuery<Flyer>(fetchFlyersFn);
|
||||
|
||||
const value: FlyersContextType = {
|
||||
flyers: flyers || [],
|
||||
@@ -26,5 +30,5 @@ export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children })
|
||||
refetchFlyers,
|
||||
};
|
||||
|
||||
return <FlyersContext.Provider value={value}>{children}</FlyersContext.Provider>;
|
||||
return <FlyersContext.Provider value={value}>{children}</FlyersContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
// src/providers/MasterItemsProvider.tsx
|
||||
import React, { ReactNode, useMemo } from 'react';
|
||||
import React, { ReactNode, useMemo, useEffect, useCallback } from 'react';
|
||||
import { MasterItemsContext } from '../contexts/MasterItemsContext';
|
||||
import type { MasterGroceryItem } from '../types';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import { useApiOnMount } from '../hooks/useApiOnMount';
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
export const MasterItemsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { data, loading, error } = useApiOnMount<MasterGroceryItem[], []>(() =>
|
||||
apiClient.fetchMasterItems(),
|
||||
);
|
||||
// LOGGING: Check if the provider is unmounting/remounting repeatedly
|
||||
useEffect(() => {
|
||||
logger.debug('MasterItemsProvider: MOUNTED');
|
||||
return () => logger.debug('MasterItemsProvider: UNMOUNTED');
|
||||
}, []);
|
||||
|
||||
// Memoize the fetch function to ensure stability for the useApiOnMount hook.
|
||||
const fetchFn = useCallback(() => apiClient.fetchMasterItems(), []);
|
||||
|
||||
const { data, loading, error } = useApiOnMount<MasterGroceryItem[], []>(fetchFn);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/providers/UserDataProvider.tsx
|
||||
import React, { useState, useEffect, useMemo, ReactNode } from 'react';
|
||||
import { logger } from '../services/logger.client';
|
||||
import React, { useState, useEffect, useMemo, ReactNode, useCallback } from 'react';
|
||||
import { UserDataContext } from '../contexts/UserDataContext';
|
||||
import type { MasterGroceryItem, ShoppingList } from '../types';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
@@ -9,18 +10,25 @@ import { useAuth } from '../hooks/useAuth';
|
||||
export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { userProfile } = useAuth();
|
||||
|
||||
// Wrap the API calls in useCallback to prevent unnecessary re-renders.
|
||||
const fetchWatchedItemsFn = useCallback(
|
||||
() => apiClient.fetchWatchedItems(),
|
||||
[],
|
||||
);
|
||||
const fetchShoppingListsFn = useCallback(() => apiClient.fetchShoppingLists(), []);
|
||||
|
||||
const {
|
||||
data: watchedItemsData,
|
||||
loading: isLoadingWatched,
|
||||
error: watchedItemsError,
|
||||
} = useApiOnMount<MasterGroceryItem[], []>(() => apiClient.fetchWatchedItems(), [userProfile], {
|
||||
} = useApiOnMount<MasterGroceryItem[], []>(fetchWatchedItemsFn, [userProfile], {
|
||||
enabled: !!userProfile,
|
||||
});
|
||||
const {
|
||||
data: shoppingListsData,
|
||||
loading: isLoadingShoppingLists,
|
||||
loading: isLoadingShoppingLists,
|
||||
error: shoppingListsError,
|
||||
} = useApiOnMount<ShoppingList[], []>(() => apiClient.fetchShoppingLists(), [userProfile], {
|
||||
} = useApiOnMount<ShoppingList[], []>(fetchShoppingListsFn, [userProfile], {
|
||||
enabled: !!userProfile,
|
||||
});
|
||||
|
||||
@@ -32,7 +40,7 @@ export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }
|
||||
useEffect(() => {
|
||||
// When the user logs out (user becomes null), immediately clear all user-specific data.
|
||||
// This also serves to clear out old data when a new user logs in, before their new data arrives.
|
||||
if (!userProfile) {
|
||||
if (!userProfile) {
|
||||
setWatchedItems([]);
|
||||
setShoppingLists([]);
|
||||
return;
|
||||
@@ -60,7 +68,7 @@ export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }
|
||||
watchedItemsError,
|
||||
shoppingListsError,
|
||||
],
|
||||
);
|
||||
);
|
||||
|
||||
return <UserDataContext.Provider value={value}>{children}</UserDataContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,12 @@ router.get(
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// LOGGING: Track how often this heavy DB call is actually made vs served from cache
|
||||
req.log.info('Fetching master items list from database...');
|
||||
|
||||
// Optimization: This list changes rarely. Instruct clients to cache it for 1 hour (3600s).
|
||||
res.set('Cache-Control', 'public, max-age=3600');
|
||||
|
||||
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
|
||||
res.json(masterItems);
|
||||
} catch (error) {
|
||||
|
||||
@@ -283,7 +283,10 @@ export const fetchFlyerById = (flyerId: number): Promise<Response> =>
|
||||
* Fetches all master grocery items from the backend.
|
||||
* @returns A promise that resolves to an array of MasterGroceryItem objects.
|
||||
*/
|
||||
export const fetchMasterItems = (): Promise<Response> => publicGet('/personalization/master-items');
|
||||
export const fetchMasterItems = (): Promise<Response> => {
|
||||
logger.debug('apiClient: fetchMasterItems called');
|
||||
return publicGet('/personalization/master-items');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all categories from the backend.
|
||||
|
||||
@@ -133,6 +133,12 @@ export class FlyerProcessingService {
|
||||
return { flyerId: flyer.flyer_id };
|
||||
} catch (error) {
|
||||
logger.warn('Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.');
|
||||
// Add detailed logging of the raw error object
|
||||
if (error instanceof Error) {
|
||||
logger.error({ err: error, stack: error.stack }, 'Raw error object in processJob catch block');
|
||||
} else {
|
||||
logger.error({ error }, 'Raw non-Error object in processJob catch block');
|
||||
}
|
||||
// This private method handles error reporting and re-throwing.
|
||||
await this._reportErrorAndThrow(error, job, logger, stages);
|
||||
// This line is technically unreachable because the above method always throws,
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
let jobStatus;
|
||||
const maxRetries = 30; // Poll for up to 90 seconds (30 * 3s)
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
console.log(`Polling attempt ${i + 1}...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
|
||||
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
|
||||
if (token) {
|
||||
@@ -89,6 +90,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
}
|
||||
const statusResponse = await statusReq;
|
||||
jobStatus = statusResponse.body;
|
||||
console.log(`Job status: ${JSON.stringify(jobStatus)}`);
|
||||
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ describe('Public API Routes Integration Tests', () => {
|
||||
email: userEmail,
|
||||
password: 'a-Very-Strong-Password-123!',
|
||||
fullName: 'Public Routes Test User',
|
||||
request,
|
||||
});
|
||||
testUser = createdUser;
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ export const cleanupDb = async (ids: TestResourceIds) => {
|
||||
await pool.query('DELETE FROM public.user_watched_items WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.user_achievements WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.activity_log WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.user_refresh_tokens WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
await pool.query('DELETE FROM public.password_reset_tokens WHERE user_id = ANY($1::uuid[])', [userIds]);
|
||||
}
|
||||
|
||||
// --- Stage 2: Delete parent records that other things depend on ---
|
||||
|
||||
Reference in New Issue
Block a user