Compare commits

..

24 Commits

Author SHA1 Message Date
Gitea Actions
ecec686347 ci: Bump version to 0.7.5 [skip ci] 2025-12-31 22:27:56 +05:00
86de680080 flyer processing fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m36s
2025-12-31 09:27:06 -08:00
Gitea Actions
0371947065 ci: Bump version to 0.7.4 [skip ci] 2025-12-31 22:03:02 +05:00
296698758c flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m20s
2025-12-31 09:02:09 -08:00
Gitea Actions
18c1161587 ci: Bump version to 0.7.3 [skip ci] 2025-12-31 15:09:29 +05:00
0010396780 flyer upload (anon) issues
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2025-12-31 02:08:37 -08:00
Gitea Actions
d4557e13fb ci: Bump version to 0.7.2 [skip ci] 2025-12-31 13:32:58 +05:00
3e41130c69 again
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m59s
2025-12-31 00:31:18 -08:00
Gitea Actions
d9034563d6 ci: Bump version to 0.7.1 [skip ci] 2025-12-31 13:21:54 +05:00
5836a75157 flyer upload (anon) issues
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 42s
2025-12-31 00:21:19 -08:00
Gitea Actions
790008ae0d ci: Bump version to 0.7.0 for production release [skip ci] 2025-12-31 12:43:41 +05:00
Gitea Actions
b5b91eb968 ci: Bump version to 0.6.6 [skip ci] 2025-12-31 12:29:43 +05:00
38eb810e7a logging the frontend loop
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m55s
2025-12-30 23:28:38 -08:00
Gitea Actions
458588a6e7 ci: Bump version to 0.6.5 [skip ci] 2025-12-31 11:34:23 +05:00
0b4113417f flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
2025-12-30 22:33:55 -08:00
Gitea Actions
b59d2a9533 ci: Bump version to 0.6.4 [skip ci] 2025-12-31 11:11:53 +05:00
6740b35f8a flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m52s
2025-12-30 22:11:21 -08:00
Gitea Actions
92ad82a012 ci: Bump version to 0.6.3 [skip ci] 2025-12-31 10:54:15 +05:00
672e4ca597 flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
2025-12-30 21:53:36 -08:00
Gitea Actions
e4d70a9b37 ci: Bump version to 0.6.2 [skip ci] 2025-12-31 10:31:41 +05:00
c30f1c4162 flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m55s
2025-12-30 21:30:55 -08:00
Gitea Actions
44062a9f5b ci: Bump version to 0.6.1 [skip ci] 2025-12-31 09:52:26 +05:00
17fac8cf86 flyer upload (anon) issues
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m1s
2025-12-30 20:44:34 -08:00
Gitea Actions
9fa8553486 ci: Bump version to 0.6.0 for production release [skip ci] 2025-12-31 09:04:20 +05:00
34 changed files with 868 additions and 223 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.5.5", "version": "0.7.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "flyer-crawler", "name": "flyer-crawler",
"version": "0.5.5", "version": "0.7.5",
"dependencies": { "dependencies": {
"@bull-board/api": "^6.14.2", "@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2", "@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{ {
"name": "flyer-crawler", "name": "flyer-crawler",
"private": true, "private": true,
"version": "0.5.5", "version": "0.7.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"", "dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -263,14 +263,16 @@ describe('FlyerUploader', () => {
}); });
it('should clear the polling timeout when a job fails', async () => { it('should clear the polling timeout when a job fails', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
console.log('--- [TEST LOG] ---: 1. Setting up mocks for failed job timeout clearance.'); console.log('--- [TEST LOG] ---: 1. Setting up mocks for failed job timeout clearance.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' }); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' });
// We need at least one 'active' response to establish a timeout loop so we have something to clear // 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. // The second call should be a rejection, as this is how getJobStatus signals a failure.
mockedAiApiClient.getJobStatus mockedAiApiClient.getJobStatus
.mockResolvedValueOnce({ state: 'active', progress: { message: 'Working...' } }) .mockResolvedValueOnce({
state: 'active',
progress: { message: 'Working...' },
} as aiApiClientModule.JobStatus)
.mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR')); .mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR'));
renderComponent(); renderComponent();
@@ -284,23 +286,12 @@ describe('FlyerUploader', () => {
// Wait for the failure UI // Wait for the failure UI
await waitFor(() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 }); await waitFor(() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 });
// Verify clearTimeout was called
expect(clearTimeoutSpy).toHaveBeenCalled();
// Verify no further polling occurs
const callsBefore = mockedAiApiClient.getJobStatus.mock.calls.length;
// Wait for a duration longer than the polling interval
await act(() => new Promise((r) => setTimeout(r, 4000)));
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBefore);
clearTimeoutSpy.mockRestore();
}); });
it('should clear the polling timeout when the component unmounts', async () => { it('should stop polling for job status when the component unmounts', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount polling stop.');
console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount timeout clearance.');
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' }); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' });
// Mock getJobStatus to always return 'active' to keep polling
mockedAiApiClient.getJobStatus.mockResolvedValue({ mockedAiApiClient.getJobStatus.mockResolvedValue({
state: 'active', state: 'active',
progress: { message: 'Polling...' }, progress: { message: 'Polling...' },
@@ -312,26 +303,38 @@ describe('FlyerUploader', () => {
fireEvent.change(input, { target: { files: [file] } }); 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...'); await screen.findByText('Polling...');
// Now that we are in a polling state (and a timeout is set), unmount the component // Wait for exactly one call to be sure polling has started.
console.log('--- [TEST LOG] ---: 2. Unmounting component to trigger cleanup effect.'); 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(); unmount();
// Verify that the cleanup function in the useEffect hook was called // Wait for a duration longer than the polling interval (3s) to see if more calls are made.
expect(clearTimeoutSpy).toHaveBeenCalled(); console.log('--- [TEST LOG] ---: 4. Waiting for 4 seconds to check for further polling.');
console.log('--- [TEST LOG] ---: 3. clearTimeout confirmed.'); 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 () => { it('should handle a duplicate flyer error (409)', async () => {
console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.'); console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.');
// The API client now throws a structured error for non-2xx responses. // The API client throws a structured error, which useFlyerUploader now parses
// to set both the errorMessage and the duplicateFlyerId.
mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({ mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({
status: 409, status: 409,
body: { flyerId: 99, message: 'Duplicate' }, body: { flyerId: 99, message: 'This flyer has already been processed.' },
}); });
console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.');
@@ -345,9 +348,10 @@ describe('FlyerUploader', () => {
try { try {
console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...'); console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...');
expect( // With the fix, the duplicate error message and the link are combined into a single paragraph.
await screen.findByText(/This flyer has already been processed/i), // We now look for this combined message.
).toBeInTheDocument(); const errorMessage = await screen.findByText(/This flyer has already been processed. You can view it here:/i);
expect(errorMessage).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.'); console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.');
} catch (error) { } catch (error) {
console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.'); console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.');

View File

@@ -30,6 +30,12 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`); if (statusMessage) logger.info(`FlyerUploader Status: ${statusMessage}`);
}, [statusMessage]); }, [statusMessage]);
useEffect(() => {
if (errorMessage) {
logger.error(`[FlyerUploader] Error encountered: ${errorMessage}`, { duplicateFlyerId });
}
}, [errorMessage, duplicateFlyerId]);
// Handle completion and navigation // Handle completion and navigation
useEffect(() => { useEffect(() => {
if (processingState === 'completed' && flyerId) { if (processingState === 'completed' && flyerId) {
@@ -94,14 +100,15 @@ export const FlyerUploader: React.FC<FlyerUploaderProps> = ({ onProcessingComple
{errorMessage && ( {errorMessage && (
<div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md"> <div className="text-red-600 dark:text-red-400 font-semibold p-4 bg-red-100 dark:bg-red-900/30 rounded-md">
<p>{errorMessage}</p> {duplicateFlyerId ? (
{duplicateFlyerId && (
<p> <p>
This flyer has already been processed. You can view it here:{' '} {errorMessage} You can view it here:{' '}
<Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline" data-discover="true"> <Link to={`/flyers/${duplicateFlyerId}`} className="text-blue-500 underline" data-discover="true">
Flyer #{duplicateFlyerId} Flyer #{duplicateFlyerId}
</Link> </Link>
</p> </p>
) : (
<p>{errorMessage}</p>
)} )}
</div> </div>
)} )}

View File

@@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { logger } from '../services/logger.client'; import { logger } from '../services/logger.client';
import { notifyError } from '../services/notificationService'; import { notifyError } from '../services/notificationService';
/** /**
* A custom React hook to simplify API calls, including loading and error states. * 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>`. * It is designed to work with apiClient functions that return a `Promise<Response>`.
@@ -29,6 +30,14 @@ export function useApi<T, TArgs extends unknown[]>(
const lastErrorMessageRef = useRef<string | null>(null); const lastErrorMessageRef = useRef<string | null>(null);
const abortControllerRef = useRef<AbortController>(new AbortController()); const abortControllerRef = useRef<AbortController>(new AbortController());
// Use a ref to track the latest apiFunction. This allows us to keep `execute` stable
// even if `apiFunction` is recreated on every render (common with inline arrow functions).
const apiFunctionRef = useRef(apiFunction);
useEffect(() => {
apiFunctionRef.current = apiFunction;
}, [apiFunction]);
// This effect ensures that when the component using the hook unmounts, // This effect ensures that when the component using the hook unmounts,
// any in-flight request is cancelled. // any in-flight request is cancelled.
useEffect(() => { useEffect(() => {
@@ -59,7 +68,7 @@ export function useApi<T, TArgs extends unknown[]>(
} }
try { try {
const response = await apiFunction(...args, abortControllerRef.current.signal); const response = await apiFunctionRef.current(...args, abortControllerRef.current.signal);
if (!response.ok) { if (!response.ok) {
// Attempt to parse a JSON error response. This is aligned with ADR-003, // Attempt to parse a JSON error response. This is aligned with ADR-003,
@@ -98,7 +107,17 @@ export function useApi<T, TArgs extends unknown[]>(
} }
return result; return result;
} catch (e) { } catch (e) {
const err = e instanceof Error ? e : new Error('An unknown error occurred.'); let err: Error;
if (e instanceof Error) {
err = e;
} else if (typeof e === 'object' && e !== null && 'status' in e) {
// Handle structured errors (e.g. { status: 409, body: { ... } })
const structuredError = e as { status: number; body?: { message?: string } };
const message = structuredError.body?.message || `Request failed with status ${structuredError.status}`;
err = new Error(message);
} else {
err = 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 the error is an AbortError, it's an intentional cancellation, so we don't set an error state.
if (err.name === 'AbortError') { if (err.name === 'AbortError') {
logger.info('API request was cancelled.', { functionName: apiFunction.name }); logger.info('API request was cancelled.', { functionName: apiFunction.name });
@@ -122,7 +141,7 @@ export function useApi<T, TArgs extends unknown[]>(
setIsRefetching(false); setIsRefetching(false);
} }
}, },
[apiFunction], [], // execute is now stable because it uses apiFunctionRef
); // abortControllerRef is stable ); // abortControllerRef is stable
return { execute, loading, isRefetching, error, data, reset }; return { execute, loading, isRefetching, error, data, reset };

View File

@@ -1,6 +1,6 @@
// src/hooks/useFlyerUploader.ts // src/hooks/useFlyerUploader.ts
// src/hooks/useFlyerUploader.ts // src/hooks/useFlyerUploader.ts
import { useState, useCallback } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
uploadAndProcessFlyer, uploadAndProcessFlyer,
@@ -14,6 +14,28 @@ import type { ProcessingStage } from '../types';
export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error'; export type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error';
// Define a type for the structured error thrown by the API client
interface ApiError {
status: number;
body: {
message: string;
flyerId?: number;
};
}
// Type guard to check if an error is a structured API error
function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof (error as { status: unknown }).status === 'number' &&
'body' in error &&
typeof (error as { body: unknown }).body === 'object' &&
(error as { body: unknown }).body !== null &&
'message' in ((error as { body: unknown }).body as object)
);
}
export const useFlyerUploader = () => { export const useFlyerUploader = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [jobId, setJobId] = useState<string | null>(null); const [jobId, setJobId] = useState<string | null>(null);
@@ -81,40 +103,57 @@ export const useFlyerUploader = () => {
queryClient.removeQueries({ queryKey: ['jobStatus'] }); queryClient.removeQueries({ queryKey: ['jobStatus'] });
}, [uploadMutation, queryClient]); }, [uploadMutation, queryClient]);
// Consolidate state for the UI from the react-query hooks // Consolidate state derivation for the UI from the react-query hooks using useMemo.
const processingState = ((): ProcessingState => { // 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 (uploadMutation.isPending) return 'uploading';
if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting')) if (jobStatus && (jobStatus.state === 'active' || jobStatus.state === 'waiting'))
return 'polling'; return 'polling';
if (jobStatus?.state === 'completed') { if (jobStatus?.state === 'completed') {
// If the job is complete but didn't return a flyerId, it's an error state. if (!jobStatus.returnValue?.flyerId) return 'error';
if (!jobStatus.returnValue?.flyerId) {
return 'error';
}
return 'completed'; return 'completed';
} }
if (uploadMutation.isError || jobStatus?.state === 'failed' || pollError) return 'error';
return 'idle'; return 'idle';
})(); })();
const getErrorMessage = () => { let msg: string | null = null;
const uploadError = uploadMutation.error as any; let dupId: number | null = null;
if (uploadMutation.isError) {
return uploadError?.body?.message || uploadError?.message || 'Upload failed.';
}
if (pollError) return `Polling failed: ${pollError.message}`;
if (jobStatus?.state === 'failed') {
return `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason}`;
}
if (jobStatus?.state === 'completed' && !jobStatus.returnValue?.flyerId) {
return 'Job completed but did not return a flyer ID.';
}
return null;
};
const errorMessage = getErrorMessage(); if (state === 'error') {
const duplicateFlyerId = (uploadMutation.error as any)?.body?.flyerId ?? null; if (uploadMutation.isError) {
const flyerId = jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId : null; const uploadError = uploadMutation.error;
if (isApiError(uploadError)) {
msg = uploadError.body.message;
// Specifically handle 409 Conflict for duplicate flyers
if (uploadError.status === 409) {
dupId = uploadError.body.flyerId ?? null;
}
} else if (uploadError instanceof Error) {
msg = uploadError.message;
} else {
msg = 'An unknown upload error occurred.';
}
} else if (pollError) {
msg = `Polling failed: ${pollError.message}`;
} else if (jobStatus?.state === 'failed') {
msg = `Processing failed: ${jobStatus.progress?.message || jobStatus.failedReason || 'Unknown reason'}`;
} else if (jobStatus?.state === 'completed' && !jobStatus.returnValue?.flyerId) {
msg = 'Job completed but did not return a flyer ID.';
}
}
return {
processingState: state,
errorMessage: msg,
duplicateFlyerId: dupId,
flyerId: jobStatus?.state === 'completed' ? jobStatus.returnValue?.flyerId ?? null : null,
statusMessage: uploadMutation.isPending ? 'Uploading file...' : jobStatus?.progress?.message,
};
}, [uploadMutation, jobStatus, pollError]);
return { return {
processingState, processingState,

View File

@@ -15,7 +15,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// FIX: Stabilize the apiFunction passed to useApi. // FIX: Stabilize the apiFunction passed to useApi.
// By wrapping this in useCallback, we ensure the same function instance is passed to // 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` // 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 getProfileCallback = useCallback(() => apiClient.getAuthenticatedUserProfile(), []);
const { execute: checkTokenApi } = useApi<UserProfile, []>(getProfileCallback); const { execute: checkTokenApi } = useApi<UserProfile, []>(getProfileCallback);

View File

@@ -4,8 +4,12 @@ import { FlyersContext, FlyersContextType } from '../contexts/FlyersContext';
import type { Flyer } from '../types'; import type { Flyer } from '../types';
import * as apiClient from '../services/apiClient'; import * as apiClient from '../services/apiClient';
import { useInfiniteQuery } from '../hooks/useInfiniteQuery'; import { useInfiniteQuery } from '../hooks/useInfiniteQuery';
import { useCallback } from 'react';
export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 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 { const {
data: flyers, data: flyers,
isLoading: isLoadingFlyers, isLoading: isLoadingFlyers,
@@ -14,7 +18,7 @@ export const FlyersProvider: React.FC<{ children: ReactNode }> = ({ children })
hasNextPage: hasNextFlyersPage, hasNextPage: hasNextFlyersPage,
refetch: refetchFlyers, refetch: refetchFlyers,
isRefetching: isRefetchingFlyers, isRefetching: isRefetchingFlyers,
} = useInfiniteQuery<Flyer>(apiClient.fetchFlyers); } = useInfiniteQuery<Flyer>(fetchFlyersFn);
const value: FlyersContextType = { const value: FlyersContextType = {
flyers: flyers || [], flyers: flyers || [],

View File

@@ -1,14 +1,22 @@
// src/providers/MasterItemsProvider.tsx // src/providers/MasterItemsProvider.tsx
import React, { ReactNode, useMemo } from 'react'; import React, { ReactNode, useMemo, useEffect, useCallback } from 'react';
import { MasterItemsContext } from '../contexts/MasterItemsContext'; import { MasterItemsContext } from '../contexts/MasterItemsContext';
import type { MasterGroceryItem } from '../types'; import type { MasterGroceryItem } from '../types';
import * as apiClient from '../services/apiClient'; import * as apiClient from '../services/apiClient';
import { useApiOnMount } from '../hooks/useApiOnMount'; import { useApiOnMount } from '../hooks/useApiOnMount';
import { logger } from '../services/logger.client';
export const MasterItemsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const MasterItemsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { data, loading, error } = useApiOnMount<MasterGroceryItem[], []>(() => // LOGGING: Check if the provider is unmounting/remounting repeatedly
apiClient.fetchMasterItems(), 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( const value = useMemo(
() => ({ () => ({

View File

@@ -1,5 +1,6 @@
// src/providers/UserDataProvider.tsx // 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 { UserDataContext } from '../contexts/UserDataContext';
import type { MasterGroceryItem, ShoppingList } from '../types'; import type { MasterGroceryItem, ShoppingList } from '../types';
import * as apiClient from '../services/apiClient'; import * as apiClient from '../services/apiClient';
@@ -9,18 +10,25 @@ import { useAuth } from '../hooks/useAuth';
export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const UserDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { userProfile } = useAuth(); 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 { const {
data: watchedItemsData, data: watchedItemsData,
loading: isLoadingWatched, loading: isLoadingWatched,
error: watchedItemsError, error: watchedItemsError,
} = useApiOnMount<MasterGroceryItem[], []>(() => apiClient.fetchWatchedItems(), [userProfile], { } = useApiOnMount<MasterGroceryItem[], []>(fetchWatchedItemsFn, [userProfile], {
enabled: !!userProfile, enabled: !!userProfile,
}); });
const { const {
data: shoppingListsData, data: shoppingListsData,
loading: isLoadingShoppingLists, loading: isLoadingShoppingLists,
error: shoppingListsError, error: shoppingListsError,
} = useApiOnMount<ShoppingList[], []>(() => apiClient.fetchShoppingLists(), [userProfile], { } = useApiOnMount<ShoppingList[], []>(fetchShoppingListsFn, [userProfile], {
enabled: !!userProfile, enabled: !!userProfile,
}); });

View File

@@ -1,7 +1,8 @@
// src/routes/admin.content.routes.test.ts // src/routes/admin.content.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express'; import type { Request, Response, NextFunction } from 'express';
import path from 'path';
import { import {
createMockUserProfile, createMockUserProfile,
createMockSuggestedCorrection, createMockSuggestedCorrection,
@@ -15,6 +16,7 @@ import type { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from
import { NotFoundError } from '../services/db/errors.db'; // This can stay, it's a type/class not a module with side effects. import { NotFoundError } from '../services/db/errors.db'; // This can stay, it's a type/class not a module with side effects.
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
import { cleanupFiles } from '../tests/utils/cleanupFiles';
// Mock the file upload middleware to allow testing the controller's internal check // Mock the file upload middleware to allow testing the controller's internal check
vi.mock('../middleware/fileUpload.middleware', () => ({ vi.mock('../middleware/fileUpload.middleware', () => ({
@@ -140,6 +142,26 @@ describe('Admin Content Management Routes (/api/admin)', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterAll(async () => {
// Safeguard to clean up any logo files created during tests.
const uploadDir = path.resolve(__dirname, '../../../flyer-images');
try {
const allFiles = await fs.readdir(uploadDir);
// Files are named like 'logoImage-timestamp-original.ext'
const testFiles = allFiles
.filter((f) => f.startsWith('logoImage-'))
.map((f) => path.join(uploadDir, f));
if (testFiles.length > 0) {
await cleanupFiles(testFiles);
}
} catch (error) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error during admin content test file cleanup:', error);
}
}
});
describe('Corrections Routes', () => { describe('Corrections Routes', () => {
it('GET /corrections should return corrections data', async () => { it('GET /corrections should return corrections data', async () => {
const mockCorrections: SuggestedCorrection[] = [ const mockCorrections: SuggestedCorrection[] = [

View File

@@ -165,6 +165,38 @@ describe('Auth Routes (/api/auth)', () => {
); );
}); });
it('should allow registration with an empty string for avatar_url', async () => {
// Arrange
const email = 'avatar-user@test.com';
const mockNewUser = createMockUserProfile({
user: { user_id: 'avatar-user-id', email },
});
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: mockNewUser,
accessToken: 'avatar-access-token',
refreshToken: 'avatar-refresh-token',
});
// Act
const response = await supertest(app).post('/api/auth/register').send({
email,
password: strongPassword,
full_name: 'Avatar User',
avatar_url: '', // Send an empty string
});
// Assert
expect(response.status).toBe(201);
expect(response.body.message).toBe('User registered successfully!');
expect(mockedAuthService.registerAndLoginUser).toHaveBeenCalledWith(
email,
strongPassword,
'Avatar User',
undefined, // The preprocess step in the Zod schema should convert '' to undefined
mockLogger,
);
});
it('should set a refresh token cookie on successful registration', async () => { it('should set a refresh token cookie on successful registration', async () => {
const mockNewUser = createMockUserProfile({ const mockNewUser = createMockUserProfile({
user: { user_id: 'new-user-id', email: 'cookie@test.com' }, user: { user_id: 'new-user-id', email: 'cookie@test.com' },

View File

@@ -51,7 +51,11 @@ const registerSchema = z.object({
}), }),
// Sanitize optional string inputs. // Sanitize optional string inputs.
full_name: z.string().trim().optional(), full_name: z.string().trim().optional(),
avatar_url: z.string().trim().url().optional(), // Allow empty string or valid URL. If empty string is received, convert to undefined.
avatar_url: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().trim().url().optional(),
),
}), }),
}); });

View File

@@ -19,6 +19,12 @@ router.get(
validateRequest(emptySchema), validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { 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); const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems); res.json(masterItems);
} catch (error) { } catch (error) {

View File

@@ -1,7 +1,8 @@
// src/routes/user.routes.test.ts // src/routes/user.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import express from 'express'; import express from 'express';
import path from 'path';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { import {
createMockUserProfile, createMockUserProfile,
@@ -19,6 +20,7 @@ import { Appliance, Notification, DietaryRestriction } from '../types';
import { ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db'; import { ForeignKeyConstraintError, NotFoundError, ValidationError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { cleanupFiles } from '../tests/utils/cleanupFiles';
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
import { userService } from '../services/userService'; import { userService } from '../services/userService';
@@ -166,6 +168,26 @@ describe('User Routes (/api/users)', () => {
beforeEach(() => { beforeEach(() => {
// All tests in this block will use the authenticated app // All tests in this block will use the authenticated app
}); });
afterAll(async () => {
// Safeguard to clean up any avatar files created during tests.
const uploadDir = path.resolve(__dirname, '../../../uploads/avatars');
try {
const allFiles = await fs.readdir(uploadDir);
// Files are named like 'avatar-user-123-timestamp.ext'
const testFiles = allFiles
.filter((f) => f.startsWith(`avatar-${mockUserProfile.user.user_id}`))
.map((f) => path.join(uploadDir, f));
if (testFiles.length > 0) {
await cleanupFiles(testFiles);
}
} catch (error) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error during user routes test file cleanup:', error);
}
}
});
describe('GET /profile', () => { describe('GET /profile', () => {
it('should return the full user profile', async () => { it('should return the full user profile', async () => {
vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile); vi.mocked(db.userRepo.findUserProfileById).mockResolvedValue(mockUserProfile);
@@ -563,6 +585,27 @@ describe('User Routes (/api/users)', () => {
expect(response.body).toEqual(updatedProfile); expect(response.body).toEqual(updatedProfile);
}); });
it('should allow updating the profile with an empty string for avatar_url', async () => {
// Arrange
const profileUpdates = { avatar_url: '' };
// The service should receive `undefined` after Zod preprocessing
const updatedProfile = createMockUserProfile({ ...mockUserProfile, avatar_url: undefined });
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(updatedProfile);
// Act
const response = await supertest(app).put('/api/users/profile').send(profileUpdates);
// Assert
expect(response.status).toBe(200);
expect(response.body).toEqual(updatedProfile);
// Verify that the Zod schema preprocessed the empty string to undefined
expect(db.userRepo.updateUserProfile).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
{ avatar_url: undefined },
expectLogger,
);
});
it('should return 500 on a generic database error', async () => { it('should return 500 on a generic database error', async () => {
const dbError = new Error('DB Connection Failed'); const dbError = new Error('DB Connection Failed');
vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError); vi.mocked(db.userRepo.updateUserProfile).mockRejectedValue(dbError);

View File

@@ -26,7 +26,13 @@ const router = express.Router();
const updateProfileSchema = z.object({ const updateProfileSchema = z.object({
body: z body: z
.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() }) .object({
full_name: z.string().optional(),
avatar_url: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().trim().url().optional(),
),
})
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
message: 'At least one field to update must be provided.', message: 'At least one field to update must be provided.',
}), }),

View File

@@ -6,12 +6,13 @@ import type { FlyerStatus, MasterGroceryItem, UserProfile } from '../types';
// Import the class, not the singleton instance, so we can instantiate it with mocks. // Import the class, not the singleton instance, so we can instantiate it with mocks.
import { import {
AIService, AIService,
AiFlyerDataSchema,
aiService as aiServiceSingleton, aiService as aiServiceSingleton,
DuplicateFlyerError, DuplicateFlyerError,
type RawFlyerItem,
} from './aiService.server'; } from './aiService.server';
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories'; import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
import { ValidationError } from './db/errors.db'; import { ValidationError } from './db/errors.db';
import { AiFlyerDataSchema } from '../types/ai';
// Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests. // Mock the logger to prevent the real pino instance from being created, which causes issues with 'pino-pretty' in tests.
vi.mock('./logger.server', () => ({ vi.mock('./logger.server', () => ({
@@ -1058,4 +1059,56 @@ describe('AI Service (Server)', () => {
expect(aiServiceSingleton).toBeInstanceOf(AIService); expect(aiServiceSingleton).toBeInstanceOf(AIService);
}); });
}); });
describe('_normalizeExtractedItems (private method)', () => {
it('should correctly normalize items with null or undefined price_in_cents', () => {
const rawItems: RawFlyerItem[] = [
{
item: 'Valid Item',
price_display: '$1.99',
price_in_cents: 199,
quantity: '1',
category_name: 'Category A',
master_item_id: 1,
},
{
item: 'Item with Null Price',
price_display: null,
price_in_cents: null, // Test case for null
quantity: '1',
category_name: 'Category B',
master_item_id: 2,
},
{
item: 'Item with Undefined Price',
price_display: '$2.99',
price_in_cents: undefined, // Test case for undefined
quantity: '1',
category_name: 'Category C',
master_item_id: 3,
},
{
item: null, // Test null item name
price_display: undefined, // Test undefined display price
price_in_cents: 50,
quantity: null, // Test null quantity
category_name: undefined, // Test undefined category
master_item_id: null, // Test null master_item_id
},
];
// Access the private method for testing
const normalized = (aiServiceInstance as any)._normalizeExtractedItems(rawItems);
expect(normalized).toHaveLength(4);
expect(normalized[0].price_in_cents).toBe(199);
expect(normalized[1].price_in_cents).toBe(null); // null should remain null
expect(normalized[2].price_in_cents).toBe(null); // undefined should become null
expect(normalized[3].item).toBe('Unknown Item');
expect(normalized[3].quantity).toBe('');
expect(normalized[3].category_name).toBe('Other/Miscellaneous');
expect(normalized[3].master_item_id).toBeUndefined(); // nullish coalescing to undefined
});
});
}); });

View File

@@ -4,7 +4,6 @@
* It is intended to be used only by the backend (e.g., server.ts) and should never be imported into client-side code. * It is intended to be used only by the backend (e.g., server.ts) and should never be imported into client-side code.
* The `.server.ts` naming convention helps enforce this separation. * The `.server.ts` naming convention helps enforce this separation.
*/ */
import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai'; import { GoogleGenAI, type GenerateContentResponse, type Content, type Tool } from '@google/genai';
import fsPromises from 'node:fs/promises'; import fsPromises from 'node:fs/promises';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
@@ -26,29 +25,11 @@ import type { Job } from 'bullmq';
import { createFlyerAndItems } from './db/flyer.db'; import { createFlyerAndItems } from './db/flyer.db';
import { generateFlyerIcon } from '../utils/imageProcessor'; import { generateFlyerIcon } from '../utils/imageProcessor';
import path from 'path'; import path from 'path';
import { ValidationError } from './db/errors.db'; import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError
import {
// Helper for consistent required string validation (handles missing/null/empty) AiFlyerDataSchema,
const requiredString = (message: string) => ExtractedFlyerItemSchema,
z.preprocess((val) => val ?? '', z.string().min(1, message)); } from '../types/ai'; // Import consolidated schemas
// --- Zod Schemas for AI Response Validation (exported for the transformer) ---
const ExtractedFlyerItemSchema = z.object({
item: z.string(),
price_display: z.string(),
price_in_cents: z.number().nullable(),
quantity: z.string(),
category_name: z.string(),
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
export const AiFlyerDataSchema = z.object({
store_name: requiredString('Store name cannot be empty'),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});
interface FlyerProcessPayload extends Partial<ExtractedCoreData> { interface FlyerProcessPayload extends Partial<ExtractedCoreData> {
checksum?: string; checksum?: string;
@@ -89,10 +70,10 @@ interface IAiClient {
* This type is intentionally loose to accommodate potential null/undefined values * This type is intentionally loose to accommodate potential null/undefined values
* from the AI before they are cleaned and normalized. * from the AI before they are cleaned and normalized.
*/ */
type RawFlyerItem = { export type RawFlyerItem = {
item: string; item: string | null;
price_display: string | null | undefined; price_display: string | null | undefined;
price_in_cents: number | null; price_in_cents: number | null | undefined;
quantity: string | null | undefined; quantity: string | null | undefined;
category_name: string | null | undefined; category_name: string | null | undefined;
master_item_id?: number | null | undefined; master_item_id?: number | null | undefined;
@@ -507,7 +488,7 @@ export class AIService {
userProfileAddress?: string, userProfileAddress?: string,
logger: Logger = this.logger, logger: Logger = this.logger,
): Promise<{ ): Promise<{
store_name: string; store_name: string | null;
valid_from: string | null; valid_from: string | null;
valid_to: string | null; valid_to: string | null;
store_address: string | null; store_address: string | null;
@@ -606,6 +587,8 @@ export class AIService {
item.category_name === null || item.category_name === undefined item.category_name === null || item.category_name === undefined
? 'Other/Miscellaneous' ? 'Other/Miscellaneous'
: String(item.category_name), : String(item.category_name),
// Ensure undefined is converted to null to match the Zod schema.
price_in_cents: item.price_in_cents ?? null,
master_item_id: item.master_item_id ?? undefined, master_item_id: item.master_item_id ?? undefined,
})); }));
} }

View File

@@ -283,7 +283,10 @@ export const fetchFlyerById = (flyerId: number): Promise<Response> =>
* Fetches all master grocery items from the backend. * Fetches all master grocery items from the backend.
* @returns A promise that resolves to an array of MasterGroceryItem objects. * @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. * Fetches all categories from the backend.

View File

@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerAiProcessor } from './flyerAiProcessor.server'; import { FlyerAiProcessor } from './flyerAiProcessor.server';
import { AiDataValidationError } from './processingErrors'; import { AiDataValidationError } from './processingErrors';
import { logger } from './logger.server'; import { logger } from './logger.server'; // Keep this import for the logger instance
import type { AIService } from './aiService.server'; import type { AIService } from './aiService.server';
import type { PersonalizationRepository } from './db/personalization.db'; import type { PersonalizationRepository } from './db/personalization.db';
import type { FlyerJobData } from '../types/job-data'; import type { FlyerJobData } from '../types/job-data';
@@ -63,7 +63,8 @@ describe('FlyerAiProcessor', () => {
}; };
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
const result = await service.extractAndValidateData([], jobData, logger); const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1); expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(1);
expect(mockPersonalizationRepo.getAllMasterItems).toHaveBeenCalledTimes(1); expect(mockPersonalizationRepo.getAllMasterItems).toHaveBeenCalledTimes(1);
@@ -83,7 +84,8 @@ describe('FlyerAiProcessor', () => {
}; };
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any); vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(invalidResponse as any);
await expect(service.extractAndValidateData([], jobData, logger)).rejects.toThrow( const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
await expect(service.extractAndValidateData(imagePaths, jobData, logger)).rejects.toThrow(
AiDataValidationError, AiDataValidationError,
); );
}); });
@@ -101,7 +103,8 @@ describe('FlyerAiProcessor', () => {
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse as any); vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse as any);
const { logger } = await import('./logger.server'); const { logger } = await import('./logger.server');
const result = await service.extractAndValidateData([], jobData, logger); const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
// It should not throw, but return the data and log a warning. // It should not throw, but return the data and log a warning.
expect(result.data).toEqual(mockAiResponse); expect(result.data).toEqual(mockAiResponse);
@@ -122,9 +125,104 @@ describe('FlyerAiProcessor', () => {
vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse); vi.mocked(mockAiService.extractCoreDataFromFlyerImage).mockResolvedValue(mockAiResponse);
const { logger } = await import('./logger.server'); const { logger } = await import('./logger.server');
const result = await service.extractAndValidateData([], jobData, logger); const imagePaths = [{ path: 'page1.jpg', mimetype: 'image/jpeg' }];
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
expect(result.data).toEqual(mockAiResponse); expect(result.data).toEqual(mockAiResponse);
expect(result.needsReview).toBe(true); expect(result.needsReview).toBe(true);
expect(logger.warn).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('contains no items. The flyer will be saved with an item_count of 0. Flagging for review.')); expect(logger.warn).toHaveBeenCalledWith(expect.any(Object), expect.stringContaining('contains no items. The flyer will be saved with an item_count of 0. Flagging for review.'));
}); });
describe('Batching Logic', () => {
it('should process images in batches and merge the results correctly', async () => {
// Arrange
const jobData = createMockJobData({});
// 5 images, with BATCH_SIZE = 4, should result in 2 batches.
const imagePaths = [
{ path: 'page1.jpg', mimetype: 'image/jpeg' },
{ path: 'page2.jpg', mimetype: 'image/jpeg' },
{ path: 'page3.jpg', mimetype: 'image/jpeg' },
{ path: 'page4.jpg', mimetype: 'image/jpeg' },
{ path: 'page5.jpg', mimetype: 'image/jpeg' },
];
const mockAiResponseBatch1 = {
store_name: 'Batch 1 Store',
valid_from: '2025-01-01',
valid_to: '2025-01-07',
store_address: '123 Batch St',
items: [
{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 },
{ item: 'Item B', price_display: '$2', price_in_cents: 200, quantity: '1', category_name: 'Cat B', master_item_id: 2 },
],
};
const mockAiResponseBatch2 = {
store_name: 'Batch 2 Store', // This should be ignored in the merge
valid_from: null,
valid_to: null,
store_address: null,
items: [
{ item: 'Item C', price_display: '$3', price_in_cents: 300, quantity: '1', category_name: 'Cat C', master_item_id: 3 },
],
};
// Mock the AI service to return different results for each batch call
vi.mocked(mockAiService.extractCoreDataFromFlyerImage)
.mockResolvedValueOnce(mockAiResponseBatch1)
.mockResolvedValueOnce(mockAiResponseBatch2);
// Act
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
// Assert
// 1. AI service was called twice (for 2 batches)
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenCalledTimes(2);
// 2. Check the arguments for each call
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(1, imagePaths.slice(0, 4), [], undefined, undefined, logger);
expect(mockAiService.extractCoreDataFromFlyerImage).toHaveBeenNthCalledWith(2, imagePaths.slice(4, 5), [], undefined, undefined, logger);
// 3. Check the merged data
expect(result.data.store_name).toBe('Batch 1 Store'); // Metadata from the first batch
expect(result.data.valid_from).toBe('2025-01-01');
expect(result.data.valid_to).toBe('2025-01-07');
expect(result.data.store_address).toBe('123 Batch St');
// 4. Check that items from both batches are merged
expect(result.data.items).toHaveLength(3);
expect(result.data.items).toEqual(expect.arrayContaining([
expect.objectContaining({ item: 'Item A' }),
expect.objectContaining({ item: 'Item B' }),
expect.objectContaining({ item: 'Item C' }),
]));
// 5. Check that the job is not flagged for review
expect(result.needsReview).toBe(false);
});
it('should fill in missing metadata from subsequent batches', async () => {
// Arrange
const jobData = createMockJobData({});
const imagePaths = [
{ path: 'page1.jpg', mimetype: 'image/jpeg' }, { path: 'page2.jpg', mimetype: 'image/jpeg' }, { path: 'page3.jpg', mimetype: 'image/jpeg' }, { path: 'page4.jpg', mimetype: 'image/jpeg' }, { path: 'page5.jpg', mimetype: 'image/jpeg' },
];
const mockAiResponseBatch1 = { store_name: null, valid_from: '2025-01-01', valid_to: '2025-01-07', store_address: null, items: [{ item: 'Item A', price_display: '$1', price_in_cents: 100, quantity: '1', category_name: 'Cat A', master_item_id: 1 }] };
const mockAiResponseBatch2 = { store_name: 'Batch 2 Store', valid_from: '2025-01-02', valid_to: null, store_address: '456 Subsequent St', items: [{ item: 'Item C', price_display: '$3', price_in_cents: 300, quantity: '1', category_name: 'Cat C', master_item_id: 3 }] };
vi.mocked(mockAiService.extractCoreDataFromFlyerImage)
.mockResolvedValueOnce(mockAiResponseBatch1)
.mockResolvedValueOnce(mockAiResponseBatch2);
// Act
const result = await service.extractAndValidateData(imagePaths, jobData, logger);
// Assert
expect(result.data.store_name).toBe('Batch 2 Store'); // Filled from batch 2
expect(result.data.valid_from).toBe('2025-01-01'); // Kept from batch 1
expect(result.data.valid_to).toBe('2025-01-07'); // Kept from batch 1
expect(result.data.store_address).toBe('456 Subsequent St'); // Filled from batch 2
expect(result.data.items).toHaveLength(2);
});
});
}); });

View File

@@ -5,28 +5,11 @@ import type { AIService } from './aiService.server';
import type { PersonalizationRepository } from './db/personalization.db'; import type { PersonalizationRepository } from './db/personalization.db';
import { AiDataValidationError } from './processingErrors'; import { AiDataValidationError } from './processingErrors';
import type { FlyerJobData } from '../types/job-data'; import type { FlyerJobData } from '../types/job-data';
import {
// Helper for consistent required string validation (handles missing/null/empty) AiFlyerDataSchema,
const requiredString = (message: string) => ExtractedFlyerItemSchema,
z.preprocess((val) => val ?? '', z.string().min(1, message)); requiredString,
} from '../types/ai'; // Import consolidated schemas and helper
// --- Zod Schemas for AI Response Validation ---
const ExtractedFlyerItemSchema = z.object({
item: z.string().nullable(),
price_display: z.string().nullable(),
price_in_cents: z.number().nullable(),
quantity: z.string().nullable(),
category_name: z.string().nullable(),
master_item_id: z.number().nullish(),
});
export const AiFlyerDataSchema = z.object({
store_name: z.string().nullable(),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});
export type ValidatedAiDataType = z.infer<typeof AiFlyerDataSchema>; export type ValidatedAiDataType = z.infer<typeof AiFlyerDataSchema>;
@@ -94,19 +77,64 @@ export class FlyerAiProcessor {
jobData: FlyerJobData, jobData: FlyerJobData,
logger: Logger, logger: Logger,
): Promise<AiProcessorResult> { ): Promise<AiProcessorResult> {
logger.info(`Starting AI data extraction.`); logger.info(`Starting AI data extraction for ${imagePaths.length} pages.`);
const { submitterIp, userProfileAddress } = jobData; const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.personalizationRepo.getAllMasterItems(logger); const masterItems = await this.personalizationRepo.getAllMasterItems(logger);
logger.debug(`Retrieved ${masterItems.length} master items for AI matching.`); logger.debug(`Retrieved ${masterItems.length} master items for AI matching.`);
const extractedData = await this.ai.extractCoreDataFromFlyerImage( // BATCHING LOGIC: Process images in chunks to avoid hitting AI payload/token limits.
imagePaths, const BATCH_SIZE = 4;
const batches = [];
for (let i = 0; i < imagePaths.length; i += BATCH_SIZE) {
batches.push(imagePaths.slice(i, i + BATCH_SIZE));
}
// Initialize container for merged data
const mergedData: ValidatedAiDataType = {
store_name: null,
valid_from: null,
valid_to: null,
store_address: null,
items: [],
};
logger.info(`Processing ${imagePaths.length} pages in ${batches.length} batches (Batch Size: ${BATCH_SIZE}).`);
for (const [index, batch] of batches.entries()) {
logger.info(`Processing batch ${index + 1}/${batches.length} (${batch.length} pages)...`);
// The AI service handles rate limiting internally (e.g., max 5 RPM).
// Processing these sequentially ensures we respect that limit.
const batchResult = await this.ai.extractCoreDataFromFlyerImage(
batch,
masterItems, masterItems,
submitterIp, submitterIp,
userProfileAddress, userProfileAddress,
logger, logger,
); );
return this._validateAiData(extractedData, logger); // MERGE LOGIC:
// 1. Metadata (Store Name, Dates): Prioritize the first batch (usually the cover page).
// If subsequent batches have data and the current is null, fill it in.
if (index === 0) {
mergedData.store_name = batchResult.store_name;
mergedData.valid_from = batchResult.valid_from;
mergedData.valid_to = batchResult.valid_to;
mergedData.store_address = batchResult.store_address;
} else {
if (!mergedData.store_name && batchResult.store_name) mergedData.store_name = batchResult.store_name;
if (!mergedData.valid_from && batchResult.valid_from) mergedData.valid_from = batchResult.valid_from;
if (!mergedData.valid_to && batchResult.valid_to) mergedData.valid_to = batchResult.valid_to;
if (!mergedData.store_address && batchResult.store_address) mergedData.store_address = batchResult.store_address;
}
// 2. Items: Append all found items to the master list.
mergedData.items.push(...batchResult.items);
}
logger.info(`Batch processing complete. Total items extracted: ${mergedData.items.length}`);
// Validate the final merged dataset
return this._validateAiData(mergedData, logger);
} }
} }

View File

@@ -2,9 +2,11 @@
import path from 'path'; import path from 'path';
import type { z } from 'zod'; import type { z } from 'zod';
import type { Logger } from 'pino'; import type { Logger } from 'pino';
import type { FlyerInsert, FlyerItemInsert, FlyerStatus } from '../types'; import type { FlyerInsert, FlyerItemInsert } from '../types';
import type { AiFlyerDataSchema, AiProcessorResult } from './flyerAiProcessor.server'; import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this import for AiProcessorResult
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
import { generateFlyerIcon } from '../utils/imageProcessor'; import { generateFlyerIcon } from '../utils/imageProcessor';
import { TransformationError } from './processingErrors';
/** /**
* This class is responsible for transforming the validated data from the AI service * This class is responsible for transforming the validated data from the AI service
@@ -56,6 +58,7 @@ export class FlyerDataTransformer {
): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> { ): Promise<{ flyerData: FlyerInsert; itemsForDb: FlyerItemInsert[] }> {
logger.info('Starting data transformation from AI output to database format.'); logger.info('Starting data transformation from AI output to database format.');
try {
const { data: extractedData, needsReview } = aiResult; const { data: extractedData, needsReview } = aiResult;
const firstImage = imagePaths[0].path; const firstImage = imagePaths[0].path;
@@ -92,5 +95,10 @@ export class FlyerDataTransformer {
); );
return { flyerData, itemsForDb }; return { flyerData, itemsForDb };
} catch (err) {
logger.error({ err }, 'Transformation process failed');
// Wrap and rethrow with the new error class
throw new TransformationError('Flyer Data Transformation Failed');
}
} }
} }

View File

@@ -1,12 +1,8 @@
// src/services/flyerProcessingService.server.test.ts // src/services/flyerProcessingService.server.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import sharp from 'sharp';
import { Job, UnrecoverableError } from 'bullmq'; import { Job, UnrecoverableError } from 'bullmq';
import type { Dirent } from 'node:fs'; import { AiFlyerDataSchema } from '../types/ai';
import type { Logger } from 'pino'; import type { FlyerInsert } from '../types';
import { z } from 'zod';
import { AiFlyerDataSchema } from './flyerAiProcessor.server';
import type { Flyer, FlyerInsert, FlyerItemInsert } from '../types';
import type { CleanupJobData, FlyerJobData } from '../types/job-data'; import type { CleanupJobData, FlyerJobData } from '../types/job-data';
// 1. Create hoisted mocks FIRST // 1. Create hoisted mocks FIRST

View File

@@ -133,6 +133,12 @@ export class FlyerProcessingService {
return { flyerId: flyer.flyer_id }; return { flyerId: flyer.flyer_id };
} catch (error) { } catch (error) {
logger.warn('Job failed. Temporary files will NOT be cleaned up to allow for manual inspection.'); 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. // This private method handles error reporting and re-throwing.
await this._reportErrorAndThrow(error, job, logger, stages); await this._reportErrorAndThrow(error, job, logger, stages);
// This line is technically unreachable because the above method always throws, // This line is technically unreachable because the above method always throws,
@@ -197,6 +203,14 @@ export class FlyerProcessingService {
logger: Logger, logger: Logger,
initialStages: ProcessingStage[], initialStages: ProcessingStage[],
): Promise<never> { ): Promise<never> {
// Map specific error codes to their corresponding processing stage names.
// This is more maintainable than a long if/else if chain.
const errorCodeToStageMap = new Map<string, string>([
['PDF_CONVERSION_FAILED', 'Preparing Inputs'],
['UNSUPPORTED_FILE_TYPE', 'Preparing Inputs'],
['AI_VALIDATION_FAILED', 'Extracting Data with AI'],
['TRANSFORMATION_FAILED', 'Transforming AI Data'], // Add new mapping
]);
const normalizedError = error instanceof Error ? error : new Error(String(error)); const normalizedError = error instanceof Error ? error : new Error(String(error));
let errorPayload: { errorCode: string; message: string; [key: string]: any }; let errorPayload: { errorCode: string; message: string; [key: string]: any };
let stagesToReport: ProcessingStage[] = [...initialStages]; // Create a mutable copy let stagesToReport: ProcessingStage[] = [...initialStages]; // Create a mutable copy
@@ -209,16 +223,15 @@ export class FlyerProcessingService {
} }
// Determine which stage failed // Determine which stage failed
let errorStageIndex = -1; const failedStageName = errorCodeToStageMap.get(errorPayload.errorCode);
let errorStageIndex = failedStageName ? stagesToReport.findIndex(s => s.name === failedStageName) : -1;
// 1. Try to map specific error codes/messages to stages // Fallback for generic errors not in the map. This is less robust and relies on string matching.
if (errorPayload.errorCode === 'PDF_CONVERSION_FAILED' || errorPayload.errorCode === 'UNSUPPORTED_FILE_TYPE') { // A future improvement would be to wrap these in specific FlyerProcessingError subclasses.
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Preparing Inputs'); if (errorStageIndex === -1 && errorPayload.message.includes('Icon generation failed')) {
} else if (errorPayload.errorCode === 'AI_VALIDATION_FAILED') {
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Extracting Data with AI');
} else if (errorPayload.message.includes('Icon generation failed')) {
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Transforming AI Data'); errorStageIndex = stagesToReport.findIndex(s => s.name === 'Transforming AI Data');
} else if (errorPayload.message.includes('Database transaction failed')) { }
if (errorStageIndex === -1 && errorPayload.message.includes('Database transaction failed')) {
errorStageIndex = stagesToReport.findIndex(s => s.name === 'Saving to Database'); errorStageIndex = stagesToReport.findIndex(s => s.name === 'Saving to Database');
} }
@@ -254,24 +267,16 @@ export class FlyerProcessingService {
// Logging logic // Logging logic
if (normalizedError instanceof FlyerProcessingError) { if (normalizedError instanceof FlyerProcessingError) {
const logDetails: Record<string, any> = { err: normalizedError }; // Simplify log object creation
const logDetails: Record<string, any> = { ...errorPayload, err: normalizedError };
if (normalizedError instanceof AiDataValidationError) { if (normalizedError instanceof AiDataValidationError) {
logDetails.validationErrors = normalizedError.validationErrors; logDetails.validationErrors = normalizedError.validationErrors;
logDetails.rawData = normalizedError.rawData; logDetails.rawData = normalizedError.rawData;
} }
// Also include stderr for PdfConversionError in logs
if (normalizedError instanceof PdfConversionError) { if (normalizedError instanceof PdfConversionError) {
logDetails.stderr = normalizedError.stderr; logDetails.stderr = normalizedError.stderr;
} }
// Include the errorPayload details in the log, but avoid duplicating err, validationErrors, rawData
Object.assign(logDetails, errorPayload);
// Remove the duplicated err property if it was assigned by Object.assign
if ('err' in logDetails && logDetails.err === normalizedError) {
// This check prevents accidental deletion if 'err' was a legitimate property of errorPayload
delete logDetails.err;
}
// Ensure the original error object is always passed as 'err' for consistency in logging
logDetails.err = normalizedError;
logger.error(logDetails, `A known processing error occurred: ${normalizedError.name}`); logger.error(logDetails, `A known processing error occurred: ${normalizedError.name}`);
} else { } else {

View File

@@ -62,6 +62,18 @@ export class AiDataValidationError extends FlyerProcessingError {
} }
} }
/**
* Error thrown when a transformation step fails.
*/
export class TransformationError extends FlyerProcessingError {
constructor(message: string) {
super(
message,
'TRANSFORMATION_FAILED',
'There was a problem transforming the flyer data. Please check the input.',
);
}
}
/** /**
* Error thrown when an image conversion fails (e.g., using sharp). * Error thrown when an image conversion fails (e.g., using sharp).
*/ */

View File

@@ -1,3 +1,4 @@
// src/services/workers.server.ts
import { Worker, Job, UnrecoverableError } from 'bullmq'; import { Worker, Job, UnrecoverableError } from 'bullmq';
import fsPromises from 'node:fs/promises'; import fsPromises from 'node:fs/promises';
import { exec } from 'child_process'; import { exec } from 'child_process';

View File

@@ -0,0 +1,164 @@
// src/tests/e2e/auth.e2e.test.ts
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as apiClient from '../../services/apiClient';
import { cleanupDb } from '../utils/cleanup';
import { createAndLoginUser, TEST_PASSWORD } from '../utils/testHelpers';
import type { UserProfile } from '../../types';
/**
* @vitest-environment node
*/
describe('Authentication E2E Flow', () => {
let testUser: UserProfile;
const createdUserIds: string[] = [];
beforeAll(async () => {
// Create a user that can be used for login-related tests in this suite.
const { user } = await createAndLoginUser({
email: `e2e-login-user-${Date.now()}@example.com`,
fullName: 'E2E Login User',
// E2E tests use apiClient which doesn't need the `request` object.
});
testUser = user;
createdUserIds.push(user.user.user_id);
});
afterAll(async () => {
if (createdUserIds.length > 0) {
await cleanupDb({ userIds: createdUserIds });
}
});
describe('Registration Flow', () => {
it('should successfully register a new user', async () => {
const email = `e2e-register-success-${Date.now()}@example.com`;
const fullName = 'E2E Register User';
// Act
const response = await apiClient.registerUser(email, TEST_PASSWORD, fullName);
const data = await response.json();
// Assert
expect(response.status).toBe(201);
expect(data.message).toBe('User registered successfully!');
expect(data.userprofile).toBeDefined();
expect(data.userprofile.user.email).toBe(email);
expect(data.token).toBeTypeOf('string');
// Add to cleanup
createdUserIds.push(data.userprofile.user.user_id);
});
it('should fail to register a user with a weak password', async () => {
const email = `e2e-register-weakpass-${Date.now()}@example.com`;
const weakPassword = '123';
// Act
const response = await apiClient.registerUser(email, weakPassword, 'Weak Pass User');
const errorData = await response.json();
// Assert
expect(response.status).toBe(400);
expect(errorData.errors[0].message).toContain('Password must be at least 8 characters long.');
});
it('should fail to register a user with a duplicate email', async () => {
const email = `e2e-register-duplicate-${Date.now()}@example.com`;
// Act 1: Register the user successfully
const firstResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const firstData = await firstResponse.json();
expect(firstResponse.status).toBe(201);
createdUserIds.push(firstData.userprofile.user.user_id); // Add for cleanup
// Act 2: Attempt to register the same user again
const secondResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Duplicate User');
const errorData = await secondResponse.json();
// Assert
expect(secondResponse.status).toBe(409); // Conflict
expect(errorData.message).toContain('A user with this email address already exists.');
});
});
describe('Login Flow', () => {
it('should successfully log in a registered user', async () => {
// Act: Attempt to log in with the user created in beforeAll
const response = await apiClient.loginUser(testUser.user.email, TEST_PASSWORD, false);
const data = await response.json();
// Assert
expect(response.status).toBe(200);
expect(data.userprofile).toBeDefined();
expect(data.userprofile.user.email).toBe(testUser.user.email);
expect(data.token).toBeTypeOf('string');
});
it('should fail to log in with an incorrect password', async () => {
// Act: Attempt to log in with the wrong password
const response = await apiClient.loginUser(testUser.user.email, 'wrong-password', false);
const errorData = await response.json();
// Assert
expect(response.status).toBe(401);
expect(errorData.message).toBe('Incorrect email or password.');
});
it('should fail to log in with a non-existent email', async () => {
const response = await apiClient.loginUser('no-one-here@example.com', TEST_PASSWORD, false);
const errorData = await response.json();
expect(response.status).toBe(401);
expect(errorData.message).toBe('Incorrect email or password.');
});
});
describe('Forgot/Reset Password Flow', () => {
it('should allow a user to reset their password and log in with the new one', async () => {
// Arrange: Create a user to reset the password for
const email = `e2e-reset-pass-${Date.now()}@example.com`;
const registerResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Reset Pass User');
const registerData = await registerResponse.json();
expect(registerResponse.status).toBe(201);
createdUserIds.push(registerData.userprofile.user.user_id);
// Act 1: Request a password reset.
// The test environment returns the token directly in the response for E2E testing.
const forgotResponse = await apiClient.requestPasswordReset(email);
const forgotData = await forgotResponse.json();
const resetToken = forgotData.token;
// Assert 1: Check that we received a token.
expect(forgotResponse.status).toBe(200);
expect(resetToken).toBeDefined();
expect(resetToken).toBeTypeOf('string');
// Act 2: Use the token to set a new password.
const newPassword = 'my-new-e2e-password-!@#$';
const resetResponse = await apiClient.resetPassword(resetToken, newPassword);
const resetData = await resetResponse.json();
// Assert 2: Check for a successful password reset message.
expect(resetResponse.status).toBe(200);
expect(resetData.message).toBe('Password has been reset successfully.');
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
const loginResponse = await apiClient.loginUser(email, newPassword, false);
const loginData = await loginResponse.json();
expect(loginResponse.status).toBe(200);
expect(loginData.userprofile).toBeDefined();
expect(loginData.userprofile.user.email).toBe(email);
});
it('should return a generic success message for a non-existent email to prevent enumeration', async () => {
const nonExistentEmail = `non-existent-e2e-${Date.now()}@example.com`;
const response = await apiClient.requestPasswordReset(nonExistentEmail);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe('If an account with that email exists, a password reset link has been sent.');
expect(data.token).toBeUndefined();
});
});
});

View File

@@ -40,17 +40,19 @@ describe('AI API Routes Integration Tests', () => {
// 1. Clean up database records // 1. Clean up database records
await cleanupDb({ userIds: [testUserId] }); await cleanupDb({ userIds: [testUserId] });
// 2. Clean up any leftover files from the 'image' and 'images' multer instances. // 2. Safeguard: Clean up any leftover files from failed tests.
// Most routes clean up after themselves, but this is a safeguard for failed tests. // The routes themselves should clean up on success, but this handles interruptions.
const uploadDir = path.resolve(__dirname, '../../../flyer-images'); const uploadDir = path.resolve(__dirname, '../../../flyer-images');
try { try {
const files = await fs.readdir(uploadDir); const allFiles = await fs.readdir(uploadDir);
// Target files created by the 'image' and 'images' multer instances. const testFiles = allFiles
const testFileNames = files.filter((f) => f.startsWith('image-') || f.startsWith('images-')); .filter((f) => f.startsWith('image-') || f.startsWith('images-'))
const testFilePaths = testFileNames.map((f) => path.join(uploadDir, f)); .map((f) => path.join(uploadDir, f));
await cleanupFiles(testFilePaths);
if (testFiles.length > 0) {
await cleanupFiles(testFiles);
}
} catch (error) { } catch (error) {
// If readdir fails (e.g., directory doesn't exist), we can ignore it.
if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') { if (error instanceof Error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('Error during AI integration test file cleanup:', error); console.error('Error during AI integration test file cleanup:', error);
} }

View File

@@ -21,16 +21,18 @@ const request = supertest(app);
describe('Authentication API Integration', () => { describe('Authentication API Integration', () => {
let testUserEmail: string; let testUserEmail: string;
let testUser: UserProfile; let testUser: UserProfile;
const createdUserIds: string[] = [];
beforeAll(async () => { beforeAll(async () => {
// Use a unique email for this test suite to prevent collisions with other tests. // Use a unique email for this test suite to prevent collisions with other tests.
const email = `auth-integration-test-${Date.now()}@example.com`; const email = `auth-integration-test-${Date.now()}@example.com`;
({ user: testUser } = await createAndLoginUser({ email, fullName: 'Auth Test User', request })); ({ user: testUser } = await createAndLoginUser({ email, fullName: 'Auth Test User', request }));
testUserEmail = testUser.user.email; testUserEmail = testUser.user.email;
createdUserIds.push(testUser.user.user_id);
}); });
afterAll(async () => { afterAll(async () => {
await cleanupDb({ userIds: testUser ? [testUser.user.user_id] : [] }); await cleanupDb({ userIds: createdUserIds });
}); });
// This test migrates the logic from the old DevTestRunner.tsx component. // This test migrates the logic from the old DevTestRunner.tsx component.
@@ -83,6 +85,38 @@ describe('Authentication API Integration', () => {
expect(errorData.message).toBe('Incorrect email or password.'); expect(errorData.message).toBe('Incorrect email or password.');
}); });
it('should allow registration with an empty string for avatar_url and save it as null', async () => {
// Arrange: Define user data with an empty avatar_url.
const email = `empty-avatar-user-${Date.now()}@example.com`;
const userData = {
email,
password: TEST_PASSWORD,
full_name: 'Empty Avatar',
avatar_url: '',
};
// Act: Register the new user.
const registerResponse = await request.post('/api/auth/register').send(userData);
// Assert 1: Check that the registration was successful and the returned profile is correct.
expect(registerResponse.status).toBe(201);
const registeredProfile = registerResponse.body.userprofile;
const registeredToken = registerResponse.body.token;
expect(registeredProfile.user.email).toBe(email);
expect(registeredProfile.avatar_url).toBeNull(); // The API should return null for the avatar_url.
// Add the newly created user's ID to the array for cleanup in afterAll.
createdUserIds.push(registeredProfile.user.user_id);
// Assert 2 (Verification): Fetch the profile using the new token to confirm the value in the DB is null.
const profileResponse = await request
.get('/api/users/profile')
.set('Authorization', `Bearer ${registeredToken}`);
expect(profileResponse.status).toBe(200);
expect(profileResponse.body.avatar_url).toBeNull();
});
it('should successfully refresh an access token using a refresh token cookie', async () => { it('should successfully refresh an access token using a refresh token cookie', async () => {
// Arrange: Log in to get a fresh, valid refresh token cookie for this specific test. // Arrange: Log in to get a fresh, valid refresh token cookie for this specific test.
// This ensures the test is self-contained and not affected by other tests. // This ensures the test is self-contained and not affected by other tests.

View File

@@ -82,6 +82,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
let jobStatus; let jobStatus;
const maxRetries = 30; // Poll for up to 90 seconds (30 * 3s) const maxRetries = 30; // Poll for up to 90 seconds (30 * 3s)
for (let i = 0; i < maxRetries; i++) { 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 await new Promise((resolve) => setTimeout(resolve, 3000)); // Wait 3 seconds between polls
const statusReq = request.get(`/api/ai/jobs/${jobId}/status`); const statusReq = request.get(`/api/ai/jobs/${jobId}/status`);
if (token) { if (token) {
@@ -89,6 +90,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
} }
const statusResponse = await statusReq; const statusResponse = await statusReq;
jobStatus = statusResponse.body; jobStatus = statusResponse.body;
console.log(`Job status: ${JSON.stringify(jobStatus)}`);
if (jobStatus.state === 'completed' || jobStatus.state === 'failed') { if (jobStatus.state === 'completed' || jobStatus.state === 'failed') {
break; break;
} }

View File

@@ -38,6 +38,7 @@ describe('Public API Routes Integration Tests', () => {
email: userEmail, email: userEmail,
password: 'a-Very-Strong-Password-123!', password: 'a-Very-Strong-Password-123!',
fullName: 'Public Routes Test User', fullName: 'Public Routes Test User',
request,
}); });
testUser = createdUser; testUser = createdUser;

View File

@@ -75,6 +75,32 @@ describe('User API Routes Integration Tests', () => {
expect(refetchedProfile.full_name).toBe('Updated Test User'); expect(refetchedProfile.full_name).toBe('Updated Test User');
}); });
it('should allow updating the profile with an empty string for avatar_url', async () => {
// Arrange: Define the profile updates.
const profileUpdates = {
full_name: 'Empty Avatar User',
avatar_url: '',
};
// Act: Call the update endpoint with the new data and the auth token.
const response = await request
.put('/api/users/profile')
.set('Authorization', `Bearer ${authToken}`)
.send(profileUpdates);
const updatedProfile = response.body;
// Assert: Check that the returned profile reflects the changes.
expect(response.status).toBe(200);
expect(updatedProfile.full_name).toBe('Empty Avatar User');
expect(updatedProfile.avatar_url).toBeNull();
// Also, fetch the profile again to ensure the change was persisted in the database as NULL.
const refetchResponse = await request
.get('/api/users/profile')
.set('Authorization', `Bearer ${authToken}`);
expect(refetchResponse.body.avatar_url).toBeNull();
});
it('should update user preferences via PUT /api/users/profile/preferences', async () => { it('should update user preferences via PUT /api/users/profile/preferences', async () => {
// Arrange: Define the preference updates. // Arrange: Define the preference updates.
const preferenceUpdates = { const preferenceUpdates = {

View File

@@ -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_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.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.activity_log WHERE user_id = ANY($1::uuid[])', [userIds]);
await pool.query('DELETE FROM public.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 --- // --- Stage 2: Delete parent records that other things depend on ---

29
src/types/ai.ts Normal file
View File

@@ -0,0 +1,29 @@
// src/types/ai.ts
import { z } from 'zod';
// Helper for consistent required string validation (handles missing/null/empty)
// This is moved here as it's directly related to the schemas.
export const requiredString = (message: string) =>
z.preprocess((val) => val ?? '', z.string().min(1, message));
// --- Zod Schemas for AI Response Validation ---
// These schemas define the expected structure of data returned by the AI.
// They are used for validation and type inference across multiple services.
export const ExtractedFlyerItemSchema = z.object({
item: z.string().nullable(),
price_display: z.string().nullable(),
price_in_cents: z.number().nullable(),
quantity: z.string().nullable(),
category_name: z.string().nullable(),
master_item_id: z.number().nullish(), // .nullish() allows null or undefined
});
export const AiFlyerDataSchema = z.object({
store_name: z.string().nullable(),
valid_from: z.string().nullable(),
valid_to: z.string().nullable(),
store_address: z.string().nullable(),
items: z.array(ExtractedFlyerItemSchema),
});