261 lines
8.3 KiB
TypeScript
261 lines
8.3 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { NextRequest } from 'next/server';
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
isServerConfiguredProvider: vi.fn(() => false),
|
|
requireProviderAccess: vi.fn(),
|
|
resolveManagedAliDocMindCredentials: vi.fn(),
|
|
resolvePDFApiKey: vi.fn((_providerId: string, clientKey?: string) => clientKey || ''),
|
|
resolvePDFBaseUrl: vi.fn((_providerId: string, clientBaseUrl?: string) => clientBaseUrl),
|
|
parseWithMinerUCloud: vi.fn(),
|
|
extractMedia: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/logger', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
debug: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/lib/server/provider-config', () => ({
|
|
isServerConfiguredProvider: mocks.isServerConfiguredProvider,
|
|
requireProviderAccess: mocks.requireProviderAccess,
|
|
resolveManagedAliDocMindCredentials: mocks.resolveManagedAliDocMindCredentials,
|
|
resolvePDFApiKey: mocks.resolvePDFApiKey,
|
|
resolvePDFBaseUrl: mocks.resolvePDFBaseUrl,
|
|
}));
|
|
|
|
vi.mock('@/lib/document', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@/lib/document')>();
|
|
return {
|
|
...actual,
|
|
extractMedia: mocks.extractMedia,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lib/pdf/mineru-cloud', () => ({
|
|
parseWithMinerUCloud: mocks.parseWithMinerUCloud,
|
|
}));
|
|
|
|
async function postExtractDocument(input: {
|
|
file: File;
|
|
providerId?: string;
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
accessKeyId?: string;
|
|
accessKeySecret?: string;
|
|
}) {
|
|
const { POST } = await import('@/app/api/extract-document/route');
|
|
const formData = new FormData();
|
|
formData.append('file', input.file);
|
|
if (input.providerId) formData.append('providerId', input.providerId);
|
|
if (input.apiKey) formData.append('apiKey', input.apiKey);
|
|
if (input.baseUrl) formData.append('baseUrl', input.baseUrl);
|
|
if (input.accessKeyId) formData.append('accessKeyId', input.accessKeyId);
|
|
if (input.accessKeySecret) formData.append('accessKeySecret', input.accessKeySecret);
|
|
|
|
const request = new Request('http://localhost/api/extract-document', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
return POST(request as unknown as NextRequest);
|
|
}
|
|
|
|
describe('POST /api/extract-document', () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.unstubAllEnvs();
|
|
mocks.isServerConfiguredProvider.mockReturnValue(false);
|
|
mocks.requireProviderAccess.mockReset();
|
|
mocks.resolveManagedAliDocMindCredentials.mockReset();
|
|
mocks.resolvePDFApiKey.mockImplementation(
|
|
(_providerId: string, clientKey?: string) => clientKey || '',
|
|
);
|
|
mocks.resolvePDFBaseUrl.mockImplementation(
|
|
(_providerId: string, clientBaseUrl?: string) => clientBaseUrl,
|
|
);
|
|
mocks.parseWithMinerUCloud.mockReset();
|
|
mocks.extractMedia.mockReset();
|
|
mocks.parseWithMinerUCloud.mockResolvedValue({
|
|
text: 'cloud parsed text',
|
|
images: [],
|
|
metadata: {
|
|
pageCount: 1,
|
|
parser: 'mineru-cloud',
|
|
},
|
|
});
|
|
delete process.env.PDF_MINERU_BASE_URL;
|
|
delete process.env.PDF_MINERU_API_KEY;
|
|
});
|
|
|
|
it('returns 400 for unsupported course material MIME types', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File(['x,y'], 'sheet.csv', { type: 'text/csv' }),
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(json).toMatchObject({
|
|
success: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
});
|
|
});
|
|
|
|
it('rejects server-role unmanaged media extraction before caller AK/SK or base URL reaches the adapter', async () => {
|
|
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server');
|
|
const { ServerManagedProviderRequiredError } =
|
|
await import('@/lib/server/provider-access-policy');
|
|
mocks.requireProviderAccess.mockImplementation(() => {
|
|
throw new ServerManagedProviderRequiredError();
|
|
});
|
|
|
|
const res = await postExtractDocument({
|
|
file: new File(['audio'], 'lesson.mp3', { type: 'audio/mpeg' }),
|
|
providerId: 'alidocmind',
|
|
baseUrl: 'https://attacker.invalid',
|
|
accessKeyId: 'caller-ak',
|
|
accessKeySecret: 'caller-sk',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(json).toMatchObject({ success: false, errorCode: 'FORBIDDEN' });
|
|
expect(mocks.requireProviderAccess).toHaveBeenCalledWith('pdf', 'alidocmind');
|
|
expect(mocks.extractMedia).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 413 before extraction when the file exceeds the per-file size limit', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File([new Uint8Array(51 * 1024 * 1024)], 'large.pdf', {
|
|
type: 'application/pdf',
|
|
}),
|
|
providerId: 'mineru-cloud',
|
|
apiKey: 'cloud-key',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(413);
|
|
expect(json).toMatchObject({
|
|
success: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
});
|
|
expect(json.error).toContain('Maximum size is 50MB');
|
|
expect(mocks.parseWithMinerUCloud).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns 400 for an unknown requested provider', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File(['hello'], 'notes.txt', { type: 'text/plain' }),
|
|
providerId: 'missing-provider',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(400);
|
|
expect(json).toMatchObject({
|
|
success: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
error: 'Unknown document extractor provider: missing-provider',
|
|
});
|
|
});
|
|
|
|
it('treats an incompatible preferred provider as a hint and falls back by MIME type', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File(['hello'], 'notes.txt', { type: 'text/plain' }),
|
|
providerId: 'unpdf',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(json).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
text: 'hello',
|
|
metadata: {
|
|
mimeType: 'text/plain',
|
|
parser: 'plain-text',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('returns actionable 422 diagnostics when DOCX requires unconfigured MinerU', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File(['not really docx'], 'lesson.docx', {
|
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
}),
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(json).toMatchObject({
|
|
success: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
});
|
|
expect(json.error).toContain('DOCX extraction requires a configured MinerU document extractor');
|
|
expect(json.error).toContain('self-hosted MinerU base URL or a MinerU Cloud API key');
|
|
});
|
|
|
|
it('allows MinerU Cloud PDF extraction with an API key and no base URL', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File(['%PDF-1.4'], 'lesson.pdf', { type: 'application/pdf' }),
|
|
providerId: 'mineru-cloud',
|
|
apiKey: 'cloud-key',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(json).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
text: 'cloud parsed text',
|
|
metadata: {
|
|
parser: 'mineru-cloud',
|
|
},
|
|
},
|
|
});
|
|
expect(mocks.parseWithMinerUCloud).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerId: 'mineru-cloud',
|
|
apiKey: 'cloud-key',
|
|
baseUrl: undefined,
|
|
}),
|
|
expect.any(Buffer),
|
|
'lesson.pdf',
|
|
);
|
|
});
|
|
|
|
it('falls back to MinerU Cloud for DOCX when self-hosted MinerU is unavailable and a cloud key is provided', async () => {
|
|
const res = await postExtractDocument({
|
|
file: new File(['not really docx'], 'lesson.docx', {
|
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
}),
|
|
apiKey: 'cloud-key',
|
|
});
|
|
const json = await res.json();
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(json).toMatchObject({
|
|
success: true,
|
|
data: {
|
|
text: 'cloud parsed text',
|
|
metadata: {
|
|
parser: 'mineru-cloud',
|
|
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
},
|
|
},
|
|
});
|
|
expect(mocks.parseWithMinerUCloud).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
providerId: 'mineru-cloud',
|
|
apiKey: 'cloud-key',
|
|
baseUrl: undefined,
|
|
}),
|
|
expect.any(Buffer),
|
|
'lesson.docx',
|
|
);
|
|
});
|
|
});
|