feat: add backend OCR manual proxy endpoint (refs #135)
Add POST /api/ocr/extract/manual endpoint that proxies to the Python OCR service's manual extraction pipeline. Includes Pro tier gating via document.scanMaintenanceSchedule, PDF-only validation, 200MB file size limit, and async 202 job response for polling via existing job status endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
213
backend/src/features/ocr/tests/unit/ocr-manual.test.ts
Normal file
213
backend/src/features/ocr/tests/unit/ocr-manual.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @ai-summary Unit tests for OCR manual extraction endpoint
|
||||
*/
|
||||
|
||||
import { OcrService } from '../../domain/ocr.service';
|
||||
import { ocrClient } from '../../external/ocr-client';
|
||||
import type { ManualJobResponse } from '../../domain/ocr.types';
|
||||
|
||||
jest.mock('../../external/ocr-client');
|
||||
jest.mock('../../../../core/logging/logger');
|
||||
|
||||
const mockSubmitManualJob = ocrClient.submitManualJob as jest.MockedFunction<
|
||||
typeof ocrClient.submitManualJob
|
||||
>;
|
||||
|
||||
describe('OcrService.submitManualJob', () => {
|
||||
let service: OcrService;
|
||||
|
||||
const userId = 'test-user-id';
|
||||
|
||||
const mockManualJobResponse: ManualJobResponse = {
|
||||
jobId: 'manual-job-123',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
estimatedSeconds: 45,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const mockCompletedJobResponse: ManualJobResponse = {
|
||||
jobId: 'manual-job-123',
|
||||
status: 'completed',
|
||||
progress: 100,
|
||||
result: {
|
||||
success: true,
|
||||
vehicleInfo: {
|
||||
make: 'Honda',
|
||||
model: 'Civic',
|
||||
year: 2023,
|
||||
},
|
||||
maintenanceSchedules: [
|
||||
{
|
||||
service: 'Engine Oil Change',
|
||||
intervalMiles: 5000,
|
||||
intervalMonths: 6,
|
||||
details: 'Use 0W-20 full synthetic oil',
|
||||
confidence: 0.95,
|
||||
subtypes: ['oil_change'],
|
||||
},
|
||||
{
|
||||
service: 'Tire Rotation',
|
||||
intervalMiles: 7500,
|
||||
intervalMonths: 6,
|
||||
details: null,
|
||||
confidence: 0.90,
|
||||
subtypes: ['tire_rotation'],
|
||||
},
|
||||
],
|
||||
rawTables: [],
|
||||
processingTimeMs: 45000,
|
||||
totalPages: 120,
|
||||
pagesProcessed: 120,
|
||||
error: null,
|
||||
},
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new OcrService();
|
||||
});
|
||||
|
||||
describe('valid manual job submission', () => {
|
||||
it('should return 202-style response with jobId for PDF submission', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
|
||||
const result = await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(result.jobId).toBe('manual-job-123');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.progress).toBe(0);
|
||||
expect(result.estimatedSeconds).toBe(45);
|
||||
expect(result.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass vehicleId to client when provided', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
|
||||
await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
vehicleId: 'vehicle-abc',
|
||||
});
|
||||
|
||||
expect(mockSubmitManualJob).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'application/pdf',
|
||||
'vehicle-abc'
|
||||
);
|
||||
});
|
||||
|
||||
it('should call client without vehicleId when not provided', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
|
||||
await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(mockSubmitManualJob).toHaveBeenCalledWith(
|
||||
expect.any(Buffer),
|
||||
'application/pdf',
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completed job result', () => {
|
||||
it('should return completed result with maintenanceSchedules', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockCompletedJobResponse);
|
||||
|
||||
const result = await service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.result).toBeDefined();
|
||||
expect(result.result!.success).toBe(true);
|
||||
expect(result.result!.maintenanceSchedules).toHaveLength(2);
|
||||
expect(result.result!.maintenanceSchedules[0].service).toBe('Engine Oil Change');
|
||||
expect(result.result!.maintenanceSchedules[0].intervalMiles).toBe(5000);
|
||||
expect(result.result!.maintenanceSchedules[0].subtypes).toEqual(['oil_change']);
|
||||
expect(result.result!.vehicleInfo.make).toBe('Honda');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw 400 for non-PDF file (JPEG)', async () => {
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 for non-PDF file (PNG)', async () => {
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-image-data'),
|
||||
contentType: 'image/png',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 for text/plain', async () => {
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('not a pdf'),
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 413 for oversized file', async () => {
|
||||
const largeBuffer = Buffer.alloc(201 * 1024 * 1024); // 201MB
|
||||
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: largeBuffer,
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 413,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept file at 200MB boundary', async () => {
|
||||
mockSubmitManualJob.mockResolvedValue(mockManualJobResponse);
|
||||
const exactBuffer = Buffer.alloc(200 * 1024 * 1024); // exactly 200MB
|
||||
|
||||
const result = await service.submitManualJob(userId, {
|
||||
fileBuffer: exactBuffer,
|
||||
contentType: 'application/pdf',
|
||||
});
|
||||
|
||||
expect(result.jobId).toBe('manual-job-123');
|
||||
});
|
||||
|
||||
it('should propagate OCR service errors', async () => {
|
||||
mockSubmitManualJob.mockRejectedValue(
|
||||
new Error('OCR service error: 500 - Internal error')
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitManualJob(userId, {
|
||||
fileBuffer: Buffer.from('fake-pdf-data'),
|
||||
contentType: 'application/pdf',
|
||||
})
|
||||
).rejects.toThrow('OCR service error: 500 - Internal error');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user