// src/tests/integration/receipt.integration.test.ts /** * Integration tests for Receipt processing workflow. * Tests the complete flow from receipt upload to item extraction and inventory addition. */ import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest'; import supertest from 'supertest'; import path from 'path'; import type { UserProfile } from '../../types'; import { createAndLoginUser } from '../utils/testHelpers'; import { cleanupDb } from '../utils/cleanup'; import { getPool } from '../../services/db/connection.db'; import { createStoreWithLocation, cleanupStoreLocations, type CreatedStoreLocation, } from '../utils/storeHelpers'; import { cleanupFiles } from '../utils/cleanupFiles'; /** * @vitest-environment node */ // Storage path for test files const testStoragePath = process.env.STORAGE_PATH || path.resolve(__dirname, '../../../uploads/receipts'); // Mock storage service to write files to disk AND return URLs (like flyer-processing) vi.mock('../../services/storage/storageService', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const fsModule = require('node:fs/promises'); // eslint-disable-next-line @typescript-eslint/no-require-imports const pathModule = require('path'); return { storageService: { upload: vi .fn() .mockImplementation( async ( fileData: Buffer | string | { name?: string; path?: string }, fileName?: string, ) => { const name = fileName || (fileData && typeof fileData === 'object' && 'name' in fileData && fileData.name) || (typeof fileData === 'string' ? pathModule.basename(fileData) : `upload-${Date.now()}.jpg`); // Use the STORAGE_PATH from the environment (set by global setup to temp directory) const uploadDir = process.env.STORAGE_PATH || pathModule.join(process.cwd(), 'uploads', 'receipts'); await fsModule.mkdir(uploadDir, { recursive: true }); const destPath = pathModule.join(uploadDir, name); let content: Buffer = Buffer.from(''); if (Buffer.isBuffer(fileData)) { content = Buffer.from(fileData); } else if (typeof fileData === 'string') { try { content = await fsModule.readFile(fileData); } catch { /* ignore */ } } else if ( fileData && typeof fileData === 'object' && 'path' in fileData && fileData.path ) { try { content = await fsModule.readFile(fileData.path); } catch { /* ignore */ } } await fsModule.writeFile(destPath, content); // Return a valid URL to satisfy the 'url_check' DB constraint return `https://example.com/uploads/receipts/${name}`; }, ), delete: vi.fn().mockResolvedValue(undefined), }, }; }); describe('Receipt Processing Integration Tests (/api/receipts)', () => { let request: ReturnType; let authToken = ''; let testUser: UserProfile; const createdUserIds: string[] = []; const createdReceiptIds: number[] = []; const createdInventoryIds: number[] = []; const createdStoreLocations: CreatedStoreLocation[] = []; const createdFilePaths: string[] = []; const originalFrontendUrl = process.env.FRONTEND_URL; beforeAll(async () => { // Stub FRONTEND_URL to ensure valid absolute URLs vi.stubEnv('FRONTEND_URL', 'https://example.com'); vi.stubEnv('STORAGE_PATH', testStoragePath); process.env.FRONTEND_URL = 'https://example.com'; const appModule = await import('../../../server'); const app = appModule.default; request = supertest(app); // Create a user for receipt tests const { user, token } = await createAndLoginUser({ email: `receipt-test-user-${Date.now()}@example.com`, fullName: 'Receipt Test User', request, }); testUser = user; authToken = token; createdUserIds.push(user.user.user_id); }); // Reset mocks before each test to ensure isolation beforeEach(async () => { console.error('[TEST SETUP] Resetting mocks before test execution'); // Add any mock resets here if needed for receipt processing }); afterAll(async () => { // Restore original value process.env.FRONTEND_URL = originalFrontendUrl; vi.unstubAllEnvs(); vi.restoreAllMocks(); // CRITICAL: Close workers FIRST before any cleanup to ensure no pending jobs try { console.error('[TEST TEARDOWN] Closing in-process workers...'); const { closeWorkers } = await import('../../services/workers.server'); await closeWorkers(); // Give workers a moment to fully release resources await new Promise((resolve) => setTimeout(resolve, 100)); } catch (error) { console.error('[TEST TEARDOWN] Error closing workers:', error); } // Close the shared redis connection used by the workers/queues const { connection } = await import('../../services/redis.server'); await connection.quit(); const pool = getPool(); // Clean up inventory items if (createdInventoryIds.length > 0) { await pool.query('DELETE FROM public.pantry_items WHERE pantry_item_id = ANY($1::int[])', [ createdInventoryIds, ]); } // Clean up receipt items and receipts if (createdReceiptIds.length > 0) { await pool.query('DELETE FROM public.receipt_items WHERE receipt_id = ANY($1::int[])', [ createdReceiptIds, ]); await pool.query( 'DELETE FROM public.receipt_processing_log WHERE receipt_id = ANY($1::int[])', [createdReceiptIds], ); await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::int[])', [ createdReceiptIds, ]); } await cleanupDb({ userIds: createdUserIds }); await cleanupStoreLocations(pool, createdStoreLocations); // Clean up test files await cleanupFiles(createdFilePaths); // Final delay to let any remaining async operations settle await new Promise((resolve) => setTimeout(resolve, 50)); }); describe('POST /api/receipts - Upload Receipt', () => { let testStoreLocationId: number; beforeAll(async () => { // Create a test store for receipt upload tests const pool = getPool(); const store = await createStoreWithLocation(pool, { name: `Receipt Upload Test Store - ${Date.now()}`, address: '123 Receipt St', city: 'Toronto', province: 'ON', postalCode: 'M5V 1A1', }); createdStoreLocations.push(store); testStoreLocationId = store.storeLocationId; }); it('should upload a receipt image successfully', async () => { // Create a simple test image buffer const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64', ); const response = await request .post('/api/receipts') .set('Authorization', `Bearer ${authToken}`) .attach('receipt', testImageBuffer, 'test-receipt.png') .field('store_location_id', testStoreLocationId.toString()) .field('transaction_date', '2024-01-15'); expect(response.status).toBe(201); expect(response.body.success).toBe(true); expect(response.body.data.receipt_id).toBeDefined(); expect(response.body.data.job_id).toBeDefined(); // Real queue job ID createdReceiptIds.push(response.body.data.receipt_id); // Track the uploaded file for cleanup createdFilePaths.push(path.join(testStoragePath, 'test-receipt.png')); }); it('should upload receipt without optional fields', async () => { const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64', ); const response = await request .post('/api/receipts') .set('Authorization', `Bearer ${authToken}`) .attach('receipt', testImageBuffer, 'test-receipt-2.png'); expect(response.status).toBe(201); expect(response.body.data.receipt_id).toBeDefined(); createdReceiptIds.push(response.body.data.receipt_id); // Track the uploaded file for cleanup createdFilePaths.push(path.join(testStoragePath, 'test-receipt-2.png')); }); it('should reject request without file', async () => { const response = await request .post('/api/receipts') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(400); }); it('should reject unauthenticated requests', async () => { const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64', ); const response = await request .post('/api/receipts') .attach('receipt', testImageBuffer, 'test-receipt.png'); expect(response.status).toBe(401); }); }); describe('GET /api/receipts - List Receipts', () => { beforeAll(async () => { // Create some receipts for testing const pool = getPool(); for (let i = 0; i < 3; i++) { const result = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status) VALUES ($1, $2, $3) RETURNING receipt_id`, [ testUser.user.user_id, `/uploads/receipts/test-${i}.jpg`, i === 0 ? 'completed' : 'pending', ], ); createdReceiptIds.push(result.rows[0].receipt_id); } }); it('should return paginated list of receipts', async () => { const response = await request .get('/api/receipts') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.receipts).toBeDefined(); expect(Array.isArray(response.body.data.receipts)).toBe(true); expect(response.body.data.total).toBeGreaterThanOrEqual(3); }); it('should support status filter', async () => { const response = await request .get('/api/receipts') .query({ status: 'completed' }) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); response.body.data.receipts.forEach((receipt: { status: string }) => { expect(receipt.status).toBe('completed'); }); }); it('should support pagination', async () => { const response = await request .get('/api/receipts') .query({ limit: 2, offset: 0 }) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data.receipts.length).toBeLessThanOrEqual(2); }); it('should only return receipts for the authenticated user', async () => { // Create another user const { user: otherUser, token: otherToken } = await createAndLoginUser({ email: `other-receipt-user-${Date.now()}@example.com`, fullName: 'Other Receipt User', request, }); createdUserIds.push(otherUser.user.user_id); const response = await request .get('/api/receipts') .set('Authorization', `Bearer ${otherToken}`); expect(response.status).toBe(200); // Other user should have no receipts expect(response.body.data.total).toBe(0); }); }); describe('GET /api/receipts/:receiptId - Get Receipt Details', () => { let testReceiptId: number; beforeAll(async () => { const pool = getPool(); // First create or get a test store const store = await createStoreWithLocation(pool, { name: `Receipt Test Store - ${Date.now()}`, address: '999 Receipt St', city: 'Toronto', province: 'ON', postalCode: 'M5V 4A4', }); createdStoreLocations.push(store); const result = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status, store_location_id, total_amount_cents) VALUES ($1, $2, 'completed', $3, 9999) RETURNING receipt_id`, [testUser.user.user_id, '/uploads/receipts/detail-test.jpg', store.storeLocationId], ); testReceiptId = result.rows[0].receipt_id; createdReceiptIds.push(testReceiptId); // Add some items to the receipt await pool.query( `INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status) VALUES ($1, 'MILK 2% 4L', 1, 599, 'matched'), ($1, 'BREAD WHITE', 2, 498, 'unmatched')`, [testReceiptId], ); }); it('should return receipt with items', async () => { const response = await request .get(`/api/receipts/${testReceiptId}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.receipt).toBeDefined(); expect(response.body.data.receipt.receipt_id).toBe(testReceiptId); expect(response.body.data.receipt.store_location_id).toBeDefined(); expect(response.body.data.items).toBeDefined(); expect(response.body.data.items.length).toBe(2); }); it('should return 404 for non-existent receipt', async () => { const response = await request .get('/api/receipts/999999') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(404); }); it("should not allow accessing another user's receipt", async () => { const { user: otherUser, token: otherToken } = await createAndLoginUser({ email: `receipt-access-test-${Date.now()}@example.com`, fullName: 'Receipt Access Test', request, }); createdUserIds.push(otherUser.user.user_id); const response = await request .get(`/api/receipts/${testReceiptId}`) .set('Authorization', `Bearer ${otherToken}`); expect(response.status).toBe(404); }); }); describe('DELETE /api/receipts/:receiptId - Delete Receipt', () => { it('should delete a receipt', async () => { // Create a receipt to delete const pool = getPool(); const result = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status) VALUES ($1, '/uploads/receipts/delete-test.jpg', 'pending') RETURNING receipt_id`, [testUser.user.user_id], ); const receiptId = result.rows[0].receipt_id; const response = await request .delete(`/api/receipts/${receiptId}`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(204); // Verify deletion const verifyResponse = await request .get(`/api/receipts/${receiptId}`) .set('Authorization', `Bearer ${authToken}`); expect(verifyResponse.status).toBe(404); }); }); describe('POST /api/receipts/:receiptId/reprocess - Reprocess Receipt', () => { let failedReceiptId: number; beforeAll(async () => { const pool = getPool(); const result = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status, error_details) VALUES ($1, '/uploads/receipts/failed-test.jpg', 'failed', '{"message": "OCR failed"}'::jsonb) RETURNING receipt_id`, [testUser.user.user_id], ); failedReceiptId = result.rows[0].receipt_id; createdReceiptIds.push(failedReceiptId); }); it('should queue a failed receipt for reprocessing', async () => { const response = await request .post(`/api/receipts/${failedReceiptId}/reprocess`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.message).toContain('reprocessing'); expect(response.body.data.job_id).toBeDefined(); // Real queue job ID }); it('should return 404 for non-existent receipt', async () => { const response = await request .post('/api/receipts/999999/reprocess') .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(404); }); }); describe('Receipt Items Management', () => { let receiptWithItemsId: number; let testItemId: number; beforeAll(async () => { const pool = getPool(); const receiptResult = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status) VALUES ($1, '/uploads/receipts/items-test.jpg', 'completed') RETURNING receipt_id`, [testUser.user.user_id], ); receiptWithItemsId = receiptResult.rows[0].receipt_id; createdReceiptIds.push(receiptWithItemsId); const itemResult = await pool.query( `INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status) VALUES ($1, 'EGGS LARGE 12CT', 1, 499, 'unmatched') RETURNING receipt_item_id`, [receiptWithItemsId], ); testItemId = itemResult.rows[0].receipt_item_id; }); describe('GET /api/receipts/:receiptId/items', () => { it('should return all receipt items', async () => { const response = await request .get(`/api/receipts/${receiptWithItemsId}/items`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data.items).toBeDefined(); expect(response.body.data.items.length).toBeGreaterThanOrEqual(1); expect(response.body.data.total).toBeGreaterThanOrEqual(1); }); }); describe('PUT /api/receipts/:receiptId/items/:itemId', () => { it('should update item status', async () => { const response = await request .put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`) .set('Authorization', `Bearer ${authToken}`) .send({ status: 'matched', match_confidence: 0.95 }); expect(response.status).toBe(200); expect(response.body.data.status).toBe('matched'); }); it('should reject invalid status', async () => { const response = await request .put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`) .set('Authorization', `Bearer ${authToken}`) .send({ status: 'invalid_status' }); expect(response.status).toBe(400); }); }); describe('GET /api/receipts/:receiptId/items/unadded', () => { it('should return unadded items', async () => { const response = await request .get(`/api/receipts/${receiptWithItemsId}/items/unadded`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.data.items).toBeDefined(); expect(Array.isArray(response.body.data.items)).toBe(true); }); }); }); describe('POST /api/receipts/:receiptId/confirm - Confirm Items to Inventory', () => { let receiptForConfirmId: number; let itemToConfirmId: number; beforeAll(async () => { const pool = getPool(); const receiptResult = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status) VALUES ($1, '/uploads/receipts/confirm-test.jpg', 'completed') RETURNING receipt_id`, [testUser.user.user_id], ); receiptForConfirmId = receiptResult.rows[0].receipt_id; createdReceiptIds.push(receiptForConfirmId); const itemResult = await pool.query( `INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status, added_to_pantry) VALUES ($1, 'YOGURT GREEK', 2, 798, 'matched', false) RETURNING receipt_item_id`, [receiptForConfirmId], ); itemToConfirmId = itemResult.rows[0].receipt_item_id; }); it('should confirm items and add to inventory', async () => { const response = await request .post(`/api/receipts/${receiptForConfirmId}/confirm`) .set('Authorization', `Bearer ${authToken}`) .send({ items: [ { receipt_item_id: itemToConfirmId, include: true, item_name: 'Greek Yogurt', quantity: 2, location: 'fridge', expiry_date: '2024-02-15', }, ], }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.added_items).toBeDefined(); expect(response.body.data.count).toBeGreaterThanOrEqual(0); // Track created inventory items for cleanup if (response.body.data.added_items) { response.body.data.added_items.forEach((item: { inventory_id: number }) => { if (item.inventory_id) { createdInventoryIds.push(item.inventory_id); } }); } }); it('should skip items with include: false', async () => { const pool = getPool(); const itemResult = await pool.query( `INSERT INTO public.receipt_items (receipt_id, raw_item_description, quantity, price_paid_cents, status, added_to_pantry) VALUES ($1, 'CHIPS BBQ', 1, 499, 'matched', false) RETURNING receipt_item_id`, [receiptForConfirmId], ); const skipItemId = itemResult.rows[0].receipt_item_id; const response = await request .post(`/api/receipts/${receiptForConfirmId}/confirm`) .set('Authorization', `Bearer ${authToken}`) .send({ items: [ { receipt_item_id: skipItemId, include: false, }, ], }); expect(response.status).toBe(200); // No items should be added when all are excluded }); it('should reject invalid location', async () => { const response = await request .post(`/api/receipts/${receiptForConfirmId}/confirm`) .set('Authorization', `Bearer ${authToken}`) .send({ items: [ { receipt_item_id: itemToConfirmId, include: true, location: 'invalid_location', }, ], }); expect(response.status).toBe(400); }); }); describe('GET /api/receipts/:receiptId/logs - Processing Logs', () => { let receiptWithLogsId: number; beforeAll(async () => { const pool = getPool(); const receiptResult = await pool.query( `INSERT INTO public.receipts (user_id, receipt_image_url, status) VALUES ($1, '/uploads/receipts/logs-test.jpg', 'completed') RETURNING receipt_id`, [testUser.user.user_id], ); receiptWithLogsId = receiptResult.rows[0].receipt_id; createdReceiptIds.push(receiptWithLogsId); // Add processing logs - using correct table name and column names // processing_step must be one of: upload, ocr_extraction, text_parsing, store_detection, // item_extraction, item_matching, price_parsing, finalization await pool.query( `INSERT INTO public.receipt_processing_log (receipt_id, processing_step, status, error_message) VALUES ($1, 'ocr_extraction', 'completed', 'OCR completed successfully'), ($1, 'item_extraction', 'completed', 'Extracted 5 items'), ($1, 'item_matching', 'completed', 'Matched 3 items')`, [receiptWithLogsId], ); }); it('should return processing logs', async () => { const response = await request .get(`/api/receipts/${receiptWithLogsId}/logs`) .set('Authorization', `Bearer ${authToken}`); expect(response.status).toBe(200); expect(response.body.success).toBe(true); expect(response.body.data.logs).toBeDefined(); expect(response.body.data.logs.length).toBe(3); expect(response.body.data.total).toBe(3); }); }); describe('Complete Receipt Workflow', () => { it('should handle full upload-process-confirm workflow', async () => { // Note: Full workflow with actual processing would require BullMQ worker // This test verifies the API contract works correctly // Step 1: Upload receipt const testImageBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64', ); const uploadResponse = await request .post('/api/receipts') .set('Authorization', `Bearer ${authToken}`) .attach('receipt', testImageBuffer, 'workflow-test.png') .field('transaction_date', '2024-01-20'); expect(uploadResponse.status).toBe(201); const receiptId = uploadResponse.body.data.receipt_id; createdReceiptIds.push(receiptId); // Step 2: Verify receipt was created const getResponse = await request .get(`/api/receipts/${receiptId}`) .set('Authorization', `Bearer ${authToken}`); expect(getResponse.status).toBe(200); expect(getResponse.body.data.receipt.receipt_id).toBe(receiptId); // Step 3: Check it appears in list const listResponse = await request .get('/api/receipts') .set('Authorization', `Bearer ${authToken}`); expect(listResponse.status).toBe(200); const found = listResponse.body.data.receipts.find( (r: { receipt_id: number }) => r.receipt_id === receiptId, ); expect(found).toBeDefined(); // Step 4: Verify logs endpoint works (empty for new receipt) const logsResponse = await request .get(`/api/receipts/${receiptId}/logs`) .set('Authorization', `Bearer ${authToken}`); expect(logsResponse.status).toBe(200); expect(Array.isArray(logsResponse.body.data.logs)).toBe(true); }); }); });