// 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 { 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 { 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_location_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_location_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, { storeLocationId: 5, transactionDate: '2024-01-15', }); expect(result.store_location_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_location_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_location_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_location_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_location_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_location_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_location_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; 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_location_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; 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_location_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; 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_location_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; // 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 = { '.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']); }); }); });