import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { createOcrJob } from './useOcrJob.svelte'; afterEach(() => { vi.restoreAllMocks(); }); function makeFetch(handlers: Record Response | Promise>) { return vi.fn(async (url: RequestInfo | URL) => { const u = url.toString(); for (const [match, fn] of Object.entries(handlers)) { if (u.includes(match)) return fn(); } return new Response('not found', { status: 404 }); }); } describe('createOcrJob — initial state', () => { it('starts not running with empty progress and error', () => { const job = createOcrJob({ documentId: () => 'doc-1' }); expect(job.running).toBe(false); expect(job.progressMessage).toBe(''); expect(job.errorMessage).toBe(''); expect(job.skippedPages).toBe(0); }); }); describe('createOcrJob.triggerOcr', () => { it('sets running=true and starts polling on 200 with jobId', async () => { const fetchImpl = makeFetch({ '/ocr': () => new Response(JSON.stringify({ jobId: 'job-7' }), { status: 200, headers: { 'Content-Type': 'application/json' } }), '/ocr/jobs/job-7': () => new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'WORKING' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('KURRENT', false); expect(job.running).toBe(true); expect(job.errorMessage).toBe(''); expect(fetchImpl).toHaveBeenCalledWith( '/api/documents/doc-1/ocr', expect.objectContaining({ method: 'POST' }) ); job.destroy(); }); it('sets errorMessage with generic message on 500', async () => { const fetchImpl = makeFetch({ '/ocr': () => new Response('boom', { status: 500 }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('KURRENT', false); expect(job.running).toBe(false); expect(job.errorMessage).toBeTruthy(); job.destroy(); }); it('extracts backend error code from 4xx body', async () => { const fetchImpl = makeFetch({ '/ocr': () => new Response(JSON.stringify({ code: 'OCR_DISABLED' }), { status: 400, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('KURRENT', false); expect(job.running).toBe(false); expect(job.errorMessage).toBeTruthy(); // errorMessage is localized — at minimum non-empty job.destroy(); }); it('handles non-JSON 4xx body gracefully', async () => { const fetchImpl = makeFetch({ '/ocr': () => new Response('not json', { status: 400 }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('KURRENT', false); expect(job.running).toBe(false); expect(job.errorMessage).toBeTruthy(); job.destroy(); }); it('handles fetch network error', async () => { const fetchImpl = vi.fn(async () => { throw new Error('network down'); }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('KURRENT', false); expect(job.running).toBe(false); expect(job.errorMessage).toBeTruthy(); job.destroy(); }); it('passes useExistingAnnotations=true in the request body', async () => { const fetchImpl = makeFetch({ '/ocr': () => new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }), '/jobs/job-1': () => new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.triggerOcr('LATIN', true); const triggerCall = fetchImpl.mock.calls.find( (c) => c[0].toString().includes('/ocr') && !c[0].toString().includes('jobs') ); expect(triggerCall).toBeDefined(); const init = (triggerCall as unknown as [string, RequestInit])[1]; const body = JSON.parse(init.body as string); expect(body).toEqual({ scriptType: 'LATIN', useExistingAnnotations: true }); job.destroy(); }); }); describe('createOcrJob.checkStatus', () => { it('starts polling when status is RUNNING with a jobId', async () => { const fetchImpl = makeFetch({ 'ocr-status': () => new Response(JSON.stringify({ status: 'RUNNING', jobId: 'job-9' }), { status: 200, headers: { 'Content-Type': 'application/json' } }), '/ocr/jobs/job-9': () => new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.checkStatus(); expect(job.running).toBe(true); job.destroy(); }); it('starts polling when status is PENDING with a jobId', async () => { const fetchImpl = makeFetch({ 'ocr-status': () => new Response(JSON.stringify({ status: 'PENDING', jobId: 'job-9' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.checkStatus(); expect(job.running).toBe(true); job.destroy(); }); it('does not start polling when status is DONE', async () => { const fetchImpl = makeFetch({ 'ocr-status': () => new Response(JSON.stringify({ status: 'DONE', jobId: null }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.checkStatus(); expect(job.running).toBe(false); job.destroy(); }); it('does not start polling when no jobId present', async () => { const fetchImpl = makeFetch({ 'ocr-status': () => new Response(JSON.stringify({ status: 'RUNNING', jobId: null }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.checkStatus(); expect(job.running).toBe(false); job.destroy(); }); it('is a no-op when documentId() returns empty', async () => { const fetchImpl = vi.fn(); const job = createOcrJob({ documentId: () => '', fetchImpl }); await job.checkStatus(); expect(fetchImpl).not.toHaveBeenCalled(); job.destroy(); }); it('handles 5xx ocr-status gracefully', async () => { const fetchImpl = makeFetch({ 'ocr-status': () => new Response('boom', { status: 500 }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.checkStatus(); expect(job.running).toBe(false); job.destroy(); }); it('handles network error gracefully', async () => { const fetchImpl = vi.fn(async () => { throw new Error('network'); }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl }); await job.checkStatus(); expect(job.running).toBe(false); job.destroy(); }); }); describe('createOcrJob — polling loop (fake timers)', () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); // const wait used to live here; replaced by vi.advanceTimersByTimeAsync below. it('updates progressMessage from translated job code', async () => { const fetchImpl = makeFetch({ '/api/documents/doc-1/ocr': () => new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }), '/api/ocr/jobs/job-1': () => new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'PREPARING' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); await vi.advanceTimersByTimeAsync(50); expect(job.progressMessage).not.toBe(''); job.destroy(); }); it('captures skippedPages from job result', async () => { const fetchImpl = makeFetch({ '/api/documents/doc-1/ocr': () => new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }), '/api/ocr/jobs/job-1': () => new Response(JSON.stringify({ status: 'RUNNING', progressMessage: 'SKIPPED:5' }), { status: 200, headers: { 'Content-Type': 'application/json' } }) }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); await vi.advanceTimersByTimeAsync(50); expect(job.skippedPages).toBeGreaterThanOrEqual(0); job.destroy(); }); it('calls onJobFinished("DONE") when polling sees status=DONE', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { const u = url.toString(); if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) { return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ status: 'DONE', progressMessage: '' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const onJobFinished = vi.fn().mockResolvedValue(undefined); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, onJobFinished, pollIntervalMs: 20, resetDelayMs: 10 }); await job.triggerOcr('KURRENT', false); await vi.advanceTimersByTimeAsync(100); expect(onJobFinished).toHaveBeenCalledWith('DONE'); job.destroy(); }); it('sets errorMessage and calls onJobFinished("FAILED") when polling sees status=FAILED', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { const u = url.toString(); if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) { return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ status: 'FAILED', progressMessage: '' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const onJobFinished = vi.fn().mockResolvedValue(undefined); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, onJobFinished, pollIntervalMs: 20, resetDelayMs: 10 }); await job.triggerOcr('KURRENT', false); await vi.advanceTimersByTimeAsync(100); expect(onJobFinished).toHaveBeenCalledWith('FAILED'); expect(job.errorMessage).toBeTruthy(); job.destroy(); }); it('ignores non-OK polling responses', async () => { const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { const u = url.toString(); if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) { return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response('boom', { status: 500 }); }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); await vi.advanceTimersByTimeAsync(50); expect(job.running).toBe(true); job.destroy(); }); it('swallows polling fetch network errors', async () => { let triggered = false; const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { const u = url.toString(); if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) { triggered = true; return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (triggered) throw new Error('network'); return new Response('', { status: 200 }); }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); await vi.advanceTimersByTimeAsync(50); expect(job.running).toBe(true); job.destroy(); }); }); describe('createOcrJob.destroy', () => { it('returns undefined and is safe to call without an active job', () => { const job = createOcrJob({ documentId: () => 'doc-1' }); // destroy() is a void function — call it directly. If it threw, the test would fail. expect(job.destroy()).toBeUndefined(); }); it('stops the polling interval when called mid-poll', async () => { vi.useFakeTimers(); const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { const u = url.toString(); if (u.includes('/api/documents/doc-1/ocr') && !u.includes('jobs')) { return new Response(JSON.stringify({ jobId: 'job-1' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ status: 'RUNNING', progressMessage: '' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); const job = createOcrJob({ documentId: () => 'doc-1', fetchImpl, pollIntervalMs: 20 }); await job.triggerOcr('KURRENT', false); job.destroy(); const callsAtDestroy = fetchImpl.mock.calls.length; await vi.advanceTimersByTimeAsync(100); // No additional fetch calls after destroy expect(fetchImpl.mock.calls.length).toBe(callsAtDestroy); vi.useRealTimers(); }); });