323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
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<typeof import('@/lib/server/public-url-fetch')>();
|
|
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<ReturnType<typeof vi.fn>> = [];
|
|
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();
|
|
});
|
|
});
|