/** * @ai-summary Unit tests for OCR manual extraction endpoint */ import { OcrService } from '../../domain/ocr.service'; import { ocrClient, JobNotFoundError } 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 >; const mockGetJobStatus = ocrClient.getJobStatus as jest.MockedFunction< typeof ocrClient.getJobStatus >; 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'); }); }); }); describe('OcrService.getJobStatus (manual job polling)', () => { let service: OcrService; const userId = 'test-user-id'; beforeEach(() => { jest.clearAllMocks(); service = new OcrService(); }); it('should return completed manual job with schedules', async () => { mockGetJobStatus.mockResolvedValue({ jobId: 'manual-job-123', status: 'completed', progress: 100, }); const result = await service.getJobStatus(userId, 'manual-job-123'); expect(result.jobId).toBe('manual-job-123'); expect(result.status).toBe('completed'); expect(result.progress).toBe(100); }); it('should return processing status with progress', async () => { mockGetJobStatus.mockResolvedValue({ jobId: 'manual-job-456', status: 'processing', progress: 50, }); const result = await service.getJobStatus(userId, 'manual-job-456'); expect(result.status).toBe('processing'); expect(result.progress).toBe(50); }); it('should throw 410 Gone for expired/missing job', async () => { mockGetJobStatus.mockRejectedValue(new JobNotFoundError('expired-job-789')); await expect( service.getJobStatus(userId, 'expired-job-789') ).rejects.toMatchObject({ statusCode: 410, message: 'Job expired (max 2 hours). Please resubmit.', }); }); }); describe('Manual extraction controller validations', () => { it('PDF magic bytes validation rejects non-PDF content', () => { // Controller validates first 4 bytes match %PDF (0x25504446) // Files without %PDF header receive 415 Unsupported Media Type const pdfMagic = Buffer.from('%PDF'); const notPdf = Buffer.from('JFIF'); expect(pdfMagic.subarray(0, 4).equals(Buffer.from('%PDF'))).toBe(true); expect(notPdf.subarray(0, 4).equals(Buffer.from('%PDF'))).toBe(false); }); it('accepts files with .pdf extension even if mimetype is octet-stream', () => { // Controller checks: contentType === 'application/pdf' OR filename.endsWith('.pdf') // This allows uploads where browser sends generic content type const filename = 'owners-manual.pdf'; expect(filename.toLowerCase().endsWith('.pdf')).toBe(true); }); }); describe('Manual route tier guard', () => { it('route is configured with tier guard for document.scanMaintenanceSchedule', async () => { // Tier guard is enforced at route level via requireTier('document.scanMaintenanceSchedule') // preHandler: [requireAuth, requireTier('document.scanMaintenanceSchedule')] // Free-tier users receive 403 TIER_REQUIRED before the handler executes. // Middleware behavior is tested in core/middleware/require-tier.test.ts const { requireTier } = await import('../../../../core/middleware/require-tier'); const handler = requireTier('document.scanMaintenanceSchedule'); expect(typeof handler).toBe('function'); }); });