import JSZip from 'jszip'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { parseWithMinerUCloud } from '@/lib/pdf/mineru-cloud'; const mocks = vi.hoisted(() => ({ fetchPinnedPublicUrl: vi.fn(), })); vi.mock('@/lib/server/public-url-fetch', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, fetchPinnedPublicUrl: mocks.fetchPinnedPublicUrl }; }); vi.mock('@/lib/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }), })); describe('MinerU Cloud document upload', () => { beforeEach(() => { mocks.fetchPinnedPublicUrl.mockReset(); }); afterEach(() => { vi.unstubAllGlobals(); }); it('preserves supported Office filename extensions for Cloud type inference', async () => { const zip = new JSZip(); zip.file('full.md', '# Parsed lesson'); const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }); const batchBodies: unknown[] = []; const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); if (url.endsWith('/file-urls/batch')) { batchBodies.push(JSON.parse(String(init?.body))); return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { batch_id: 'batch-1', file_urls: ['https://upload.example/lesson.docx'], }, }), { status: 200 }, ); } if (url === 'https://upload.example/lesson.docx') { return new Response('', { status: 200 }); } if (url.endsWith('/extract-results/batch/batch-1')) { return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { extract_result: { file_name: 'lesson.docx', state: 'done', full_zip_url: 'https://download.example/result.zip', }, }, }), { status: 200 }, ); } if (url === 'https://download.example/result.zip') { return new Response( zipBuffer.buffer.slice( zipBuffer.byteOffset, zipBuffer.byteOffset + zipBuffer.byteLength, ) as ArrayBuffer, { status: 200 }, ); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const disposals: Array> = []; mocks.fetchPinnedPublicUrl.mockImplementation(async (input, init) => { const dispose = vi.fn().mockResolvedValue(undefined); disposals.push(dispose); return { response: await fetchMock(input, init), dispose, }; }); const result = await parseWithMinerUCloud( { providerId: 'mineru-cloud', apiKey: 'cloud-key', baseUrl: 'https://mineru.example/api/v4', }, Buffer.from('docx bytes'), 'lesson.docx', ); expect(result.text).toContain('Parsed lesson'); expect(result.metadata?.parser).toBe('mineru-cloud'); expect(batchBodies).toEqual([ expect.objectContaining({ files: [{ name: 'lesson.docx' }], }), ]); expect(mocks.fetchPinnedPublicUrl).toHaveBeenNthCalledWith( 1, 'https://upload.example/lesson.docx', expect.objectContaining({ method: 'PUT', body: Buffer.from('docx bytes'), maxRequestBytes: 50 * 1024 * 1024, maxResponseBytes: 1024 * 1024, }), ); expect(mocks.fetchPinnedPublicUrl).toHaveBeenNthCalledWith( 2, 'https://download.example/result.zip', expect.objectContaining({ method: 'GET', maxResponseBytes: 100 * 1024 * 1024, }), ); expect(disposals).toHaveLength(2); for (const dispose of disposals) expect(dispose).toHaveBeenCalledOnce(); }); it('preserves legacy Office filenames (cloud accepts .doc/.ppt/.xls)', async () => { const zip = new JSZip(); zip.file('full.md', '# Parsed legacy lesson'); const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' }); const batchBodies: unknown[] = []; const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); if (url.endsWith('/file-urls/batch')) { batchBodies.push(JSON.parse(String(init?.body))); return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { batch_id: 'batch-legacy', file_urls: ['https://upload.example/legacy.doc'], }, }), { status: 200 }, ); } if (url === 'https://upload.example/legacy.doc') { return new Response('', { status: 200 }); } if (url.endsWith('/extract-results/batch/batch-legacy')) { return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { extract_result: { file_name: 'legacy.doc', state: 'done', full_zip_url: 'https://download.example/legacy.zip', }, }, }), { status: 200 }, ); } if (url === 'https://download.example/legacy.zip') { return new Response( zipBuffer.buffer.slice( zipBuffer.byteOffset, zipBuffer.byteOffset + zipBuffer.byteLength, ) as ArrayBuffer, { status: 200 }, ); } throw new Error(`Unexpected fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); mocks.fetchPinnedPublicUrl.mockImplementation(async (input, init) => ({ response: await fetchMock(input, init), dispose: vi.fn().mockResolvedValue(undefined), })); const result = await parseWithMinerUCloud( { providerId: 'mineru-cloud', apiKey: 'cloud-key', baseUrl: 'https://mineru.example/api/v4', }, Buffer.from('doc bytes'), 'legacy.doc', ); expect(result.text).toContain('Parsed legacy lesson'); expect(batchBodies).toEqual([ expect.objectContaining({ files: [{ name: 'legacy.doc' }], }), ]); }); it('fails closed on a presigned upload redirect without polling or forwarding API auth', async () => { const fetchMock = vi.fn(async (input: string | URL | Request) => { const url = String(input); if (url.endsWith('/file-urls/batch')) { return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { batch_id: 'batch-redirect', file_urls: ['https://upload.example/presigned?signature=secret'], }, }), { status: 200 }, ); } throw new Error(`Unexpected direct fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const dispose = vi.fn().mockResolvedValue(undefined); mocks.fetchPinnedPublicUrl.mockResolvedValue({ response: new Response(null, { status: 307, headers: { Location: 'http://127.0.0.1/internal' }, }), dispose, }); await expect( parseWithMinerUCloud( { providerId: 'mineru-cloud', apiKey: 'cloud-key', baseUrl: 'https://mineru.example/api/v4', }, Buffer.from('document bytes'), 'lesson.pdf', ), ).rejects.toThrow('MinerU Cloud upload redirect is not allowed'); expect(mocks.fetchPinnedPublicUrl).toHaveBeenCalledOnce(); expect(mocks.fetchPinnedPublicUrl.mock.calls[0][1]).not.toHaveProperty('headers'); expect(fetchMock).toHaveBeenCalledOnce(); expect(dispose).toHaveBeenCalledOnce(); }); it('fails closed on a result ZIP redirect and disposes both pinned connections', async () => { const fetchMock = vi.fn(async (input: string | URL | Request) => { const url = String(input); if (url.endsWith('/file-urls/batch')) { return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { batch_id: 'batch-zip-redirect', file_urls: ['https://upload.example/presigned?signature=secret'], }, }), { status: 200 }, ); } if (url.endsWith('/extract-results/batch/batch-zip-redirect')) { return new Response( JSON.stringify({ code: 0, msg: 'ok', data: { extract_result: { file_name: 'lesson.pdf', state: 'done', full_zip_url: 'https://download.example/result.zip?signature=secret', }, }, }), { status: 200 }, ); } throw new Error(`Unexpected direct fetch: ${url}`); }); vi.stubGlobal('fetch', fetchMock); const uploadDispose = vi.fn().mockResolvedValue(undefined); const zipDispose = vi.fn().mockResolvedValue(undefined); mocks.fetchPinnedPublicUrl .mockResolvedValueOnce({ response: new Response(null, { status: 200 }), dispose: uploadDispose, }) .mockResolvedValueOnce({ response: new Response(null, { status: 302, headers: { Location: 'http://100.100.100.200/latest/meta-data' }, }), dispose: zipDispose, }); await expect( parseWithMinerUCloud( { providerId: 'mineru-cloud', apiKey: 'cloud-key', baseUrl: 'https://mineru.example/api/v4', }, Buffer.from('document bytes'), 'lesson.pdf', ), ).rejects.toThrow('MinerU Cloud ZIP download redirect is not allowed'); expect(mocks.fetchPinnedPublicUrl).toHaveBeenCalledTimes(2); expect(mocks.fetchPinnedPublicUrl.mock.calls[0][1]).not.toHaveProperty('headers'); expect(mocks.fetchPinnedPublicUrl.mock.calls[1][1]).not.toHaveProperty('headers'); expect(uploadDispose).toHaveBeenCalledOnce(); expect(zipDispose).toHaveBeenCalledOnce(); }); });