Files
flyer-crawler.projectium.com/src/routes/receipt.routes.test.ts
Torben Sorensen faf2900c28
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m43s
unit test repairs
2026-01-12 10:58:00 -08:00

786 lines
25 KiB
TypeScript

// src/routes/receipt.routes.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import receiptRouter from './receipt.routes';
import type { ReceiptStatus, ReceiptItemStatus } from '../types/expiry';
import { NotFoundError } from '../services/db/errors.db';
// Test state - must be declared before vi.mock calls that reference them
let mockUser: ReturnType<typeof createMockUserProfile> | null = null;
let mockFile: Express.Multer.File | null = null;
// Mock passport
vi.mock('../config/passport', () => ({
default: {
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
if (mockUser) {
req.user = mockUser;
next();
} else {
res.status(401).json({ success: false, error: { message: 'Unauthorized' } });
}
}),
initialize: () => (req: any, res: any, next: any) => next(),
},
}));
// Mock receipt service
vi.mock('../services/receiptService.server', () => ({
getReceipts: vi.fn(),
createReceipt: vi.fn(),
getReceiptById: vi.fn(),
deleteReceipt: vi.fn(),
getReceiptItems: vi.fn(),
updateReceiptItem: vi.fn(),
getUnaddedItems: vi.fn(),
getProcessingLogs: vi.fn(),
}));
// Mock expiry service
vi.mock('../services/expiryService.server', () => ({
addItemsFromReceipt: vi.fn(),
}));
// Mock receipt queue
vi.mock('../services/queues.server', () => ({
receiptQueue: {
add: vi.fn(),
},
}));
// Mock multer middleware
vi.mock('../middleware/multer.middleware', () => {
return {
createUploadMiddleware: vi.fn(() => ({
single: vi.fn(() => (req: any, _res: any, next: any) => {
// Simulate file upload by setting req.file
if (mockFile) {
req.file = mockFile;
}
// Multer also parses the body fields from multipart form data.
// Since we're mocking multer, we need to ensure req.body is an object.
// Supertest with .field() sends data as multipart which express.json() doesn't parse.
// The actual field data won't be in req.body from supertest when multer is mocked,
// so we leave req.body as-is (express.json() will have parsed JSON requests,
// and for multipart we need to ensure body is at least an empty object).
if (req.body === undefined) {
req.body = {};
}
next();
}),
})),
handleMulterError: vi.fn((err: any, _req: any, res: any, next: any) => {
// Only handle multer-specific errors, pass others to the error handler
if (err && err.name === 'MulterError') {
return res.status(400).json({ success: false, error: { message: err.message } });
}
// Pass non-multer errors to the next error handler
next(err);
}),
};
});
// Mock file upload middleware
vi.mock('../middleware/fileUpload.middleware', () => ({
requireFileUpload: vi.fn(() => (req: any, res: any, next: any) => {
if (!req.file) {
return res.status(400).json({
success: false,
error: { message: 'File is required' },
});
}
next();
}),
}));
import * as receiptService from '../services/receiptService.server';
import * as expiryService from '../services/expiryService.server';
import { receiptQueue } from '../services/queues.server';
// Helper to create mock receipt (ReceiptScan type)
function createMockReceipt(overrides: { status?: ReceiptStatus; [key: string]: unknown } = {}) {
return {
receipt_id: 1,
user_id: 'user-123',
receipt_image_url: '/uploads/receipts/receipt-123.jpg',
store_id: null,
transaction_date: null,
total_amount_cents: null,
status: 'pending' as ReceiptStatus,
raw_text: null,
store_confidence: null,
ocr_provider: null,
error_details: null,
retry_count: 0,
ocr_confidence: null,
currency: 'USD',
created_at: '2024-01-15T10:00:00Z',
processed_at: null,
updated_at: '2024-01-15T10:00:00Z',
...overrides,
};
}
// Helper to create mock receipt item (ReceiptItem type)
function createMockReceiptItem(
overrides: { status?: ReceiptItemStatus; [key: string]: unknown } = {},
) {
return {
receipt_item_id: 1,
receipt_id: 1,
raw_item_description: 'MILK 2% 4L',
quantity: 1,
price_paid_cents: 599,
master_item_id: null,
product_id: null,
status: 'unmatched' as ReceiptItemStatus,
line_number: 1,
match_confidence: null,
is_discount: false,
unit_price_cents: null,
unit_type: null,
added_to_pantry: false,
pantry_item_id: null,
upc_code: null,
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T10:00:00Z',
...overrides,
};
}
// Helper to create mock processing log (ReceiptProcessingLogRecord type)
function createMockProcessingLog(overrides: Record<string, unknown> = {}) {
return {
log_id: 1,
receipt_id: 1,
processing_step: 'upload' as const,
status: 'completed' as const,
provider: null,
duration_ms: null,
tokens_used: null,
cost_cents: null,
input_data: null,
output_data: null,
error_message: null,
created_at: '2024-01-15T10:00:00Z',
...overrides,
};
}
describe('Receipt Routes', () => {
let app: ReturnType<typeof createTestApp>;
beforeEach(() => {
vi.clearAllMocks();
mockUser = createMockUserProfile();
mockFile = null;
app = createTestApp({
router: receiptRouter,
basePath: '/receipts',
authenticatedUser: mockUser,
});
});
afterEach(() => {
vi.resetAllMocks();
mockUser = null;
mockFile = null;
});
describe('GET /receipts', () => {
it('should return user receipts with default pagination', async () => {
const mockReceipts = [createMockReceipt(), createMockReceipt({ receipt_id: 2 })];
vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
receipts: mockReceipts,
total: 2,
});
const response = await request(app).get('/receipts');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.receipts).toHaveLength(2);
expect(receiptService.getReceipts).toHaveBeenCalledWith(
expect.objectContaining({
user_id: mockUser!.user.user_id,
limit: 50,
offset: 0,
}),
expect.anything(),
);
});
it('should support status filter', async () => {
vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
receipts: [createMockReceipt({ status: 'completed' })],
total: 1,
});
const response = await request(app).get('/receipts?status=completed');
expect(response.status).toBe(200);
expect(receiptService.getReceipts).toHaveBeenCalledWith(
expect.objectContaining({ status: 'completed' }),
expect.anything(),
);
});
it('should support store_id filter', async () => {
vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
receipts: [createMockReceipt({ store_id: 5 })],
total: 1,
});
const response = await request(app).get('/receipts?store_id=5');
expect(response.status).toBe(200);
expect(receiptService.getReceipts).toHaveBeenCalledWith(
expect.objectContaining({ store_id: 5 }),
expect.anything(),
);
});
it('should support date range filter', async () => {
vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
receipts: [],
total: 0,
});
const response = await request(app).get('/receipts?from_date=2024-01-01&to_date=2024-01-31');
expect(response.status).toBe(200);
expect(receiptService.getReceipts).toHaveBeenCalledWith(
expect.objectContaining({
from_date: '2024-01-01',
to_date: '2024-01-31',
}),
expect.anything(),
);
});
it('should support pagination', async () => {
vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({
receipts: [],
total: 100,
});
const response = await request(app).get('/receipts?limit=10&offset=20');
expect(response.status).toBe(200);
expect(receiptService.getReceipts).toHaveBeenCalledWith(
expect.objectContaining({ limit: 10, offset: 20 }),
expect.anything(),
);
});
it('should reject invalid status', async () => {
const response = await request(app).get('/receipts?status=invalid');
expect(response.status).toBe(400);
});
it('should handle service error', async () => {
vi.mocked(receiptService.getReceipts).mockRejectedValueOnce(new Error('DB error'));
const response = await request(app).get('/receipts');
expect(response.status).toBe(500);
});
});
describe('POST /receipts', () => {
beforeEach(() => {
mockFile = {
fieldname: 'receipt',
originalname: 'receipt.jpg',
encoding: '7bit',
mimetype: 'image/jpeg',
destination: '/uploads/receipts',
filename: 'receipt-123.jpg',
path: '/uploads/receipts/receipt-123.jpg',
size: 1024000,
} as Express.Multer.File;
});
it('should upload receipt and queue for processing', async () => {
const mockReceipt = createMockReceipt();
vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-123' } as any);
// Send JSON body instead of form fields since multer is mocked and doesn't parse form data
const response = await request(app)
.post('/receipts')
.send({ store_id: '1', transaction_date: '2024-01-15' });
expect(response.status).toBe(201);
expect(response.body.success).toBe(true);
expect(response.body.data.receipt_id).toBe(1);
expect(response.body.data.job_id).toBe('job-123');
expect(receiptService.createReceipt).toHaveBeenCalledWith(
mockUser!.user.user_id,
'/uploads/receipts/receipt-123.jpg',
expect.anything(),
expect.objectContaining({
storeId: 1,
transactionDate: '2024-01-15',
}),
);
expect(receiptQueue.add).toHaveBeenCalledWith(
'process-receipt',
expect.objectContaining({
receiptId: 1,
userId: mockUser!.user.user_id,
imagePath: '/uploads/receipts/receipt-123.jpg',
}),
expect.objectContaining({
jobId: 'receipt-1',
}),
);
});
it('should upload receipt without optional fields', async () => {
const mockReceipt = createMockReceipt();
vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-456' } as any);
const response = await request(app).post('/receipts');
expect(response.status).toBe(201);
expect(receiptService.createReceipt).toHaveBeenCalledWith(
mockUser!.user.user_id,
'/uploads/receipts/receipt-123.jpg',
expect.anything(),
expect.objectContaining({
storeId: undefined,
transactionDate: undefined,
}),
);
});
it('should reject request without file', async () => {
mockFile = null;
const response = await request(app).post('/receipts');
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('File is required');
});
it('should handle service error', async () => {
vi.mocked(receiptService.createReceipt).mockRejectedValueOnce(new Error('Storage error'));
const response = await request(app).post('/receipts');
expect(response.status).toBe(500);
});
});
describe('GET /receipts/:receiptId', () => {
it('should return receipt with items', async () => {
const mockReceipt = createMockReceipt();
const mockItems = [createMockReceiptItem(), createMockReceiptItem({ receipt_item_id: 2 })];
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.getReceiptItems).mockResolvedValueOnce(mockItems);
const response = await request(app).get('/receipts/1');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.receipt.receipt_id).toBe(1);
expect(response.body.data.items).toHaveLength(2);
expect(receiptService.getReceiptById).toHaveBeenCalledWith(
1,
mockUser!.user.user_id,
expect.anything(),
);
});
it('should return 404 for non-existent receipt', async () => {
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
new NotFoundError('Receipt not found'),
);
const response = await request(app).get('/receipts/999');
expect(response.status).toBe(404);
});
it('should reject invalid receipt ID', async () => {
const response = await request(app).get('/receipts/invalid');
expect(response.status).toBe(400);
});
});
describe('DELETE /receipts/:receiptId', () => {
it('should delete receipt successfully', async () => {
vi.mocked(receiptService.deleteReceipt).mockResolvedValueOnce(undefined);
const response = await request(app).delete('/receipts/1');
expect(response.status).toBe(204);
expect(receiptService.deleteReceipt).toHaveBeenCalledWith(
1,
mockUser!.user.user_id,
expect.anything(),
);
});
it('should return 404 for non-existent receipt', async () => {
vi.mocked(receiptService.deleteReceipt).mockRejectedValueOnce(
new NotFoundError('Receipt not found'),
);
const response = await request(app).delete('/receipts/999');
expect(response.status).toBe(404);
});
});
describe('POST /receipts/:receiptId/reprocess', () => {
it('should queue receipt for reprocessing', async () => {
const mockReceipt = createMockReceipt({ status: 'failed' });
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'reprocess-job-123' } as any);
const response = await request(app).post('/receipts/1/reprocess');
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('reprocess-job-123');
expect(receiptQueue.add).toHaveBeenCalledWith(
'process-receipt',
expect.objectContaining({
receiptId: 1,
imagePath: mockReceipt.receipt_image_url,
}),
expect.objectContaining({
jobId: expect.stringMatching(/^receipt-1-reprocess-\d+$/),
}),
);
});
it('should return 404 for non-existent receipt', async () => {
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
new NotFoundError('Receipt not found'),
);
const response = await request(app).post('/receipts/999/reprocess');
expect(response.status).toBe(404);
});
});
describe('GET /receipts/:receiptId/items', () => {
it('should return receipt items', async () => {
const mockReceipt = createMockReceipt();
const mockItems = [
createMockReceiptItem(),
createMockReceiptItem({ receipt_item_id: 2, parsed_name: 'Bread' }),
];
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.getReceiptItems).mockResolvedValueOnce(mockItems);
const response = await request(app).get('/receipts/1/items');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.items).toHaveLength(2);
expect(response.body.data.total).toBe(2);
});
it('should return 404 if receipt not found', async () => {
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
new NotFoundError('Receipt not found'),
);
const response = await request(app).get('/receipts/999/items');
expect(response.status).toBe(404);
});
});
describe('PUT /receipts/:receiptId/items/:itemId', () => {
it('should update receipt item status', async () => {
const mockReceipt = createMockReceipt();
const updatedItem = createMockReceiptItem({ status: 'matched', match_confidence: 0.95 });
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.updateReceiptItem).mockResolvedValueOnce(updatedItem);
const response = await request(app)
.put('/receipts/1/items/1')
.send({ status: 'matched', match_confidence: 0.95 });
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.status).toBe('matched');
expect(receiptService.updateReceiptItem).toHaveBeenCalledWith(
1,
expect.objectContaining({ status: 'matched', match_confidence: 0.95 }),
expect.anything(),
);
});
it('should update item with master_item_id', async () => {
const mockReceipt = createMockReceipt();
const updatedItem = createMockReceiptItem({ master_item_id: 42 });
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.updateReceiptItem).mockResolvedValueOnce(updatedItem);
const response = await request(app).put('/receipts/1/items/1').send({ master_item_id: 42 });
expect(response.status).toBe(200);
expect(response.body.data.master_item_id).toBe(42);
});
it('should reject empty update body', async () => {
const response = await request(app).put('/receipts/1/items/1').send({});
expect(response.status).toBe(400);
});
it('should reject invalid status value', async () => {
const response = await request(app)
.put('/receipts/1/items/1')
.send({ status: 'invalid_status' });
expect(response.status).toBe(400);
});
it('should reject invalid match_confidence', async () => {
const response = await request(app)
.put('/receipts/1/items/1')
.send({ match_confidence: 1.5 });
expect(response.status).toBe(400);
});
});
describe('GET /receipts/:receiptId/items/unadded', () => {
it('should return unadded items', async () => {
const mockReceipt = createMockReceipt();
const mockItems = [
createMockReceiptItem({ added_to_inventory: false }),
createMockReceiptItem({ receipt_item_id: 2, added_to_inventory: false }),
];
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.getUnaddedItems).mockResolvedValueOnce(mockItems);
const response = await request(app).get('/receipts/1/items/unadded');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.items).toHaveLength(2);
expect(response.body.data.total).toBe(2);
});
it('should return empty array when all items added', async () => {
const mockReceipt = createMockReceipt();
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.getUnaddedItems).mockResolvedValueOnce([]);
const response = await request(app).get('/receipts/1/items/unadded');
expect(response.status).toBe(200);
expect(response.body.data.items).toHaveLength(0);
expect(response.body.data.total).toBe(0);
});
});
describe('POST /receipts/:receiptId/confirm', () => {
it('should confirm items for inventory', async () => {
const addedItems = [
{ inventory_id: 1, item_name: 'Milk 2%', quantity: 1 },
{ inventory_id: 2, item_name: 'Bread', quantity: 2 },
];
vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce(addedItems as any);
const response = await request(app)
.post('/receipts/1/confirm')
.send({
items: [
{ receipt_item_id: 1, include: true, location: 'fridge' },
{ receipt_item_id: 2, include: true, location: 'pantry', expiry_date: '2024-01-20' },
{ receipt_item_id: 3, include: false },
],
});
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.added_items).toHaveLength(2);
expect(response.body.data.count).toBe(2);
expect(expiryService.addItemsFromReceipt).toHaveBeenCalledWith(
mockUser!.user.user_id,
1,
expect.arrayContaining([
expect.objectContaining({ receipt_item_id: 1, include: true }),
expect.objectContaining({ receipt_item_id: 2, include: true }),
expect.objectContaining({ receipt_item_id: 3, include: false }),
]),
expect.anything(),
);
});
it('should accept custom item_name and quantity', async () => {
vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce([
{ inventory_id: 1, item_name: 'Custom Name', quantity: 5 },
] as any);
const response = await request(app)
.post('/receipts/1/confirm')
.send({
items: [
{
receipt_item_id: 1,
include: true,
item_name: 'Custom Name',
quantity: 5,
location: 'pantry',
},
],
});
expect(response.status).toBe(200);
expect(expiryService.addItemsFromReceipt).toHaveBeenCalledWith(
mockUser!.user.user_id,
1,
expect.arrayContaining([
expect.objectContaining({
item_name: 'Custom Name',
quantity: 5,
}),
]),
expect.anything(),
);
});
it('should accept empty items array', async () => {
// Empty array is technically valid, service decides what to do
vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce([]);
const response = await request(app).post('/receipts/1/confirm').send({ items: [] });
expect(response.status).toBe(200);
expect(response.body.data.count).toBe(0);
});
it('should reject missing items field', async () => {
const response = await request(app).post('/receipts/1/confirm').send({});
expect(response.status).toBe(400);
});
it('should reject invalid location', async () => {
const response = await request(app)
.post('/receipts/1/confirm')
.send({
items: [{ receipt_item_id: 1, include: true, location: 'invalid_location' }],
});
expect(response.status).toBe(400);
});
it('should reject invalid expiry_date format', async () => {
const response = await request(app)
.post('/receipts/1/confirm')
.send({
items: [{ receipt_item_id: 1, include: true, expiry_date: 'not-a-date' }],
});
expect(response.status).toBe(400);
});
it('should handle service error', async () => {
vi.mocked(expiryService.addItemsFromReceipt).mockRejectedValueOnce(
new Error('Failed to add items'),
);
const response = await request(app)
.post('/receipts/1/confirm')
.send({
items: [{ receipt_item_id: 1, include: true }],
});
expect(response.status).toBe(500);
});
});
describe('GET /receipts/:receiptId/logs', () => {
it('should return processing logs', async () => {
const mockReceipt = createMockReceipt();
const mockLogs = [
createMockProcessingLog({
processing_step: 'ocr_extraction' as const,
status: 'completed' as const,
}),
createMockProcessingLog({
log_id: 2,
processing_step: 'item_extraction' as const,
status: 'completed' as const,
}),
createMockProcessingLog({
log_id: 3,
processing_step: 'item_matching' as const,
status: 'started' as const,
}),
];
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.getProcessingLogs).mockResolvedValueOnce(mockLogs);
const response = await request(app).get('/receipts/1/logs');
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
expect(response.body.data.logs).toHaveLength(3);
expect(response.body.data.total).toBe(3);
});
it('should return empty logs for new receipt', async () => {
const mockReceipt = createMockReceipt();
vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptService.getProcessingLogs).mockResolvedValueOnce([]);
const response = await request(app).get('/receipts/1/logs');
expect(response.status).toBe(200);
expect(response.body.data.logs).toHaveLength(0);
expect(response.body.data.total).toBe(0);
});
it('should return 404 for non-existent receipt', async () => {
vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(
new NotFoundError('Receipt not found'),
);
const response = await request(app).get('/receipts/999/logs');
expect(response.status).toBe(404);
});
});
describe('Authentication', () => {
it('should reject unauthenticated requests', async () => {
mockUser = null;
app = createTestApp({
router: receiptRouter,
basePath: '/receipts',
authenticatedUser: undefined,
});
const response = await request(app).get('/receipts');
expect(response.status).toBe(401);
});
});
});