Files
curriculum-project-hub/hub/test/integration/capability-pdf-to-md.test.ts
T
2026-07-18 15:55:02 +08:00

286 lines
9.4 KiB
TypeScript

import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
import { createPdfToMdBundleAdapter, CapabilityPathEscape } from "../../src/capability/pdfToMdBundle.js";
import { MineruClientError, type MineruClient, type MineruParseResult } from "../../src/capability/mineruClient.js";
import type { CapabilitySecretPayload } from "../../src/capability/types.js";
const CAPABILITY_ID = "pdf_to_md_bundle";
const PROVIDER_ID = "mineru";
/**
* ADR-0027: pdf_to_md_bundle adapter contract tests. Proves:
* - workspace containment (input + output confined to run workspace)
* - fail-closed credential resolution (no ACTIVE connection → error)
* - UsageFact attribution with non-token meter (pages), costSource rules
* - outputs (md + images) written into workspace
* - MinerU client errors propagate
* The MineruClient is mocked; the prisma + envelope + filesystem are real.
*/
describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
let workspaceRoot: string;
let runId: string;
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-cap-"));
runId = "run-cap-test";
// AgentRun is required for the UsageFact FK.
await prisma.project.create({
data: {
id: "proj-cap",
organizationId: DEFAULT_ORG_ID,
name: "Cap Test",
workspaceDir: workspaceRoot,
},
});
await prisma.agentRun.create({
data: {
id: runId,
projectId: "proj-cap",
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
status: "ACTIVE",
prompt: "convert this pdf",
metadata: {},
},
});
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
});
async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
baseUrl: "https://mineru.test/api",
apiToken: "mineru-secret-token",
projectId: null,
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
id: "cap-conn-1",
organizationId: DEFAULT_ORG_ID,
capabilityId: CAPABILITY_ID,
status: "ACTIVE",
activatedAt: new Date(),
},
});
const envelope = testSecretEnvelope.encryptJson(
{
purpose: "capability",
organizationId: DEFAULT_ORG_ID,
connectionId: connection.id,
secretVersionId: "cap-sv-1",
},
payload,
);
await prisma.capabilityCredentialVersion.create({
data: {
id: "cap-sv-1",
connectionId: connection.id,
version: 1,
envelope: envelope as object,
keyId: envelope.keyId,
},
});
await prisma.organizationCapabilityConnection.update({
where: { id: connection.id },
data: { activeSecretVersionId: "cap-sv-1" },
});
}
function mockMineruClient(result: Partial<MineruParseResult> = {}): MineruClient {
const full: MineruParseResult = {
markdown: "# Parsed Document\n\nHello world.\n\n![fig](page_1_fig_0.jpg)\n",
images: [
{ filename: "page_1_fig_0.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
],
pageCount: 3,
costUsd: 0.015,
requestId: "mineru-task-abc",
...result,
};
return {
parse: vi.fn(async (): Promise<MineruParseResult> => full),
};
}
function makeAdapter(client: MineruClient) {
return createPdfToMdBundleAdapter({
secrets: testSecretEnvelope,
client,
prisma,
});
}
async function seedInputPdf(name: string = "input.pdf"): Promise<string> {
const dir = join(workspaceRoot, "sources");
await mkdir(dir, { recursive: true });
const path = join(dir, name);
await writeFile(path, "%PDF-1.4 fake pdf bytes");
return join("sources", name);
}
it("writes md + images into the workspace and records a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient();
const adapter = makeAdapter(client);
const result = await adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
});
// Outputs landed in workspace.
const md = await readFile(join(workspaceRoot, "output", "document.md"), "utf8");
expect(md).toContain("# Parsed Document");
expect(result.artifacts.some((a) => a.kind === "markdown" && a.path === "output/document.md")).toBe(true);
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1_fig_0.jpg")).toBe(true);
// Client received the decrypted credential, not the env.
const call = (client.parse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as CapabilitySecretPayload;
expect(call.apiToken).toBe("mineru-secret-token");
expect(call.baseUrl).toBe("https://mineru.test/api");
// UsageFact written with non-token meter and provider_reported cost.
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(1);
const fact = facts[0]!;
expect(fact.kind).toBe("external_capability");
expect(fact.provider).toBe(PROVIDER_ID);
expect(fact.capabilityId).toBe(CAPABILITY_ID);
expect(Number(fact.quantity)).toBe(3);
expect(fact.unit).toBe("pages");
expect(Number(fact.costUsd!)).toBeCloseTo(0.015, 8);
expect(fact.costSource).toBe("provider_reported");
expect(fact.correlationId).toBe("mineru-task-abc");
});
it("writes UsageFact with costSource=unknown when MinerU reports no cost", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient({ costUsd: null, requestId: null });
const adapter = makeAdapter(client);
await adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
});
const fact = (await prisma.usageFact.findFirstOrThrow({ where: { runId } }));
expect(fact.costUsd).toBeNull();
expect(fact.costSource).toBe("unknown");
// quantity still recorded — missing cost ≠ zero consumption (ADR-0022).
expect(Number(fact.quantity)).toBe(3);
});
it("fails closed when the org has no ACTIVE capability connection", async () => {
// No connection seeded.
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockMineruClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/no ACTIVE capability connection/);
// No UsageFact written for a failed resolution.
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(0);
});
it("rejects an input path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const adapter = makeAdapter(mockMineruClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath: "../../../etc/passwd",
outputDir: "output",
prisma,
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("rejects an output path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockMineruClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "../../outside",
prisma,
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("propagates MinerU client errors without writing a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client: MineruClient = {
parse: vi.fn(async (): Promise<MineruParseResult> => {
throw new MineruClientError("upstream 502", "mineru_rejected", 502);
}),
};
const adapter = makeAdapter(client);
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/upstream 502/);
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(0);
});
it("rejects empty markdown output as a parse failure", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient({ markdown: "" });
const adapter = makeAdapter(client);
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/empty markdown/);
});
});