From 9db30742f800a378fd28da27d9f6035dfe3cc77e Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 4 Jun 2026 20:04:30 -0600 Subject: [PATCH] test(pdf-parse): add unit tests for client lifecycle and worker route flows Introduce new unit tests covering the PDF parse client lifecycle and worker-based API routes. Tests verify client behavior for not-ready and ready parse states, operation initiation, SSE event handling, and route validation. Also add coverage for worker event proxy and worker flow routes. Remove all legacy parseStatus and parsedJsonKey fields from document types, API, and gallery view components to align with the new worker-owned PDF parse model. --- src/app/api/documents/route.ts | 4 - src/components/doclist/views/GalleryView.tsx | 14 -- src/types/documents.ts | 2 - .../pdf-parse-client-lifecycle.vitest.spec.ts | 169 ++++++++++++++++++ ...-events-route-worker-proxy.vitest.spec.ts} | 2 +- ...f-parsed-route-worker-flow.vitest.spec.ts} | 2 +- 6 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 tests/unit/pdf-parse-client-lifecycle.vitest.spec.ts rename tests/unit/{pdf-parsed-events-route-legacy-backfill.vitest.spec.ts => pdf-parsed-events-route-worker-proxy.vitest.spec.ts} (98%) rename tests/unit/{pdf-parsed-route-legacy-backfill.vitest.spec.ts => pdf-parsed-route-worker-flow.vitest.spec.ts} (98%) diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 1cdc2f6..fa34474 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -67,8 +67,6 @@ export async function GET(req: NextRequest) { size: number; lastModified: number; filePath: string; - parseState: string | null; - parsedJsonKey: string | null; }>; const results: BaseDocument[] = rows.map((doc) => { @@ -79,8 +77,6 @@ export async function GET(req: NextRequest) { size: Number(doc.size), lastModified: Number(doc.lastModified), type, - parseStatus: null, - parsedJsonKey: null, scope: 'user', }; }); diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx index 0bc609e..6d2bf6a 100644 --- a/src/components/doclist/views/GalleryView.tsx +++ b/src/components/doclist/views/GalleryView.tsx @@ -28,14 +28,6 @@ function formatDateTime(value: number | undefined): string { }); } -function formatParseStatus(status: DocumentListDocument['parseStatus']): string { - if (!status) return 'N/A'; - if (status === 'pending') return 'Pending'; - if (status === 'running') return 'Running'; - if (status === 'ready') return 'Ready'; - return 'Failed'; -} - function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) { if (doc.type === 'pdf') return ; if (doc.type === 'epub') return ; @@ -244,12 +236,6 @@ export function GalleryView({ )} - {activeDoc.type === 'pdf' && ( - <> -
Parse status
-
{formatParseStatus(activeDoc.parseStatus)}
- - )} ) : ( diff --git a/src/types/documents.ts b/src/types/documents.ts index ac870d5..98802b5 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -7,8 +7,6 @@ export interface BaseDocument { lastModified: number; recentlyOpenedAt?: number; type: DocumentType; - parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null; - parsedJsonKey?: string | null; scope?: 'user'; folderId?: string; } diff --git a/tests/unit/pdf-parse-client-lifecycle.vitest.spec.ts b/tests/unit/pdf-parse-client-lifecycle.vitest.spec.ts new file mode 100644 index 0000000..f661970 --- /dev/null +++ b/tests/unit/pdf-parse-client-lifecycle.vitest.spec.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +type SnapshotListener = (event: Event) => void; + +class MockMessageEvent extends Event { + data: string; + + constructor(type: string, init: { data: string }) { + super(type); + this.data = init.data; + } +} + +class MockEventSource { + static instances: MockEventSource[] = []; + + readonly url: string; + readonly listeners = new Map(); + closed = false; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + addEventListener(type: string, listener: SnapshotListener): void { + const arr = this.listeners.get(type) ?? []; + arr.push(listener); + this.listeners.set(type, arr); + } + + close(): void { + this.closed = true; + } + + emit(type: string, payload: unknown): void { + const event = new MockMessageEvent(type, { + data: JSON.stringify(payload), + }); + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +describe('PDF parse client lifecycle', () => { + const originalFetch = global.fetch; + const originalEventSource = global.EventSource; + const originalMessageEvent = global.MessageEvent; + + beforeEach(() => { + MockEventSource.instances = []; + + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + + if (url.endsWith('/api/documents/doc-1/parsed') && method === 'GET') { + fetchMock.getCount = (fetchMock.getCount ?? 0) + 1; + if (fetchMock.getCount === 1) { + return new Response(JSON.stringify({ + parseStatus: 'pending', + parseProgress: null, + opId: null, + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ + documentId: 'doc-1', + pages: [], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.endsWith('/api/documents/doc-1/parsed') && method === 'POST') { + return new Response(JSON.stringify({ + parseStatus: 'pending', + parseProgress: null, + opId: 'op-1', + }), { + status: 202, + headers: { 'Content-Type': 'application/json' }, + }); + } + + throw new Error(`Unexpected fetch: ${method} ${url}`); + }) as typeof fetch & { getCount?: number }; + + global.fetch = fetchMock; + global.EventSource = MockEventSource as unknown as typeof EventSource; + global.MessageEvent = MockMessageEvent as unknown as typeof MessageEvent; + }); + + afterEach(() => { + global.fetch = originalFetch; + global.EventSource = originalEventSource; + global.MessageEvent = originalMessageEvent; + }); + + test('follows not-ready -> ensure op -> SSE snapshots -> ready artifact', async () => { + const { + ParsedPdfNotReadyError, + ensureParsedPdfDocumentOperation, + getParsedPdfDocument, + subscribeParsedPdfDocumentEvents, + } = await import('../../src/lib/client/api/documents'); + + let initialError: unknown; + try { + await getParsedPdfDocument('doc-1'); + } catch (error) { + initialError = error; + } + + expect(initialError).toBeInstanceOf(ParsedPdfNotReadyError); + expect((initialError as InstanceType).parseStatus).toBe('pending'); + + const ensured = await ensureParsedPdfDocumentOperation('doc-1'); + expect(ensured).toMatchObject({ + parseStatus: 'pending', + opId: 'op-1', + }); + + const snapshots: Array<{ parseStatus: string; opId?: string | null }> = []; + const unsubscribe = subscribeParsedPdfDocumentEvents('doc-1', { opId: 'op-1' }, { + onSnapshot: (snapshot) => { + snapshots.push({ + parseStatus: snapshot.parseStatus, + opId: snapshot.opId, + }); + }, + }); + + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]?.url).toBe('/api/documents/doc-1/parsed/events?opId=op-1'); + + MockEventSource.instances[0]?.emit('snapshot', { + eventId: 1, + snapshot: { + opId: 'op-1', + status: 'running', + progress: { totalPages: 5, pagesParsed: 2, currentPage: 3, phase: 'infer' }, + }, + }); + MockEventSource.instances[0]?.emit('snapshot', { + eventId: 2, + snapshot: { + opId: 'op-1', + status: 'succeeded', + }, + }); + + expect(snapshots).toEqual([ + { parseStatus: 'running', opId: 'op-1' }, + { parseStatus: 'ready', opId: 'op-1' }, + ]); + + unsubscribe(); + expect(MockEventSource.instances[0]?.closed).toBe(true); + + const parsed = await getParsedPdfDocument('doc-1'); + expect(parsed).toMatchObject({ documentId: 'doc-1' }); + }); +}); diff --git a/tests/unit/pdf-parsed-events-route-legacy-backfill.vitest.spec.ts b/tests/unit/pdf-parsed-events-route-worker-proxy.vitest.spec.ts similarity index 98% rename from tests/unit/pdf-parsed-events-route-legacy-backfill.vitest.spec.ts rename to tests/unit/pdf-parsed-events-route-worker-proxy.vitest.spec.ts index c86f679..87fd852 100644 --- a/tests/unit/pdf-parsed-events-route-legacy-backfill.vitest.spec.ts +++ b/tests/unit/pdf-parsed-events-route-worker-proxy.vitest.spec.ts @@ -57,7 +57,7 @@ vi.mock('@/lib/server/logger', () => ({ })), })); -describe('GET /api/documents/[id]/parsed/events worker proxy', () => { +describe('GET /api/documents/[id]/parsed/events worker event proxy', () => { const originalFetch = global.fetch; beforeEach(() => { diff --git a/tests/unit/pdf-parsed-route-legacy-backfill.vitest.spec.ts b/tests/unit/pdf-parsed-route-worker-flow.vitest.spec.ts similarity index 98% rename from tests/unit/pdf-parsed-route-legacy-backfill.vitest.spec.ts rename to tests/unit/pdf-parsed-route-worker-flow.vitest.spec.ts index ae1e6d3..13fa85b 100644 --- a/tests/unit/pdf-parsed-route-legacy-backfill.vitest.spec.ts +++ b/tests/unit/pdf-parsed-route-worker-flow.vitest.spec.ts @@ -76,7 +76,7 @@ vi.mock('@/lib/server/logger', () => ({ })), })); -describe('GET/POST /api/documents/[id]/parsed worker-owned flow', () => { +describe('GET/POST /api/documents/[id]/parsed worker flow', () => { beforeEach(() => { hoisted.db = { select: vi.fn(() => ({