feat: add receipt extraction proxy endpoint (refs #130)
Add POST /api/ocr/extract/receipt endpoint that proxies to the Python OCR service's /extract/receipt for receipt-specific field extraction. - ReceiptExtractionResponse type with receiptType, extractedFields, rawText - OcrClient.extractReceipt() with optional receipt_type form field - OcrService.extractReceipt() with 10MB max, image-only validation - OcrController.extractReceipt() with file upload and error mapping - Route with auth middleware - 9 unit tests covering normal, edge, and error scenarios Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,14 @@ const SUPPORTED_TYPES = new Set([
|
|||||||
'application/pdf',
|
'application/pdf',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Image-only MIME types for receipt extraction (no PDF) */
|
||||||
|
const SUPPORTED_IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/heic',
|
||||||
|
'image/heif',
|
||||||
|
]);
|
||||||
|
|
||||||
export class OcrController {
|
export class OcrController {
|
||||||
/**
|
/**
|
||||||
* POST /api/ocr/extract
|
* POST /api/ocr/extract
|
||||||
@@ -223,6 +231,111 @@ export class OcrController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ocr/extract/receipt
|
||||||
|
* Extract data from a receipt image using receipt-specific OCR.
|
||||||
|
*/
|
||||||
|
async extractReceipt(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const userId = (request as any).user?.sub as string;
|
||||||
|
|
||||||
|
logger.info('Receipt extract requested', {
|
||||||
|
operation: 'ocr.controller.extractReceipt',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = await (request as any).file({ limits: { files: 1 } });
|
||||||
|
if (!file) {
|
||||||
|
logger.warn('No file provided for receipt extraction', {
|
||||||
|
operation: 'ocr.controller.extractReceipt.no_file',
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'No file provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = file.mimetype as string;
|
||||||
|
if (!SUPPORTED_IMAGE_TYPES.has(contentType)) {
|
||||||
|
logger.warn('Unsupported file type for receipt extraction', {
|
||||||
|
operation: 'ocr.controller.extractReceipt.unsupported_type',
|
||||||
|
userId,
|
||||||
|
contentType,
|
||||||
|
fileName: file.filename,
|
||||||
|
});
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of file.file) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
const fileBuffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
if (fileBuffer.length === 0) {
|
||||||
|
logger.warn('Empty file provided for receipt extraction', {
|
||||||
|
operation: 'ocr.controller.extractReceipt.empty_file',
|
||||||
|
userId,
|
||||||
|
fileName: file.filename,
|
||||||
|
});
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Empty file provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get optional receipt_type from form fields
|
||||||
|
const receiptType = file.fields?.receipt_type?.value as string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ocrService.extractReceipt(userId, {
|
||||||
|
fileBuffer,
|
||||||
|
contentType,
|
||||||
|
receiptType,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Receipt extract completed', {
|
||||||
|
operation: 'ocr.controller.extractReceipt.success',
|
||||||
|
userId,
|
||||||
|
success: result.success,
|
||||||
|
receiptType: result.receiptType,
|
||||||
|
processingTimeMs: result.processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.statusCode === 413) {
|
||||||
|
return reply.code(413).send({
|
||||||
|
error: 'Payload Too Large',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.statusCode === 415) {
|
||||||
|
return reply.code(415).send({
|
||||||
|
error: 'Unsupported Media Type',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('Receipt extract failed', {
|
||||||
|
operation: 'ocr.controller.extractReceipt.error',
|
||||||
|
userId,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(500).send({
|
||||||
|
error: 'Internal Server Error',
|
||||||
|
message: 'Receipt extraction failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ocr/jobs
|
* POST /api/ocr/jobs
|
||||||
* Submit an async OCR job for large files.
|
* Submit an async OCR job for large files.
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ export const ocrRoutes: FastifyPluginAsync = async (
|
|||||||
handler: ctrl.extractVin.bind(ctrl),
|
handler: ctrl.extractVin.bind(ctrl),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/ocr/extract/receipt - Receipt-specific OCR extraction
|
||||||
|
fastify.post('/ocr/extract/receipt', {
|
||||||
|
preHandler: [requireAuth],
|
||||||
|
handler: ctrl.extractReceipt.bind(ctrl),
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/ocr/jobs - Submit async OCR job
|
// POST /api/ocr/jobs - Submit async OCR job
|
||||||
fastify.post('/ocr/jobs', {
|
fastify.post('/ocr/jobs', {
|
||||||
preHandler: [requireAuth],
|
preHandler: [requireAuth],
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
OcrExtractRequest,
|
OcrExtractRequest,
|
||||||
OcrJobSubmitRequest,
|
OcrJobSubmitRequest,
|
||||||
OcrResponse,
|
OcrResponse,
|
||||||
|
ReceiptExtractRequest,
|
||||||
|
ReceiptExtractionResponse,
|
||||||
VinExtractionResponse,
|
VinExtractionResponse,
|
||||||
} from './ocr.types';
|
} from './ocr.types';
|
||||||
|
|
||||||
@@ -26,6 +28,14 @@ const SUPPORTED_TYPES = new Set([
|
|||||||
'application/pdf',
|
'application/pdf',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/** Image-only MIME types for receipt extraction (no PDF) */
|
||||||
|
const SUPPORTED_IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/heic',
|
||||||
|
'image/heif',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domain service for OCR operations.
|
* Domain service for OCR operations.
|
||||||
* Handles business logic and validation for OCR requests.
|
* Handles business logic and validation for OCR requests.
|
||||||
@@ -150,6 +160,65 @@ export class OcrService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract data from a receipt image using receipt-specific OCR.
|
||||||
|
*
|
||||||
|
* @param userId - User ID for logging
|
||||||
|
* @param request - Receipt extraction request
|
||||||
|
* @returns Receipt extraction result
|
||||||
|
*/
|
||||||
|
async extractReceipt(userId: string, request: ReceiptExtractRequest): Promise<ReceiptExtractionResponse> {
|
||||||
|
if (request.fileBuffer.length > MAX_SYNC_SIZE) {
|
||||||
|
const err: any = new Error(
|
||||||
|
`File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.`
|
||||||
|
);
|
||||||
|
err.statusCode = 413;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) {
|
||||||
|
const err: any = new Error(
|
||||||
|
`Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}`
|
||||||
|
);
|
||||||
|
err.statusCode = 415;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Receipt extract requested', {
|
||||||
|
operation: 'ocr.service.extractReceipt',
|
||||||
|
userId,
|
||||||
|
contentType: request.contentType,
|
||||||
|
fileSize: request.fileBuffer.length,
|
||||||
|
receiptType: request.receiptType,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await ocrClient.extractReceipt(
|
||||||
|
request.fileBuffer,
|
||||||
|
request.contentType,
|
||||||
|
request.receiptType
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Receipt extract completed', {
|
||||||
|
operation: 'ocr.service.extractReceipt.success',
|
||||||
|
userId,
|
||||||
|
success: result.success,
|
||||||
|
receiptType: result.receiptType,
|
||||||
|
fieldCount: Object.keys(result.extractedFields).length,
|
||||||
|
processingTimeMs: result.processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Receipt extract failed', {
|
||||||
|
operation: 'ocr.service.extractReceipt.error',
|
||||||
|
userId,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit an async OCR job for large files.
|
* Submit an async OCR job for large files.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ export interface OcrExtractRequest {
|
|||||||
preprocess?: boolean;
|
preprocess?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Response from receipt-specific extraction */
|
||||||
|
export interface ReceiptExtractionResponse {
|
||||||
|
success: boolean;
|
||||||
|
receiptType: string;
|
||||||
|
extractedFields: Record<string, ExtractedField>;
|
||||||
|
rawText: string;
|
||||||
|
processingTimeMs: number;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request for receipt extraction */
|
||||||
|
export interface ReceiptExtractRequest {
|
||||||
|
fileBuffer: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
receiptType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Response from VIN-specific extraction */
|
/** Response from VIN-specific extraction */
|
||||||
export interface VinExtractionResponse {
|
export interface VinExtractionResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
58
backend/src/features/ocr/external/ocr-client.ts
vendored
58
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -2,7 +2,7 @@
|
|||||||
* @ai-summary HTTP client for OCR service communication
|
* @ai-summary HTTP client for OCR service communication
|
||||||
*/
|
*/
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import type { JobResponse, OcrResponse, VinExtractionResponse } from '../domain/ocr.types';
|
import type { JobResponse, OcrResponse, ReceiptExtractionResponse, VinExtractionResponse } from '../domain/ocr.types';
|
||||||
|
|
||||||
/** OCR service configuration */
|
/** OCR service configuration */
|
||||||
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
|
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
|
||||||
@@ -119,6 +119,62 @@ export class OcrClient {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract data from a receipt image using receipt-specific OCR.
|
||||||
|
*
|
||||||
|
* @param fileBuffer - Image file buffer
|
||||||
|
* @param contentType - MIME type of the file
|
||||||
|
* @param receiptType - Optional receipt type hint (e.g., 'fuel')
|
||||||
|
* @returns Receipt extraction result
|
||||||
|
*/
|
||||||
|
async extractReceipt(
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
contentType: string,
|
||||||
|
receiptType?: string
|
||||||
|
): Promise<ReceiptExtractionResponse> {
|
||||||
|
const formData = this.buildFormData(fileBuffer, contentType);
|
||||||
|
if (receiptType) {
|
||||||
|
formData.append('receipt_type', receiptType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}/extract/receipt`;
|
||||||
|
|
||||||
|
logger.info('OCR receipt extract request', {
|
||||||
|
operation: 'ocr.client.extractReceipt',
|
||||||
|
url,
|
||||||
|
contentType,
|
||||||
|
fileSize: fileBuffer.length,
|
||||||
|
receiptType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.fetchWithTimeout(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
logger.error('OCR receipt extract failed', {
|
||||||
|
operation: 'ocr.client.extractReceipt.error',
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await response.json()) as ReceiptExtractionResponse;
|
||||||
|
|
||||||
|
logger.info('OCR receipt extract completed', {
|
||||||
|
operation: 'ocr.client.extractReceipt.success',
|
||||||
|
success: result.success,
|
||||||
|
receiptType: result.receiptType,
|
||||||
|
fieldCount: Object.keys(result.extractedFields).length,
|
||||||
|
processingTimeMs: result.processingTimeMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit an async OCR job for large files.
|
* Submit an async OCR job for large files.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export type {
|
|||||||
JobResponse,
|
JobResponse,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
OcrResponse,
|
OcrResponse,
|
||||||
|
ReceiptExtractionResponse,
|
||||||
} from './domain/ocr.types';
|
} from './domain/ocr.types';
|
||||||
|
|||||||
181
backend/src/features/ocr/tests/unit/ocr-receipt.test.ts
Normal file
181
backend/src/features/ocr/tests/unit/ocr-receipt.test.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Unit tests for OCR receipt extraction endpoint
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { OcrService } from '../../domain/ocr.service';
|
||||||
|
import { ocrClient } from '../../external/ocr-client';
|
||||||
|
import type { ReceiptExtractionResponse } from '../../domain/ocr.types';
|
||||||
|
|
||||||
|
jest.mock('../../external/ocr-client');
|
||||||
|
jest.mock('../../../../core/logging/logger');
|
||||||
|
|
||||||
|
const mockExtractReceipt = ocrClient.extractReceipt as jest.MockedFunction<
|
||||||
|
typeof ocrClient.extractReceipt
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe('OcrService.extractReceipt', () => {
|
||||||
|
let service: OcrService;
|
||||||
|
|
||||||
|
const userId = 'test-user-id';
|
||||||
|
|
||||||
|
const mockReceiptResponse: ReceiptExtractionResponse = {
|
||||||
|
success: true,
|
||||||
|
receiptType: 'fuel',
|
||||||
|
extractedFields: {
|
||||||
|
merchantName: { value: 'Shell Gas Station', confidence: 0.92 },
|
||||||
|
transactionDate: { value: '2026-02-10', confidence: 0.88 },
|
||||||
|
totalAmount: { value: '45.67', confidence: 0.95 },
|
||||||
|
fuelQuantity: { value: '12.345', confidence: 0.87 },
|
||||||
|
pricePerUnit: { value: '3.699', confidence: 0.90 },
|
||||||
|
fuelGrade: { value: 'Regular 87', confidence: 0.85 },
|
||||||
|
},
|
||||||
|
rawText: 'SHELL\n02/10/2026\nREGULAR 87\n12.345 GAL\n$3.699/GAL\nTOTAL $45.67',
|
||||||
|
processingTimeMs: 1250,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new OcrService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('valid receipt extraction', () => {
|
||||||
|
it('should return receipt extraction response for valid image', async () => {
|
||||||
|
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||||
|
|
||||||
|
const result = await service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-image-data'),
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.receiptType).toBe('fuel');
|
||||||
|
expect(result.extractedFields.merchantName.value).toBe('Shell Gas Station');
|
||||||
|
expect(result.extractedFields.totalAmount.value).toBe('45.67');
|
||||||
|
expect(result.extractedFields.fuelQuantity.value).toBe('12.345');
|
||||||
|
expect(result.extractedFields.pricePerUnit.value).toBe('3.699');
|
||||||
|
expect(result.extractedFields.fuelGrade.value).toBe('Regular 87');
|
||||||
|
expect(result.extractedFields.transactionDate.value).toBe('2026-02-10');
|
||||||
|
expect(result.processingTimeMs).toBe(1250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass receipt_type hint to client when provided', async () => {
|
||||||
|
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||||
|
|
||||||
|
await service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-image-data'),
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
receiptType: 'fuel',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockExtractReceipt).toHaveBeenCalledWith(
|
||||||
|
expect.any(Buffer),
|
||||||
|
'image/jpeg',
|
||||||
|
'fuel'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support PNG images', async () => {
|
||||||
|
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||||
|
|
||||||
|
const result = await service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-png-data'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support HEIC images', async () => {
|
||||||
|
mockExtractReceipt.mockResolvedValue(mockReceiptResponse);
|
||||||
|
|
||||||
|
const result = await service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-heic-data'),
|
||||||
|
contentType: 'image/heic',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('missing optional fields', () => {
|
||||||
|
it('should handle response with some fields not detected', async () => {
|
||||||
|
const partialResponse: ReceiptExtractionResponse = {
|
||||||
|
success: true,
|
||||||
|
receiptType: 'fuel',
|
||||||
|
extractedFields: {
|
||||||
|
merchantName: { value: 'Unknown Station', confidence: 0.60 },
|
||||||
|
totalAmount: { value: '30.00', confidence: 0.88 },
|
||||||
|
},
|
||||||
|
rawText: 'UNKNOWN STATION\nTOTAL $30.00',
|
||||||
|
processingTimeMs: 980,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExtractReceipt.mockResolvedValue(partialResponse);
|
||||||
|
|
||||||
|
const result = await service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-image-data'),
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.extractedFields.merchantName).toBeDefined();
|
||||||
|
expect(result.extractedFields.totalAmount).toBeDefined();
|
||||||
|
expect(result.extractedFields.fuelQuantity).toBeUndefined();
|
||||||
|
expect(result.extractedFields.pricePerUnit).toBeUndefined();
|
||||||
|
expect(result.extractedFields.fuelGrade).toBeUndefined();
|
||||||
|
expect(result.extractedFields.transactionDate).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('should throw 415 for unsupported file type (PDF)', async () => {
|
||||||
|
await expect(
|
||||||
|
service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 415,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw 415 for text/plain', async () => {
|
||||||
|
await expect(
|
||||||
|
service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('not an image'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 415,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw 413 for oversized file', async () => {
|
||||||
|
const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.extractReceipt(userId, {
|
||||||
|
fileBuffer: largeBuffer,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
statusCode: 413,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should propagate OCR service errors', async () => {
|
||||||
|
mockExtractReceipt.mockRejectedValue(
|
||||||
|
new Error('OCR service error: 500 - Internal error')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.extractReceipt(userId, {
|
||||||
|
fileBuffer: Buffer.from('fake-image-data'),
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('OCR service error: 500 - Internal error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user