one lazy ai
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m26s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 4m26s
This commit is contained in:
314
src/App.tsx
314
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<User | null>(null); // Moved user state to the top
|
||||
|
||||
// --- Data Fetching ---
|
||||
const [flyers, setFlyers] = useState<Flyer[]>([]);
|
||||
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
|
||||
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
|
||||
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]); // This was a duplicate, fixed.
|
||||
const [flyers, setFlyers] = useState<Flyer[]>([]);
|
||||
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
|
||||
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
|
||||
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]); // 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<Flyer | null>(null);
|
||||
const [flyerItems, setFlyerItems] = useState<FlyerItem[]>([]);
|
||||
@@ -94,14 +94,8 @@ function App() {
|
||||
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
|
||||
const [activeDealsLoading, setActiveDealsLoading] = useState(false);
|
||||
const [totalActiveItems, setTotalActiveItems] = useState(0);
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [processingProgress, setProcessingProgress] = useState(0);
|
||||
const [currentFile, setCurrentFile] = useState<string | null>(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<ProcessingStage[]>([]);
|
||||
const [estimatedTime, setEstimatedTime] = useState(0);
|
||||
const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null);
|
||||
|
||||
const [activeListId, setActiveListId] = useState<number | null>(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<ProcessingStage>) => 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<ProcessingStage>) => {
|
||||
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<ProcessingStage>) => 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() {
|
||||
|
||||
<div className="lg:col-span-1 flex flex-col space-y-6">
|
||||
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} />
|
||||
{(
|
||||
<BulkImporter
|
||||
onProcess={handleProcessFiles}
|
||||
isProcessing={isProcessing}
|
||||
/>
|
||||
)}
|
||||
<FlyerUploader onProcessingComplete={refetchFlyers} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2 flex flex-col space-y-6">
|
||||
{error && <ErrorDisplay message={error} />} {/* This was a duplicate, fixed. */}
|
||||
|
||||
{isProcessing ? (
|
||||
<ProcessingStatus
|
||||
stages={processingStages}
|
||||
estimatedTime={estimatedTime}
|
||||
currentFile={currentFile}
|
||||
pageProgress={pageProgress}
|
||||
bulkProgress={processingProgress}
|
||||
bulkFileCount={fileCount}
|
||||
/>
|
||||
) : selectedFlyer ? (
|
||||
{selectedFlyer ? (
|
||||
<>
|
||||
<FlyerDisplay
|
||||
imageUrl={selectedFlyer.image_url}
|
||||
@@ -919,8 +671,6 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : importSummary ? (
|
||||
<BulkImportSummary summary={importSummary} onDismiss={() => setImportSummary(null)} /> // This was a duplicate, fixed.
|
||||
) : (
|
||||
<div className="text-center p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 h-full flex flex-col justify-center min-h-[400px]">
|
||||
<h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200">Welcome to Flyer Crawler!</h2>
|
||||
|
||||
@@ -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<FlyerUploaderProps> = ({ onProcessingComplete }) => {
|
||||
const [processingState, setProcessingState] = useState<ProcessingState>('idle');
|
||||
const [statusMessage, setStatusMessage] = useState<string>('Select a flyer (PDF or image) to begin.');
|
||||
const [jobId, setJobId] = useState<string | null>(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...');
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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<FlyerItem, ...> 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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user