Files
flyer-crawler.projectium.com/src/services/receiptService.server.test.ts
Torben Sorensen 11aeac5edd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more !
2026-01-11 19:07:02 -08:00

792 lines
23 KiB
TypeScript

// src/services/receiptService.server.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Logger } from 'pino';
import type { Job } from 'bullmq';
import type { ReceiptJobData } from '../types/job-data';
import { createMockLogger } from '../tests/utils/mockLogger';
import type {
ReceiptStatus,
ReceiptItemStatus,
ReceiptProcessingStep,
ReceiptProcessingStatus,
OcrProvider,
ReceiptProcessingLogRecord,
} from '../types/expiry';
// Mock dependencies
vi.mock('./db/index.db', () => ({
receiptRepo: {
createReceipt: vi.fn(),
getReceiptById: vi.fn(),
getReceipts: vi.fn(),
updateReceipt: vi.fn(),
deleteReceipt: vi.fn(),
logProcessingStep: vi.fn(),
detectStoreFromText: vi.fn(),
addReceiptItems: vi.fn(),
incrementRetryCount: vi.fn(),
getReceiptItems: vi.fn(),
updateReceiptItem: vi.fn(),
getUnaddedReceiptItems: vi.fn(),
getProcessingLogs: vi.fn(),
getProcessingStats: vi.fn(),
getReceiptsNeedingProcessing: vi.fn(),
addStorePattern: vi.fn(),
getActiveStorePatterns: vi.fn(),
},
}));
vi.mock('../config/env', () => ({
isAiConfigured: false,
config: {
gemini: {
apiKey: undefined,
},
},
}));
vi.mock('./aiService.server', () => ({
aiService: {
extractItemsFromReceiptImage: vi.fn(),
},
}));
vi.mock('./logger.server', () => ({
logger: {
child: vi.fn().mockReturnThis(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('node:fs/promises', () => ({
default: {
access: vi.fn(),
},
}));
// Import after mocks are set up
import {
createReceipt,
getReceiptById,
getReceipts,
deleteReceipt,
processReceipt,
getReceiptItems,
updateReceiptItem,
getUnaddedItems,
getProcessingLogs,
getProcessingStats,
getReceiptsNeedingProcessing,
addStorePattern,
getActiveStorePatterns,
processReceiptJob,
} from './receiptService.server';
import { receiptRepo } from './db/index.db';
// Helper to create mock processing log record
function createMockProcessingLogRecord(
overrides: Partial<ReceiptProcessingLogRecord> = {},
): ReceiptProcessingLogRecord {
return {
log_id: 1,
receipt_id: 1,
processing_step: 'upload' as ReceiptProcessingStep,
status: 'completed' as ReceiptProcessingStatus,
provider: null,
duration_ms: null,
tokens_used: null,
cost_cents: null,
input_data: null,
output_data: null,
error_message: null,
created_at: new Date().toISOString(),
...overrides,
};
}
// Helper to create mock store pattern row
interface StoreReceiptPatternRow {
pattern_id: number;
store_id: number;
pattern_type: string;
pattern_value: string;
priority: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
function createMockStorePatternRow(
overrides: Partial<StoreReceiptPatternRow> = {},
): StoreReceiptPatternRow {
return {
pattern_id: 1,
store_id: 1,
pattern_type: 'name',
pattern_value: 'WALMART',
priority: 0,
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
describe('receiptService.server', () => {
let mockLogger: Logger;
beforeEach(() => {
vi.clearAllMocks();
mockLogger = createMockLogger();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('createReceipt', () => {
it('should create a new receipt and log upload step', async () => {
const mockReceipt = {
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.createReceipt).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValueOnce(
createMockProcessingLogRecord(),
);
const result = await createReceipt('user-1', '/uploads/receipt.jpg', mockLogger);
expect(result.receipt_id).toBe(1);
expect(receiptRepo.createReceipt).toHaveBeenCalledWith(
{
user_id: 'user-1',
receipt_image_url: '/uploads/receipt.jpg',
store_id: undefined,
transaction_date: undefined,
},
mockLogger,
);
expect(receiptRepo.logProcessingStep).toHaveBeenCalledWith(
1,
'upload',
'completed',
mockLogger,
expect.any(Object),
);
});
it('should create receipt with optional store ID and transaction date', async () => {
const mockReceipt = {
receipt_id: 2,
user_id: 'user-1',
store_id: 5,
receipt_image_url: '/uploads/receipt2.jpg',
transaction_date: '2024-01-15',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.createReceipt).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValueOnce(
createMockProcessingLogRecord(),
);
const result = await createReceipt('user-1', '/uploads/receipt2.jpg', mockLogger, {
storeId: 5,
transactionDate: '2024-01-15',
});
expect(result.store_id).toBe(5);
expect(result.transaction_date).toBe('2024-01-15');
});
});
describe('getReceiptById', () => {
it('should return receipt by ID', async () => {
const mockReceipt = {
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
const result = await getReceiptById(1, 'user-1', mockLogger);
expect(result.receipt_id).toBe(1);
expect(receiptRepo.getReceiptById).toHaveBeenCalledWith(1, 'user-1', mockLogger);
});
});
describe('getReceipts', () => {
it('should return paginated receipts for user', async () => {
const mockReceipts = {
receipts: [
{
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt1.jpg',
transaction_date: null,
total_amount_cents: null,
status: 'completed' as ReceiptStatus,
raw_text: null,
store_confidence: null,
ocr_provider: null,
error_details: null,
retry_count: 0,
ocr_confidence: null,
currency: 'USD',
created_at: new Date().toISOString(),
processed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
],
total: 1,
};
vi.mocked(receiptRepo.getReceipts).mockResolvedValueOnce(mockReceipts);
const result = await getReceipts({ user_id: 'user-1', limit: 10, offset: 0 }, mockLogger);
expect(result.receipts).toHaveLength(1);
expect(result.total).toBe(1);
});
it('should filter by status', async () => {
vi.mocked(receiptRepo.getReceipts).mockResolvedValueOnce({ receipts: [], total: 0 });
await getReceipts({ user_id: 'user-1', status: 'completed' }, mockLogger);
expect(receiptRepo.getReceipts).toHaveBeenCalledWith(
{ user_id: 'user-1', status: 'completed' },
mockLogger,
);
});
});
describe('deleteReceipt', () => {
it('should delete receipt', async () => {
vi.mocked(receiptRepo.deleteReceipt).mockResolvedValueOnce(undefined);
await deleteReceipt(1, 'user-1', mockLogger);
expect(receiptRepo.deleteReceipt).toHaveBeenCalledWith(1, 'user-1', mockLogger);
});
});
describe('processReceipt', () => {
it('should process receipt and return items when AI not configured', async () => {
const mockReceipt = {
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
const mockUpdatedReceipt = { ...mockReceipt, status: 'processing' };
const mockCompletedReceipt = { ...mockReceipt, status: 'completed' };
vi.mocked(receiptRepo.updateReceipt)
.mockResolvedValueOnce(mockUpdatedReceipt as any) // status: processing
.mockResolvedValueOnce({ ...mockUpdatedReceipt, raw_text: '[AI not configured]' } as any) // raw_text update
.mockResolvedValueOnce(mockCompletedReceipt as any); // status: completed
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
const result = await processReceipt(1, mockLogger);
expect(result.receipt.status).toBe('completed');
expect(receiptRepo.updateReceipt).toHaveBeenCalledWith(
1,
{ status: 'processing' },
expect.any(Object),
);
});
it('should detect store from receipt text', async () => {
const mockReceipt = {
receipt_id: 2,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce({
store_id: 10,
confidence: 0.9,
});
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
await processReceipt(2, mockLogger);
expect(receiptRepo.updateReceipt).toHaveBeenCalledWith(
2,
expect.objectContaining({ store_id: 10, store_confidence: 0.9 }),
expect.any(Object),
);
});
it('should handle processing errors', async () => {
vi.mocked(receiptRepo.updateReceipt).mockRejectedValueOnce(new Error('DB error'));
vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
await expect(processReceipt(1, mockLogger)).rejects.toThrow('DB error');
expect(receiptRepo.incrementRetryCount).toHaveBeenCalledWith(1, expect.any(Object));
});
});
describe('getReceiptItems', () => {
it('should return receipt items', async () => {
const mockItems = [
{
receipt_item_id: 1,
receipt_id: 1,
raw_item_description: 'MILK 2%',
quantity: 1,
price_paid_cents: 399,
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: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
vi.mocked(receiptRepo.getReceiptItems).mockResolvedValueOnce(mockItems);
const result = await getReceiptItems(1, mockLogger);
expect(result).toHaveLength(1);
expect(result[0].raw_item_description).toBe('MILK 2%');
});
});
describe('updateReceiptItem', () => {
it('should update receipt item', async () => {
const mockUpdatedItem = {
receipt_item_id: 1,
receipt_id: 1,
raw_item_description: 'MILK 2%',
quantity: 2,
price_paid_cents: 399,
master_item_id: 5,
product_id: null,
status: 'matched' as ReceiptItemStatus,
line_number: 1,
match_confidence: 0.95,
is_discount: false,
unit_price_cents: null,
unit_type: null,
added_to_pantry: false,
pantry_item_id: null,
upc_code: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.updateReceiptItem).mockResolvedValueOnce(mockUpdatedItem);
const result = await updateReceiptItem(
1,
{ master_item_id: 5, status: 'matched' as ReceiptItemStatus, match_confidence: 0.95 },
mockLogger,
);
expect(result.quantity).toBe(2);
expect(result.master_item_id).toBe(5);
expect(result.status).toBe('matched');
});
});
describe('getUnaddedItems', () => {
it('should return items not yet added to pantry', async () => {
const mockItems = [
{
receipt_item_id: 1,
receipt_id: 1,
raw_item_description: 'BREAD',
quantity: 1,
price_paid_cents: 299,
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: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
vi.mocked(receiptRepo.getUnaddedReceiptItems).mockResolvedValueOnce(mockItems);
const result = await getUnaddedItems(1, mockLogger);
expect(result).toHaveLength(1);
expect(result[0].added_to_pantry).toBe(false);
});
});
describe('getProcessingLogs', () => {
it('should return processing logs for receipt', async () => {
const mockLogs = [
{
log_id: 1,
receipt_id: 1,
processing_step: 'upload' as ReceiptProcessingStep,
status: 'completed' as ReceiptProcessingStatus,
provider: 'internal' as OcrProvider,
duration_ms: 50,
tokens_used: null,
cost_cents: null,
input_data: null,
output_data: null,
error_message: null,
created_at: new Date().toISOString(),
},
];
vi.mocked(receiptRepo.getProcessingLogs).mockResolvedValueOnce(mockLogs);
const result = await getProcessingLogs(1, mockLogger);
expect(result).toHaveLength(1);
expect(result[0].processing_step).toBe('upload');
});
});
describe('getProcessingStats', () => {
it('should return processing statistics', async () => {
const mockStats = {
total_receipts: 100,
completed: 85,
failed: 10,
pending: 5,
avg_processing_time_ms: 2500,
total_cost_cents: 0,
};
vi.mocked(receiptRepo.getProcessingStats).mockResolvedValueOnce(mockStats);
const result = await getProcessingStats(mockLogger);
expect(result.total_receipts).toBe(100);
expect(result.completed).toBe(85);
});
it('should filter by date range', async () => {
const mockStats = {
total_receipts: 20,
completed: 18,
failed: 2,
pending: 0,
avg_processing_time_ms: 2000,
total_cost_cents: 0,
};
vi.mocked(receiptRepo.getProcessingStats).mockResolvedValueOnce(mockStats);
await getProcessingStats(mockLogger, {
fromDate: '2024-01-01',
toDate: '2024-01-31',
});
expect(receiptRepo.getProcessingStats).toHaveBeenCalledWith(mockLogger, {
fromDate: '2024-01-01',
toDate: '2024-01-31',
});
});
});
describe('getReceiptsNeedingProcessing', () => {
it('should return pending receipts for processing', async () => {
const mockReceipts = [
{
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
},
];
vi.mocked(receiptRepo.getReceiptsNeedingProcessing).mockResolvedValueOnce(mockReceipts);
const result = await getReceiptsNeedingProcessing(10, mockLogger);
expect(result).toHaveLength(1);
expect(result[0].status).toBe('pending');
});
});
describe('addStorePattern', () => {
it('should add store pattern', async () => {
vi.mocked(receiptRepo.addStorePattern).mockResolvedValueOnce(createMockStorePatternRow());
await addStorePattern(1, 'name', 'WALMART', mockLogger, { priority: 1 });
expect(receiptRepo.addStorePattern).toHaveBeenCalledWith(1, 'name', 'WALMART', mockLogger, {
priority: 1,
});
});
});
describe('getActiveStorePatterns', () => {
it('should return active store patterns', async () => {
const mockPatterns = [
createMockStorePatternRow({
pattern_id: 1,
store_id: 1,
pattern_type: 'name',
pattern_value: 'WALMART',
}),
];
vi.mocked(receiptRepo.getActiveStorePatterns).mockResolvedValueOnce(mockPatterns);
const result = await getActiveStorePatterns(mockLogger);
expect(result).toHaveLength(1);
});
});
describe('processReceiptJob', () => {
it('should process receipt job successfully', async () => {
const mockReceipt = {
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({
...mockReceipt,
status: 'completed' as ReceiptStatus,
} as any);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null);
vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]);
const mockJob = {
id: 'job-1',
data: {
receiptId: 1,
userId: 'user-1',
meta: { requestId: 'req-1' },
},
attemptsMade: 0,
} as Job<ReceiptJobData>;
const result = await processReceiptJob(mockJob, mockLogger);
expect(result.success).toBe(true);
expect(result.receiptId).toBe(1);
});
it('should skip already completed receipts', async () => {
const mockReceipt = {
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
transaction_date: null,
total_amount_cents: null,
status: 'completed' as ReceiptStatus,
raw_text: 'Previous text',
store_confidence: null,
ocr_provider: null,
error_details: null,
retry_count: 0,
ocr_confidence: null,
currency: 'USD',
created_at: new Date().toISOString(),
processed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
const mockJob = {
id: 'job-2',
data: {
receiptId: 1,
userId: 'user-1',
},
attemptsMade: 0,
} as Job<ReceiptJobData>;
const result = await processReceiptJob(mockJob, mockLogger);
expect(result.success).toBe(true);
expect(result.itemsFound).toBe(0);
expect(receiptRepo.updateReceipt).not.toHaveBeenCalled();
});
it('should handle job processing errors', async () => {
const mockReceipt = {
receipt_id: 1,
user_id: 'user-1',
store_id: null,
receipt_image_url: '/uploads/receipt.jpg',
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: new Date().toISOString(),
processed_at: null,
updated_at: new Date().toISOString(),
};
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
vi.mocked(receiptRepo.updateReceipt)
.mockRejectedValueOnce(new Error('Processing failed'))
.mockResolvedValueOnce({ ...mockReceipt, status: 'failed' } as any);
vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1);
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
const mockJob = {
id: 'job-3',
data: {
receiptId: 1,
userId: 'user-1',
},
attemptsMade: 1,
} as Job<ReceiptJobData>;
await expect(processReceiptJob(mockJob, mockLogger)).rejects.toThrow('Processing failed');
expect(receiptRepo.updateReceipt).toHaveBeenCalledWith(
1,
expect.objectContaining({ status: 'failed' }),
expect.any(Object),
);
});
});
});