All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m43s
786 lines
25 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|