diff --git a/src/App.tsx b/src/App.tsx index c902f8a5..ccf07573 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,13 +17,9 @@ import { PriceHistoryChart } from './features/charts/PriceHistoryChart'; import * as apiClient from './services/apiClient'; import { FlyerList } from './features/flyer/FlyerList'; import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer'; -import { ProcessingStatus } from './features/flyer/ProcessingStatus'; -import { generateFileChecksum } from './utils/checksum'; -import { convertPdfToImageFiles } from './utils/pdfConverter'; -import { BulkImportSummary } from './features/flyer/BulkImportSummary'; -import { withTimeout } from './utils/timeout'; import { ProfileManager } from './pages/admin/components/ProfileManager'; import { ShoppingListComponent } from './features/shopping/ShoppingList'; +import { FlyerUploader } from './features/flyer/FlyerUploader'; import { VoiceAssistant } from './features/voice-assistant/VoiceAssistant'; import { AdminPage } from './pages/admin/AdminPage'; import { AdminRoute } from './components/AdminRoute'; @@ -60,30 +56,34 @@ function App() { const [user, setUser] = useState(null); // Moved user state to the top // --- Data Fetching --- - const [flyers, setFlyers] = useState([]); - const [masterItems, setMasterItems] = useState([]); - const [watchedItems, setWatchedItems] = useState([]); - const [shoppingLists, setShoppingLists] = useState([]); // This was a duplicate, fixed. + const [flyers, setFlyers] = useState([]); + const [masterItems, setMasterItems] = useState([]); + const [watchedItems, setWatchedItems] = useState([]); + const [shoppingLists, setShoppingLists] = useState([]); // This was a duplicate, fixed. - useEffect(() => { - const fetchData = async () => { - try { - const [flyersRes, masterItemsRes, watchedItemsRes, shoppingListsRes] = await Promise.all([ - apiClient.fetchFlyers(), - apiClient.fetchMasterItems(), - user ? apiClient.fetchWatchedItems() : Promise.resolve(new Response(JSON.stringify([]))), - user ? apiClient.fetchShoppingLists() : Promise.resolve(new Response(JSON.stringify([]))), - ]); - setFlyers(await flyersRes.json()); - setMasterItems(await masterItemsRes.json()); - setWatchedItems(await watchedItemsRes.json()); - setShoppingLists(await shoppingListsRes.json()); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } - }; - fetchData(); - }, [user]); + const refetchFlyers = useCallback(async () => { + try { + const flyersRes = await apiClient.fetchFlyers(); + setFlyers(await flyersRes.json()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + const fetchData = async () => { + const [masterItemsRes, watchedItemsRes, shoppingListsRes] = await Promise.all([ + apiClient.fetchMasterItems(), + user ? apiClient.fetchWatchedItems() : Promise.resolve(new Response(JSON.stringify([]))), + user ? apiClient.fetchShoppingLists() : Promise.resolve(new Response(JSON.stringify([]))), + ]); + setMasterItems(await masterItemsRes.json()); + setWatchedItems(await watchedItemsRes.json()); + setShoppingLists(await shoppingListsRes.json()); + }; + refetchFlyers(); // Initial fetch + fetchData(); + }, [user, refetchFlyers]); const [selectedFlyer, setSelectedFlyer] = useState(null); const [flyerItems, setFlyerItems] = useState([]); @@ -94,14 +94,8 @@ function App() { const [activeDeals, setActiveDeals] = useState([]); const [activeDealsLoading, setActiveDealsLoading] = useState(false); const [totalActiveItems, setTotalActiveItems] = useState(0); - - const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState(null); - const [processingProgress, setProcessingProgress] = useState(0); - const [currentFile, setCurrentFile] = useState(null); - const [fileCount, setFileCount] = useState<{current: number, total: number} | null>(null); - const [importSummary, setImportSummary] = useState<{ processed: string[]; skipped: string[]; @@ -132,9 +126,7 @@ function App() { setSelectedFlyer(updatedFlyer); }; - const [processingStages, setProcessingStages] = useState([]); const [estimatedTime, setEstimatedTime] = useState(0); - const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null); const [activeListId, setActiveListId] = useState(null); @@ -285,13 +277,6 @@ function App() { const resetState = useCallback(() => { setSelectedFlyer(null); setFlyerItems([]); - setError(null); - setProcessingProgress(0); - setProcessingStages([]); - setImportSummary(null); - setCurrentFile(null); - setPageProgress(null); - setFileCount(null); }, []); const handleFlyerSelect = useCallback(async (flyer: Flyer) => { @@ -310,10 +295,10 @@ function App() { }, []); useEffect(() => { - if (!isProcessing && !selectedFlyer && flyers.length > 0) { + if (!selectedFlyer && flyers.length > 0) { handleFlyerSelect(flyers[0]); } - }, [flyers, selectedFlyer, handleFlyerSelect, isProcessing]); + }, [flyers, selectedFlyer, handleFlyerSelect]); useEffect(() => { const findActiveDeals = async () => { @@ -420,225 +405,6 @@ function App() { calculateTotalActiveItems(); }, [flyers]); - const processAndUploadFlyer = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial) => void) => { - let stageIndex = 0; - - // Stage: Validating Flyer - updateStage?.(stageIndex, { status: 'in-progress' }); - const isFlyerResponse = await withTimeout(aiApiClient.isImageAFlyer(files[0]), 15000); - const { is_flyer: isFlyer } = await isFlyerResponse.json(); - if (!isFlyer) { - throw new Error("The uploaded image does not appear to be a grocery flyer."); - } - updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 1 - - const pageCount = files.length; - const coreDataTimeout = 60000 * pageCount; - const nonCriticalTimeout = 30000; - - // Granular stages for core data extraction - const storeInfoStageIndex = stageIndex; // Stage 1: Extracting Store Name & Sale Dates - const itemExtractionStageIndex = stageIndex + 1; // Stage 2: Extracting All Items from Flyer - - // Mark both stages as in-progress for the single AI call - updateStage?.(storeInfoStageIndex, { status: 'in-progress' }); - updateStage?.(itemExtractionStageIndex, { status: 'in-progress', detail: pageCount > 1 ? `(${pageCount} pages)` : undefined }); - - let progressInterval: number | undefined; - let extractedData; - - try { - if (pageCount > 1) { - let currentPage = 0; - const intervalTime = 2500; - // Attach progress bar to the item extraction stage - progressInterval = window.setInterval(() => { // This was a duplicate test, fixed. - currentPage++; - if (currentPage <= pageCount) { - updateStage?.(itemExtractionStageIndex, { progress: { current: currentPage, total: pageCount } }); - } else { - clearInterval(progressInterval); // This was a duplicate test, fixed. - } - }, intervalTime); - } - - extractedData = await withTimeout(aiApiClient.extractCoreDataFromImage(files, masterItems), coreDataTimeout); - await extractedData.clone().json(); // Parse the Response object to ensure it's valid JSON, but we don't need the result here. - - // Mark both stages as completed after the AI call finishes // This was a duplicate test, fixed. - updateStage?.(storeInfoStageIndex, { status: 'completed' }); - updateStage?.(itemExtractionStageIndex, { status: 'completed', progress: null }); - } finally { - if (progressInterval) { - clearInterval(progressInterval); - } - } - - // If extractedData is null or undefined at this point, it means the AI call failed. - if (!extractedData) { - throw new Error("Core data extraction failed. The AI did not return valid data."); - } - - const { store_name, valid_from, valid_to, items: extractedItems } = await extractedData.json(); - stageIndex += 2; // Increment by 2 for the stages we just completed. stageIndex is now 3 - - // Stage: Extracting Store Address - let storeAddress: string | null = null; - try { - updateStage?.(stageIndex, { status: 'in-progress' }); - const addressResponse = await withTimeout(aiApiClient.extractAddressFromImage(files[0]), nonCriticalTimeout); - storeAddress = (await addressResponse.json()).address; - updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 4 - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.warn("Non-critical step failed: Address extraction.", { error: errorMessage }); - updateStage?.(stageIndex++, { status: 'error', detail: '(Skipped)' }); // stageIndex is now 4 - } - - // Stage: Extracting Store Logo - let storeLogoBase64: string | null = null; - try { - updateStage?.(stageIndex, { status: 'in-progress' }); - const logoData = await withTimeout(aiApiClient.extractLogoFromImage(files.slice(0, 1)), nonCriticalTimeout); - storeLogoBase64 = (await logoData.json()).store_logo_base_64; - updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 5 - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.warn("Non-critical step failed: Logo extraction.", { error: errorMessage }); - updateStage?.(stageIndex++, { status: 'error', detail: '(Skipped)' }); // stageIndex is now 5 - } - - // Stage: Uploading and Saving Data to Backend - updateStage?.(stageIndex, { status: 'in-progress' }); - const backendRawResponse = await apiClient.processFlyerFile(files[0], checksum, originalFileName, { store_name, valid_from, valid_to, items: extractedItems, store_address: storeAddress }); - const backendResponse = await backendRawResponse.json(); - updateStage?.(stageIndex, { status: 'completed', detail: backendResponse.message }); - - // Fire-and-forget logo upload if a logo was extracted and the store doesn't already have one. - if (storeLogoBase64 && backendResponse.flyer.store_id && !backendResponse.flyer.store?.logo_url) { - const logoFile = await (await fetch(storeLogoBase64)).blob(); - apiClient.uploadLogoAndUpdateStore(backendResponse.flyer.store_id, new File([logoFile], 'logo.png', { type: 'image/png' })) // This returns a Response - .catch(e => logger.warn("Non-critical error: Failed to upload store logo.", { error: e })); - } - - return { newFlyer: backendResponse.flyer, items: [] }; // Items are now saved on backend, return empty array - }; - - const setupProcessingStages = (isPdf: boolean) => { - const pendingStatus: StageStatus = 'pending'; - - const baseStages: ProcessingStage[] = [ - { name: 'Validating Flyer', status: pendingStatus, critical: true }, - { name: 'Extracting Store Name & Sale Dates', status: pendingStatus, critical: true }, - { name: 'Extracting All Items from Flyer', status: pendingStatus, critical: true }, - { name: 'Extracting Store Address', status: pendingStatus, critical: false }, - { name: 'Extracting Store Logo', status: pendingStatus, critical: false }, - { name: 'Uploading and Saving to Database', status: pendingStatus, critical: true }, - ]; - if (isPdf) { - return [ - { name: 'Analyzing PDF', status: pendingStatus, critical: true }, - { name: 'Converting PDF to Images', status: pendingStatus, critical: true }, - ...baseStages - ]; - } - return baseStages; - }; - - const handleProcessFiles = useCallback(async (files: FileList) => { - if (files.length === 0) return; - - // If a user is not logged in, transition them to an 'ANONYMOUS' state - // upon their first major interaction (uploading a flyer). This allows - // the app to handle session-based data without requiring a full login. - // This state can be used to prompt the user to save their work by creating - // an account later. - if (authStatus === 'SIGNED_OUT') { - setAuthStatus('ANONYMOUS'); - logger.info("User is not signed in. Transitioning to ANONYMOUS session for this upload."); - } - resetState(); - setIsProcessing(true); - setProcessingProgress(0); - - const summary = { - processed: [] as string[], - skipped: [] as string[], - errors: [] as { fileName: string; message: string }[], - }; - - const avgTime = getAverageProcessingTime(); - setEstimatedTime(avgTime * files.length); - - for (let i = 0; i < files.length; i++) { - const originalFile = files[i]; - setCurrentFile(originalFile.name); - setFileCount({ current: i + 1, total: files.length }); - setPageProgress(null); - - const isPdf = originalFile.type === 'application/pdf'; - setProcessingStages(setupProcessingStages(isPdf)); - - const updateStage = (index: number, updates: Partial) => { - setProcessingStages(prev => - prev.map((stage, j) => (j === index ? { ...stage, ...updates } : stage)) - ); - }; - - let currentStageIndex = 0; - const startTime = Date.now(); - - try { - let filesToProcess: File[]; - let checksum = ''; - - if (isPdf) { - updateStage(currentStageIndex, { status: 'in-progress' }); - const onPdfProgress = (currentPage: number, totalPages: number) => { - setPageProgress({ current: currentPage, total: totalPages }); - }; - const { imageFiles, pageCount } = await convertPdfToImageFiles(originalFile, onPdfProgress); - filesToProcess = imageFiles; - setPageProgress(null); - updateStage(currentStageIndex++, { status: 'completed', detail: `(${pageCount} pages)` }); - updateStage(currentStageIndex++, { status: 'completed' }); - } else { - filesToProcess = [originalFile]; - } - - checksum = await generateFileChecksum(originalFile); - - const processAndUploadUpdateStage = (idx: number, updates: Partial) => updateStage(idx + currentStageIndex, updates); - - await processAndUploadFlyer(filesToProcess, checksum, originalFile.name, processAndUploadUpdateStage); - summary.processed.push(originalFile.name); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : String(e); - logger.error(`Failed to process ${originalFile.name}:`, { error: errorMessage }); - summary.errors.push({ fileName: originalFile.name, message: errorMessage }); - setProcessingStages(prev => prev.map(stage => { - if (stage.status === 'in-progress' && (stage.critical ?? true)) { - return {...stage, status: 'error'}; - } - return stage; - })); - await new Promise(resolve => setTimeout(resolve, 2000)); - } finally { - const duration = (Date.now() - startTime) / 1000; - recordProcessingTime(duration); - } - setProcessingProgress(((i + 1) / files.length) * 100); - } - - // Data will be re-fetched automatically by the useApiOnMount hooks if we re-trigger them, - // but for now, we'll just update the local state. A full page refresh would also work. - setImportSummary(summary); - setIsProcessing(false); - setCurrentFile(null); // This was a duplicate, fixed. - setPageProgress(null); - setFileCount(null); - }, [resetState, masterItems, authStatus]); // Removed fetchFlyers, fetchMasterItems - const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => { if (!user) return; try { @@ -872,27 +638,13 @@ function App() {
- {( - - )} +
{error && } {/* This was a duplicate, fixed. */} - {isProcessing ? ( - - ) : selectedFlyer ? ( + {selectedFlyer ? ( <> )} - ) : importSummary ? ( - setImportSummary(null)} /> // This was a duplicate, fixed. ) : (

Welcome to Flyer Crawler!

diff --git a/src/features/flyer/FlyerUploader.tsx b/src/features/flyer/FlyerUploader.tsx index e5d18542..0a5a192d 100644 --- a/src/features/flyer/FlyerUploader.tsx +++ b/src/features/flyer/FlyerUploader.tsx @@ -11,7 +11,11 @@ const LoadingSpinner = () => ( type ProcessingState = 'idle' | 'uploading' | 'polling' | 'completed' | 'error'; -export const FlyerUploader: React.FC = () => { +interface FlyerUploaderProps { + onProcessingComplete: () => void; +} + +export const FlyerUploader: React.FC = ({ onProcessingComplete }) => { const [processingState, setProcessingState] = useState('idle'); const [statusMessage, setStatusMessage] = useState('Select a flyer (PDF or image) to begin.'); const [jobId, setJobId] = useState(null); @@ -47,6 +51,8 @@ export const FlyerUploader: React.FC = () => { if (flyerId) { setStatusMessage(`Processing complete! Redirecting to flyer ${flyerId}...`); setProcessingState('completed'); + // Call the callback to refetch the main flyer list + onProcessingComplete(); // Redirect to the new flyer's page after a short delay setTimeout(() => { window.location.href = `/flyers/${flyerId}`; @@ -83,7 +89,7 @@ export const FlyerUploader: React.FC = () => { clearTimeout(pollingTimeoutRef.current); } }; - }, [processingState, jobId]); + }, [processingState, jobId, onProcessingComplete]); const processFile = useCallback(async (file: File) => { setProcessingState('uploading'); setErrorMessage(null); setStatusMessage('Calculating file checksum...'); diff --git a/src/services/aiApiClient.test.ts b/src/services/aiApiClient.test.ts index b5bb8af7..01824ae2 100644 --- a/src/services/aiApiClient.test.ts +++ b/src/services/aiApiClient.test.ts @@ -112,29 +112,6 @@ describe('AI API Client (Network Mocking with MSW)', () => { }); }); - describe('extractCoreDataFromImage', () => { - it('should construct FormData with files and master items JSON', async () => { - const blob1 = new Blob(['f1'], { type: 'image/jpeg' }); - const blob2 = new Blob(['f2'], { type: 'image/jpeg' }); - const formData = new FormData(); - formData.append('flyerImages', blob1, 'f1.jpg'); - formData.append('flyerImages', blob2, 'f2.jpg'); - const masterItems = [{ master_grocery_item_id: 1, name: 'Milk' }]; - - await aiApiClient.extractCoreDataFromImage(formData as any, masterItems as any); - - expect(requestSpy).toHaveBeenCalledTimes(1); - const req = requestSpy.mock.calls[0][0]; - - expect(req.endpoint).toBe('process-flyer'); - expect(req.body._isFormData).toBe(true); - // Check that a file field exists (FormData conflation in object conversion keeps last key usually) - // Note: When multiple files are appended with the same key, `formData.get()` returns the first one. - expect(req.body.flyerImages).toHaveProperty('name', 'f1.jpg'); - expect(req.body.masterItems).toBe(JSON.stringify(masterItems)); - }); - }); - describe('extractLogoFromImage', () => { it('should construct FormData and send a POST request', async () => { const blob = new Blob(['logo'], { type: 'image/jpeg' }); diff --git a/src/tests/integration/flyer-processing.integration.test.ts b/src/tests/integration/flyer-processing.integration.test.ts index a6aed243..308eab41 100644 --- a/src/tests/integration/flyer-processing.integration.test.ts +++ b/src/tests/integration/flyer-processing.integration.test.ts @@ -6,6 +6,7 @@ import * as apiClient from '../../services/apiClient'; import * as aiApiClient from '../../services/aiApiClient'; import * as db from '../../services/db'; import { getPool } from '../../services/db/connection'; +import { generateFileChecksum } from '../../utils/checksum'; import type { User, MasterGroceryItem } from '../../types'; /** @@ -28,15 +29,12 @@ const createAndLoginUser = async (email: string) => { return { user, token }; }; -describe('Flyer Processing End-to-End Integration Tests', () => { - let masterItems: MasterGroceryItem[] = []; +describe('Flyer Processing Background Job Integration Test', () => { const createdUserIds: string[] = []; const createdFlyerIds: number[] = []; beforeAll(async () => { - // Fetch master items once, as they are static for the tests. - masterItems = await db.getAllMasterItems(); - expect(masterItems.length).toBeGreaterThan(0); + // This setup is now simpler as the worker handles fetching master items. }); afterAll(async () => { @@ -51,66 +49,52 @@ describe('Flyer Processing End-to-End Integration Tests', () => { }); /** - * This is the original end-to-end test that calls the real AI service. - * It's valuable but can be slow and less reliable. + * This is the new end-to-end test for the background job processing flow. + * It uploads a file, polls for completion, and verifies the result in the database. */ - const runFullE2EProcessingTest = async (user?: User, token?: string) => { - // Arrange: Load a real flyer image from the filesystem to properly test AI extraction. - // The path is resolved from the current file's directory (`src/tests/integration`). - // We need to go up one level to `src/tests` and then into the `assets` directory. + const runBackgroundProcessingTest = async (user?: User, token?: string) => { + // Arrange: Load a mock flyer PDF. const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = fs.readFileSync(imagePath); const mockImageFile = new File([imageBuffer], 'test-flyer-image.jpg', { type: 'image/jpeg' }); + const checksum = await generateFileChecksum(mockImageFile); - const mockMasterItems: MasterGroceryItem[] = masterItems; + // Act 1: Upload the file to start the background job. + const uploadResponse = await aiApiClient.uploadAndProcessFlyer(mockImageFile, checksum, token); + const { jobId } = await uploadResponse.json(); - // Act 1: Simulate the first step - AI data extraction - // In a real test, we might mock the AI service, but here we call our backend which has stubs. - const extractedDataResponse = await aiApiClient.extractCoreDataFromImage([mockImageFile], mockMasterItems); - const extractedData = await extractedDataResponse.json(); - const coreData = extractedData.data; + // Assert 1: Check that a job ID was returned. + expect(jobId).toBeTypeOf('string'); - // Assert 1: Check that we got some data back from the extraction step - expect(coreData).toBeDefined(); - expect(coreData).not.toBeNull(); - if (!coreData) throw new Error('Extraction failed'); - - // The AI might not always find a store name or items. The test should be flexible. - // We assert that if a store name exists, it's a string, but we don't fail if it's null. - if (coreData.store_name) { - expect(coreData.store_name).toBeTypeOf('string'); + // Act 2: Poll for the job status until it completes. + let jobStatus; + const maxRetries = 20; // Poll for up to 60 seconds (20 * 3s) + for (let i = 0; i < maxRetries; i++) { + await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3 seconds between polls + const statusResponse = await aiApiClient.getJobStatus(jobId, token); + jobStatus = await statusResponse.json(); + if (jobStatus.state === 'completed' || jobStatus.state === 'failed') { + break; + } } - // We assert that the items property is an array, but it's okay if it's empty. - expect(coreData.items).toBeInstanceOf(Array); - // Arrange 2: Prepare for the final processing step - const checksum = `test-checksum-${Date.now()}`; - const originalFileName = `test-flyer-${Date.now()}.jpg`; + // Assert 2: Check that the job completed successfully. + expect(jobStatus?.state).toBe('completed'); + const flyerId = jobStatus?.returnValue?.flyerId; + expect(flyerId).toBeTypeOf('number'); + createdFlyerIds.push(flyerId); // Track for cleanup - // Act 2: Call the backend endpoint to process and save the flyer. - // We now pass the token directly, avoiding global state modification. - const response = await apiClient.processFlyerFile( - mockImageFile, - checksum, - originalFileName, - coreData, // Pass the nested coreData object - token // Pass token override here - ); - const processResponse = await response.json(); - - // Assert 2: Check for a successful response from the server - expect(processResponse).toBeDefined(); - expect(processResponse.message).toBe('Flyer processed and saved successfully.'); - expect(processResponse.flyer).toBeDefined(); - expect(processResponse.flyer.file_name).toBe(originalFileName); - createdFlyerIds.push(processResponse.flyer.flyer_id); // Track for cleanup - - // Assert 3: Verify the flyer was actually saved in the database + // Assert 3: Verify the flyer and its items were actually saved in the database. const savedFlyer = await db.findFlyerByChecksum(checksum); expect(savedFlyer).toBeDefined(); - expect(savedFlyer?.flyer_id).toBe(processResponse.flyer.flyer_id); + expect(savedFlyer?.flyer_id).toBe(flyerId); - // Assert 4: Verify user association is correct + const items = await db.getFlyerItems(flyerId); + // The stubbed AI response returns items, so we expect them to be here. + expect(items.length).toBeGreaterThan(0); + expect(items[0].item).toBeTypeOf('string'); + + // Assert 4: Verify user association is correct. if (token) { expect(savedFlyer?.uploaded_by).toBe(user?.user_id); } else { @@ -118,7 +102,7 @@ describe('Flyer Processing End-to-End Integration Tests', () => { } }; - it('should successfully process and save a flyer for an AUTHENTICATED user', async ({ onTestFinished }) => { + it('should successfully process a flyer for an AUTHENTICATED user via the background queue', async ({ onTestFinished }) => { // Arrange: Create a new user specifically for this test. const email = `auth-flyer-user-${Date.now()}@example.com`; const { user, token } = await createAndLoginUser(email); @@ -130,66 +114,11 @@ describe('Flyer Processing End-to-End Integration Tests', () => { }); // Act & Assert - await runFullE2EProcessingTest(user, token); + await runBackgroundProcessingTest(user, token); }, 120000); // Increase timeout to 120 seconds for this long-running test - it('should successfully process and save a flyer for an ANONYMOUS user', async () => { + it('should successfully process a flyer for an ANONYMOUS user via the background queue', async () => { // Act & Assert: Call the test helper without a user or token. - await runFullE2EProcessingTest(); + await runBackgroundProcessingTest(); }, 120000); // Increase timeout to 120 seconds for this long-running test - - /** - * NEW, FOCUSED TEST - * This test bypasses the AI service call and uses a mock data object. - * It's faster, more reliable, and specifically tests the backend's data handling - * and database insertion logic. - */ - it('should save a flyer with pre-extracted mock data', async () => { - // Arrange 1: Load a mock flyer image from the filesystem. - // The path is resolved from the current file's directory (`src/tests/integration`). - // We need to go up one level to `src/tests` and then into the `assets` directory. - const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); - const imageBuffer = fs.readFileSync(imagePath); - const mockImageFile = new File([imageBuffer], 'test-flyer-image.jpg', { type: 'image/jpeg' }); - - // Arrange 2: Define a fixed, mock data object. This replaces the AI call. - const mockExtractedData = { - store_name: 'Mock Store', - valid_from: '2025-01-01', - valid_to: '2025-01-07', - store_address: '456 Mock St, Mockville', - // Add missing properties to satisfy the Omit type. - items: [{ - item: 'Mock Chicken', - price_display: '$10.99', - price_in_cents: 1099, - quantity: '1kg', - category_name: 'Meat & Seafood', - view_count: 0, // Add required property - click_count: 0, // Add required property - updated_at: new Date().toISOString() // Add required property - }] - }; - const checksum = `mock-checksum-${Date.now()}`; - const originalFileName = `mock-flyer-${Date.now()}.jpg`; - - // Act: Call the backend endpoint directly with the mock data. - const response = await apiClient.processFlyerFile( - mockImageFile, - checksum, - originalFileName, - mockExtractedData - ); - const processResponse = await response.json(); - - // Assert: Check for a successful response and verify data in the database. - expect(processResponse).toBeDefined(); - expect(processResponse.message).toBe('Flyer processed and saved successfully.'); - createdFlyerIds.push(processResponse.flyer.flyer_id); // Track for cleanup - - const savedFlyer = await db.findFlyerByChecksum(checksum); - expect(savedFlyer).toBeDefined(); - expect(savedFlyer?.flyer_id).toBe(processResponse.flyer.flyer_id); - expect(savedFlyer?.uploaded_by).toBe(null); // Anonymous upload - }); }); diff --git a/src/tests/setup/unit-setup.ts b/src/tests/setup/unit-setup.ts index 21585ec9..95be4782 100644 --- a/src/tests/setup/unit-setup.ts +++ b/src/tests/setup/unit-setup.ts @@ -185,7 +185,6 @@ vi.mock('../../services/apiClient', () => ({ vi.mock('../../services/aiApiClient', () => ({ isImageAFlyer: vi.fn(), extractAddressFromImage: vi.fn(), - extractCoreDataFromImage: vi.fn(), extractLogoFromImage: vi.fn(), getQuickInsights: vi.fn(), getDeepDiveAnalysis: vi.fn(),