All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m51s
1039 lines
32 KiB
TypeScript
1039 lines
32 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),
|
|
);
|
|
});
|
|
|
|
it('should handle error when updating receipt status fails after processing error', 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(),
|
|
};
|
|
|
|
// First call returns receipt, then processReceipt calls it internally
|
|
vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt);
|
|
|
|
// All updateReceipt calls fail
|
|
vi.mocked(receiptRepo.updateReceipt).mockRejectedValue(new Error('Database unavailable'));
|
|
|
|
vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1);
|
|
vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord());
|
|
|
|
const mockJob = {
|
|
id: 'job-4',
|
|
data: {
|
|
receiptId: 1,
|
|
userId: 'user-1',
|
|
},
|
|
attemptsMade: 1,
|
|
} as Job<ReceiptJobData>;
|
|
|
|
// When all updateReceipt calls fail, the error is propagated
|
|
await expect(processReceiptJob(mockJob, mockLogger)).rejects.toThrow('Database unavailable');
|
|
});
|
|
});
|
|
|
|
// Test internal logic patterns used in the service
|
|
describe('receipt text parsing patterns', () => {
|
|
// These test the regex patterns and logic used in parseReceiptText
|
|
|
|
it('should match price pattern at end of line', () => {
|
|
const pricePattern = /\$?(\d+)\.(\d{2})\s*$/;
|
|
|
|
expect('MILK 2% $4.99'.match(pricePattern)).toBeTruthy();
|
|
expect('BREAD 2.49'.match(pricePattern)).toBeTruthy();
|
|
expect('Item Name $12.00'.match(pricePattern)).toBeTruthy();
|
|
expect('No price here'.match(pricePattern)).toBeNull();
|
|
});
|
|
|
|
it('should match quantity pattern', () => {
|
|
const quantityPattern = /^(\d+)\s*[@xX]/;
|
|
|
|
expect('2 @ $3.99 APPLES'.match(quantityPattern)?.[1]).toBe('2');
|
|
expect('3x Bananas'.match(quantityPattern)?.[1]).toBe('3');
|
|
expect('5X ITEM'.match(quantityPattern)?.[1]).toBe('5');
|
|
expect('Regular Item'.match(quantityPattern)).toBeNull();
|
|
});
|
|
|
|
it('should identify discount lines', () => {
|
|
const isDiscount = (line: string) =>
|
|
line.includes('-') || line.toLowerCase().includes('discount');
|
|
|
|
expect(isDiscount('COUPON DISCOUNT -$2.00')).toBe(true);
|
|
expect(isDiscount('MEMBER DISCOUNT')).toBe(true);
|
|
expect(isDiscount('-$1.50')).toBe(true);
|
|
expect(isDiscount('Regular Item $4.99')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('receipt header/footer detection patterns', () => {
|
|
// Test the isHeaderOrFooter logic
|
|
const skipPatterns = [
|
|
'thank you',
|
|
'thanks for',
|
|
'visit us',
|
|
'total',
|
|
'subtotal',
|
|
'tax',
|
|
'change',
|
|
'cash',
|
|
'credit',
|
|
'debit',
|
|
'visa',
|
|
'mastercard',
|
|
'approved',
|
|
'transaction',
|
|
'terminal',
|
|
'receipt',
|
|
'store #',
|
|
'date:',
|
|
'time:',
|
|
'cashier',
|
|
];
|
|
|
|
const isHeaderOrFooter = (line: string): boolean => {
|
|
const lowercaseLine = line.toLowerCase();
|
|
return skipPatterns.some((pattern) => lowercaseLine.includes(pattern));
|
|
};
|
|
|
|
it('should skip thank you lines', () => {
|
|
expect(isHeaderOrFooter('THANK YOU FOR SHOPPING')).toBe(true);
|
|
expect(isHeaderOrFooter('Thanks for visiting!')).toBe(true);
|
|
});
|
|
|
|
it('should skip total/subtotal lines', () => {
|
|
expect(isHeaderOrFooter('SUBTOTAL $45.99')).toBe(true);
|
|
expect(isHeaderOrFooter('TOTAL $49.99')).toBe(true);
|
|
expect(isHeaderOrFooter('TAX $3.00')).toBe(true);
|
|
});
|
|
|
|
it('should skip payment method lines', () => {
|
|
expect(isHeaderOrFooter('VISA **** 1234')).toBe(true);
|
|
expect(isHeaderOrFooter('MASTERCARD APPROVED')).toBe(true);
|
|
expect(isHeaderOrFooter('CASH TENDERED')).toBe(true);
|
|
expect(isHeaderOrFooter('CREDIT CARD')).toBe(true);
|
|
expect(isHeaderOrFooter('DEBIT $50.00')).toBe(true);
|
|
});
|
|
|
|
it('should skip store info lines', () => {
|
|
expect(isHeaderOrFooter('Store #1234')).toBe(true);
|
|
expect(isHeaderOrFooter('DATE: 01/15/2024')).toBe(true);
|
|
expect(isHeaderOrFooter('TIME: 14:30')).toBe(true);
|
|
expect(isHeaderOrFooter('Cashier: John')).toBe(true);
|
|
});
|
|
|
|
it('should allow regular item lines', () => {
|
|
expect(isHeaderOrFooter('MILK 2% $4.99')).toBe(false);
|
|
expect(isHeaderOrFooter('BREAD WHOLE WHEAT')).toBe(false);
|
|
expect(isHeaderOrFooter('BANANAS 2.5LB')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('receipt metadata extraction patterns', () => {
|
|
// Test the extractReceiptMetadata logic
|
|
|
|
it('should extract total amount from different formats', () => {
|
|
const totalPatterns = [
|
|
/total[:\s]+\$?(\d+)\.(\d{2})/i,
|
|
/grand total[:\s]+\$?(\d+)\.(\d{2})/i,
|
|
/amount due[:\s]+\$?(\d+)\.(\d{2})/i,
|
|
];
|
|
|
|
const extractTotal = (text: string): number | undefined => {
|
|
for (const pattern of totalPatterns) {
|
|
const match = text.match(pattern);
|
|
if (match) {
|
|
return parseInt(match[1], 10) * 100 + parseInt(match[2], 10);
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
expect(extractTotal('TOTAL: $45.99')).toBe(4599);
|
|
expect(extractTotal('Grand Total $123.00')).toBe(12300);
|
|
expect(extractTotal('AMOUNT DUE: 78.50')).toBe(7850);
|
|
expect(extractTotal('No total here')).toBeUndefined();
|
|
});
|
|
|
|
it('should extract date from MM/DD/YYYY format', () => {
|
|
const datePattern = /(\d{1,2})\/(\d{1,2})\/(\d{2,4})/;
|
|
|
|
const match1 = '01/15/2024'.match(datePattern);
|
|
expect(match1?.[1]).toBe('01');
|
|
expect(match1?.[2]).toBe('15');
|
|
expect(match1?.[3]).toBe('2024');
|
|
|
|
const match2 = '1/5/24'.match(datePattern);
|
|
expect(match2?.[1]).toBe('1');
|
|
expect(match2?.[2]).toBe('5');
|
|
expect(match2?.[3]).toBe('24');
|
|
});
|
|
|
|
it('should extract date from YYYY-MM-DD format', () => {
|
|
const datePattern = /(\d{4})-(\d{2})-(\d{2})/;
|
|
|
|
const match = '2024-01-15'.match(datePattern);
|
|
expect(match?.[1]).toBe('2024');
|
|
expect(match?.[2]).toBe('01');
|
|
expect(match?.[3]).toBe('15');
|
|
});
|
|
|
|
it('should convert 2-digit years to 4-digit years', () => {
|
|
const convertYear = (year: number): number => {
|
|
if (year < 100) {
|
|
return year + 2000;
|
|
}
|
|
return year;
|
|
};
|
|
|
|
expect(convertYear(24)).toBe(2024);
|
|
expect(convertYear(99)).toBe(2099);
|
|
expect(convertYear(2024)).toBe(2024);
|
|
});
|
|
});
|
|
|
|
describe('OCR extraction edge cases', () => {
|
|
// These test the logic in performOcrExtraction
|
|
|
|
it('should determine if URL is local path', () => {
|
|
const isLocalPath = (url: string) => !url.startsWith('http');
|
|
|
|
expect(isLocalPath('/uploads/receipt.jpg')).toBe(true);
|
|
expect(isLocalPath('./images/receipt.png')).toBe(true);
|
|
expect(isLocalPath('https://example.com/receipt.jpg')).toBe(false);
|
|
expect(isLocalPath('http://localhost/receipt.jpg')).toBe(false);
|
|
});
|
|
|
|
it('should determine MIME type from extension', () => {
|
|
const mimeTypeMap: Record<string, string> = {
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.gif': 'image/gif',
|
|
'.webp': 'image/webp',
|
|
};
|
|
|
|
const getMimeType = (ext: string) => mimeTypeMap[ext] || 'image/jpeg';
|
|
|
|
expect(getMimeType('.jpg')).toBe('image/jpeg');
|
|
expect(getMimeType('.jpeg')).toBe('image/jpeg');
|
|
expect(getMimeType('.png')).toBe('image/png');
|
|
expect(getMimeType('.gif')).toBe('image/gif');
|
|
expect(getMimeType('.webp')).toBe('image/webp');
|
|
expect(getMimeType('.unknown')).toBe('image/jpeg');
|
|
});
|
|
|
|
it('should format extracted items as text', () => {
|
|
const extractedItems = [
|
|
{ raw_item_description: 'MILK 2%', price_paid_cents: 499 },
|
|
{ raw_item_description: 'BREAD', price_paid_cents: 299 },
|
|
];
|
|
|
|
const textLines = extractedItems.map(
|
|
(item) => `${item.raw_item_description} - $${(item.price_paid_cents / 100).toFixed(2)}`,
|
|
);
|
|
|
|
expect(textLines).toEqual(['MILK 2% - $4.99', 'BREAD - $2.99']);
|
|
});
|
|
});
|
|
});
|