Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 1m10s
792 lines
23 KiB
TypeScript
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),
|
|
);
|
|
});
|
|
});
|
|
});
|