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.
This commit is contained in:
Richard R 2026-06-04 20:04:30 -06:00
parent 7a86ce6a94
commit 9db30742f8
6 changed files with 171 additions and 22 deletions

View file

@ -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',
};
});

View file

@ -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 <PDFIcon className={className ?? 'w-4 h-4 text-danger'} />;
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-accent'} />;
@ -244,12 +236,6 @@ export function GalleryView({
</dd>
</>
)}
{activeDoc.type === 'pdf' && (
<>
<dt className="text-soft">Parse status</dt>
<dd className="text-foreground text-right">{formatParseStatus(activeDoc.parseStatus)}</dd>
</>
)}
</dl>
</div>
) : (

View file

@ -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;
}

View file

@ -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<string, SnapshotListener[]>();
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<typeof ParsedPdfNotReadyError>).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' });
});
});

View file

@ -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(() => {

View file

@ -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(() => ({