mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Coverage 83.6 to 87.36% lines, 81.63 to 84.14% branches. Mutation testing across five packages: image-engine 85, media-engine 92, doc-engine 87, shared+enterprise 86, apps/api security and jobs slice. Runs all five lanes weekly. Fixes the silently-broken mutation CI (babel pin), a redact-pdf envelope-shape test bug, an untested enterprise license valid-signature path, and an audit test that only exercised a hand-copied reproduction. Test and config only, no product code changes beyond the babel pin and one test-only oidc export. Full suite: 16,712 pass, 0 fail.
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
import type { SignPlacement } from "@snapotter/shared";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
/**
|
|
* python-docs.ts wraps runDocsScript (from @snapotter/ai) around the doc_* Python
|
|
* scripts. We mock only runDocsScript and use the real @snapotter/shared SafeError
|
|
* so parseDocsJson's error type is exercised faithfully.
|
|
*/
|
|
const runDocsScript = vi.fn<(script: string, args: unknown, opts?: unknown) => Promise<string>>();
|
|
vi.mock("@snapotter/ai", () => ({
|
|
runDocsScript: (script: string, args: unknown, opts?: unknown) =>
|
|
runDocsScript(script, args, opts),
|
|
}));
|
|
|
|
function firstCall() {
|
|
return runDocsScript.mock.calls[0];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
runDocsScript.mockReset();
|
|
});
|
|
|
|
describe("pdfPageCountPy", () => {
|
|
it("calls doc_pagecount with the path and returns the page count", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"pages": 7}');
|
|
const n = await import("../src/python-docs.js").then((m) => m.pdfPageCountPy("/abs/doc.pdf"));
|
|
expect(n).toBe(7);
|
|
expect(firstCall()).toEqual(["doc_pagecount", { path: "/abs/doc.pdf" }, undefined]);
|
|
});
|
|
|
|
it("throws the reported error message", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "corrupt xref"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfPageCountPy("/x.pdf")),
|
|
).rejects.toThrow("doc_pagecount failed: corrupt xref");
|
|
});
|
|
|
|
it("throws when pages is not a number", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"pages": "seven"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfPageCountPy("/x.pdf")),
|
|
).rejects.toThrow("doc_pagecount failed:");
|
|
});
|
|
|
|
it("returns 0 pages faithfully", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"pages": 0}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfPageCountPy("/x.pdf")),
|
|
).resolves.toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("parseDocsJson (via pdfFlattenPy non-JSON)", () => {
|
|
it("resolves on a valid ok:true payload", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfFlattenPy("/in.pdf", "/out.pdf")),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("passes path + out to doc_flatten", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await import("../src/python-docs.js").then((m) => m.pdfFlattenPy("/in.pdf", "/out.pdf"));
|
|
expect(firstCall()).toEqual(["doc_flatten", { path: "/in.pdf", out: "/out.pdf" }, undefined]);
|
|
});
|
|
|
|
it("wraps non-JSON stdout in a SafeError with kind bug, script code, and raw cause", async () => {
|
|
runDocsScript.mockResolvedValueOnce("Traceback (most recent call last): boom");
|
|
try {
|
|
await import("../src/python-docs.js").then((m) => m.pdfFlattenPy("/in.pdf", "/out.pdf"));
|
|
throw new Error("should have thrown");
|
|
} catch (e) {
|
|
const err = e as {
|
|
message: string;
|
|
name: string;
|
|
kind?: string;
|
|
code?: string;
|
|
cause?: Error;
|
|
};
|
|
expect(err.name).toBe("SafeError");
|
|
expect(err.message).toBe("Document tool returned non-JSON output");
|
|
expect(err.kind).toBe("bug");
|
|
expect(err.code).toBe("doc_flatten");
|
|
expect(err.cause?.message).toContain("doc_flatten stdout (first 200 chars):");
|
|
expect(err.cause?.message).toContain("Traceback");
|
|
}
|
|
});
|
|
|
|
it("truncates the raw stdout to 200 chars in the SafeError cause", async () => {
|
|
const huge = `X${"y".repeat(500)}`; // not JSON
|
|
runDocsScript.mockResolvedValueOnce(huge);
|
|
try {
|
|
await import("../src/python-docs.js").then((m) => m.pdfFlattenPy("/in.pdf", "/out.pdf"));
|
|
throw new Error("should have thrown");
|
|
} catch (e) {
|
|
const cause = (e as { cause?: Error }).cause;
|
|
const snippet = cause?.message.split("chars): ")[1] ?? "";
|
|
expect(snippet.length).toBe(200);
|
|
}
|
|
});
|
|
|
|
it("surfaces a script-reported error (JSON with error field) as a plain Error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "flatten blew up"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfFlattenPy("/in.pdf", "/out.pdf")),
|
|
).rejects.toThrow("doc_flatten failed: flatten blew up");
|
|
});
|
|
});
|
|
|
|
describe("pdfScrubProducerPy", () => {
|
|
it("calls doc_scrub_meta with path + out and resolves on ok", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await import("../src/python-docs.js").then((m) => m.pdfScrubProducerPy("/in.pdf", "/out.pdf"));
|
|
expect(firstCall()).toEqual([
|
|
"doc_scrub_meta",
|
|
{ path: "/in.pdf", out: "/out.pdf" },
|
|
undefined,
|
|
]);
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "scrub failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfScrubProducerPy("/in.pdf", "/out.pdf")),
|
|
).rejects.toThrow("doc_scrub_meta failed: scrub failed");
|
|
});
|
|
});
|
|
|
|
describe("pdfRedactPy", () => {
|
|
it("passes terms + caseSensitive and returns the found count", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"found": 3, "verified": true}');
|
|
const res = await import("../src/python-docs.js").then((m) =>
|
|
m.pdfRedactPy("/in.pdf", "/out.pdf", ["ssn", "dob"], true),
|
|
);
|
|
expect(res).toEqual({ found: 3 });
|
|
expect(firstCall()).toEqual([
|
|
"doc_redact",
|
|
{ path: "/in.pdf", out: "/out.pdf", terms: ["ssn", "dob"], caseSensitive: true },
|
|
undefined,
|
|
]);
|
|
});
|
|
|
|
it("forwards caseSensitive: false verbatim", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"found": 0}');
|
|
await import("../src/python-docs.js").then((m) =>
|
|
m.pdfRedactPy("/in.pdf", "/out.pdf", ["x"], false),
|
|
);
|
|
expect((firstCall()[1] as { caseSensitive: boolean }).caseSensitive).toBe(false);
|
|
});
|
|
|
|
it("returns found: 0 when nothing matched", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"found": 0, "verified": true}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfRedactPy("/in.pdf", "/out.pdf", ["x"], true),
|
|
),
|
|
).resolves.toEqual({ found: 0 });
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "redaction verify failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfRedactPy("/in.pdf", "/out.pdf", ["x"], true),
|
|
),
|
|
).rejects.toThrow("doc_redact failed: redaction verify failed");
|
|
});
|
|
|
|
it("throws when found is missing but no error field is present", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"verified": true}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfRedactPy("/in.pdf", "/out.pdf", ["x"], true),
|
|
),
|
|
).rejects.toThrow("doc_redact failed:");
|
|
});
|
|
});
|
|
|
|
describe("pdfTextPy", () => {
|
|
it("passes path + out and returns chars + hasText", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"chars": 128, "hasText": true}');
|
|
const res = await import("../src/python-docs.js").then((m) =>
|
|
m.pdfTextPy("/in.pdf", "/out.txt"),
|
|
);
|
|
expect(res).toEqual({ chars: 128, hasText: true });
|
|
expect(firstCall()).toEqual(["doc_text", { path: "/in.pdf", out: "/out.txt" }, undefined]);
|
|
});
|
|
|
|
it("respects an explicit hasText:false even with chars > 0", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"chars": 50, "hasText": false}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfTextPy("/in.pdf", "/out.txt")),
|
|
).resolves.toEqual({ chars: 50, hasText: false });
|
|
});
|
|
|
|
it("derives hasText=true from chars>0 when hasText is absent", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"chars": 5}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfTextPy("/in.pdf", "/out.txt")),
|
|
).resolves.toEqual({ chars: 5, hasText: true });
|
|
});
|
|
|
|
it("derives hasText=false from chars===0 when hasText is absent", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"chars": 0}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfTextPy("/in.pdf", "/out.txt")),
|
|
).resolves.toEqual({ chars: 0, hasText: false });
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "text extract failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfTextPy("/in.pdf", "/out.txt")),
|
|
).rejects.toThrow("doc_text failed: text extract failed");
|
|
});
|
|
|
|
it("throws when chars is not a number and no error field is present", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"hasText": true}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfTextPy("/in.pdf", "/out.txt")),
|
|
).rejects.toThrow("doc_text failed:");
|
|
});
|
|
});
|
|
|
|
describe("pdfToWordPy", () => {
|
|
it("calls doc_to_word with a 5-minute timeout", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await import("../src/python-docs.js").then((m) => m.pdfToWordPy("/in.pdf", "/out.docx"));
|
|
expect(firstCall()).toEqual([
|
|
"doc_to_word",
|
|
{ path: "/in.pdf", out: "/out.docx" },
|
|
{ timeoutMs: 300_000 },
|
|
]);
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "pdf2docx failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfToWordPy("/in.pdf", "/out.docx")),
|
|
).rejects.toThrow("doc_to_word failed: pdf2docx failed");
|
|
});
|
|
});
|
|
|
|
describe("pdfMetadataGetPy", () => {
|
|
it("calls doc_metadata mode:get and returns the metadata object", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"metadata": {"Title": "Report", "Author": "Otter"}}');
|
|
const meta = await import("../src/python-docs.js").then((m) => m.pdfMetadataGetPy("/in.pdf"));
|
|
expect(meta).toEqual({ Title: "Report", Author: "Otter" });
|
|
expect(firstCall()).toEqual(["doc_metadata", { path: "/in.pdf", mode: "get" }, undefined]);
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "docinfo read failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfMetadataGetPy("/in.pdf")),
|
|
).rejects.toThrow("doc_metadata get failed: docinfo read failed");
|
|
});
|
|
|
|
it("throws when metadata is missing/not an object", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"metadata": "not-an-object"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.pdfMetadataGetPy("/in.pdf")),
|
|
).rejects.toThrow("doc_metadata get failed:");
|
|
});
|
|
});
|
|
|
|
describe("pdfMetadataSetPy", () => {
|
|
it("calls doc_metadata mode:set with the metadata payload", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await import("../src/python-docs.js").then((m) =>
|
|
m.pdfMetadataSetPy("/in.pdf", "/out.pdf", { Title: "New Title" }),
|
|
);
|
|
expect(firstCall()).toEqual([
|
|
"doc_metadata",
|
|
{ path: "/in.pdf", out: "/out.pdf", mode: "set", metadata: { Title: "New Title" } },
|
|
undefined,
|
|
]);
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "docinfo write failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfMetadataSetPy("/in.pdf", "/out.pdf", { Title: "X" }),
|
|
),
|
|
).rejects.toThrow("doc_metadata set failed: docinfo write failed");
|
|
});
|
|
});
|
|
|
|
describe("htmlToPdfPy", () => {
|
|
it("calls doc_html_pdf with the mode and a 2-minute timeout", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await import("../src/python-docs.js").then((m) =>
|
|
m.htmlToPdfPy("/in.html", "/out.pdf", "html"),
|
|
);
|
|
expect(firstCall()).toEqual([
|
|
"doc_html_pdf",
|
|
{ path: "/in.html", out: "/out.pdf", mode: "html" },
|
|
{ timeoutMs: 120_000 },
|
|
]);
|
|
});
|
|
|
|
it("forwards the markdown mode verbatim", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await import("../src/python-docs.js").then((m) =>
|
|
m.htmlToPdfPy("/in.md", "/out.pdf", "markdown"),
|
|
);
|
|
expect((firstCall()[1] as { mode: string }).mode).toBe("markdown");
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "weasyprint failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) => m.htmlToPdfPy("/in.html", "/out.pdf", "html")),
|
|
).rejects.toThrow("doc_html_pdf failed: weasyprint failed");
|
|
});
|
|
});
|
|
|
|
describe("pdfSignPy", () => {
|
|
const placements: SignPlacement[] = [{ sig: 0, page: 1, x: 0.1, y: 0.2, w: 0.3, h: 0.4 }];
|
|
|
|
it("uses input/output keys and passes signatures + placements, returning placed", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"placed": 2, "ok": true}');
|
|
const res = await import("../src/python-docs.js").then((m) =>
|
|
m.pdfSignPy("/in.pdf", "/out.pdf", ["sig0.png", "sig1.png"], placements),
|
|
);
|
|
expect(res).toEqual({ placed: 2 });
|
|
expect(firstCall()).toEqual([
|
|
"doc_sign",
|
|
{
|
|
input: "/in.pdf",
|
|
output: "/out.pdf",
|
|
signatures: ["sig0.png", "sig1.png"],
|
|
placements,
|
|
},
|
|
undefined,
|
|
]);
|
|
});
|
|
|
|
it("returns placed: 0 faithfully", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"placed": 0}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfSignPy("/in.pdf", "/out.pdf", ["s.png"], placements),
|
|
),
|
|
).resolves.toEqual({ placed: 0 });
|
|
});
|
|
|
|
it("throws the reported error", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"error": "sign failed"}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfSignPy("/in.pdf", "/out.pdf", ["s.png"], placements),
|
|
),
|
|
).rejects.toThrow("doc_sign failed: sign failed");
|
|
});
|
|
|
|
it("throws when placed is missing but no error field is present", async () => {
|
|
runDocsScript.mockResolvedValueOnce('{"ok": true}');
|
|
await expect(
|
|
import("../src/python-docs.js").then((m) =>
|
|
m.pdfSignPy("/in.pdf", "/out.pdf", ["s.png"], placements),
|
|
),
|
|
).rejects.toThrow("doc_sign failed:");
|
|
});
|
|
});
|