Files
flyer-crawler.projectium.com/src/tests/integration/receipt.integration.test.ts
Torben Sorensen 1696aeb54f
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 18m42s
minor fixin
2026-01-19 21:28:44 -08:00

733 lines
26 KiB
TypeScript

// 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<typeof supertest>;
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);
});
});
});