Files
flyer-crawler.projectium.com/src/tests/integration/receipt.integration.test.ts
Torben Sorensen 4e06dde9e1
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m30s
logging work - almost there
2026-01-12 16:57:18 -08:00

637 lines
22 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 } from 'vitest';
import supertest from 'supertest';
import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import { getPool } from '../../services/db/connection.db';
/**
* @vitest-environment node
*/
// Mock Bull Board to prevent BullMQAdapter from validating queue instances
vi.mock('@bull-board/api', () => ({
createBullBoard: vi.fn(),
}));
vi.mock('@bull-board/api/bullMQAdapter', () => ({
BullMQAdapter: vi.fn(),
}));
// Mock the queues to prevent actual background processing
// IMPORTANT: Must include all queue exports that are imported by workers.server.ts
vi.mock('../../services/queues.server', () => ({
receiptQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-job-id' }),
},
cleanupQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-cleanup-job-id' }),
},
flyerQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-flyer-job-id' }),
},
emailQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-email-job-id' }),
},
analyticsQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-analytics-job-id' }),
},
weeklyAnalyticsQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-weekly-analytics-job-id' }),
},
tokenCleanupQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-token-cleanup-job-id' }),
},
expiryAlertQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-expiry-alert-job-id' }),
},
barcodeDetectionQueue: {
add: vi.fn().mockResolvedValue({ id: 'mock-barcode-job-id' }),
},
}));
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[] = [];
beforeAll(async () => {
vi.stubEnv('FRONTEND_URL', 'https://example.com');
const app = (await import('../../../server')).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);
});
afterAll(async () => {
vi.unstubAllEnvs();
const pool = getPool();
// Clean up inventory items
if (createdInventoryIds.length > 0) {
await pool.query('DELETE FROM public.user_inventory WHERE inventory_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 });
});
describe('POST /api/receipts - Upload Receipt', () => {
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_id', '1')
.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).toBe('mock-job-id');
createdReceiptIds.push(response.body.data.receipt_id);
});
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);
});
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 storeResult = await pool.query(
`INSERT INTO public.stores (name)
VALUES ('Test Store')
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING store_id`,
);
const storeId = storeResult.rows[0].store_id;
const result = await pool.query(
`INSERT INTO public.receipts (user_id, receipt_image_url, status, store_id, total_amount_cents)
VALUES ($1, $2, 'completed', $3, 9999)
RETURNING receipt_id`,
[testUser.user.user_id, '/uploads/receipts/detail-test.jpg', storeId],
);
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_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).toBe('mock-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);
});
});
});