Files
SnapOtter/tests/unit/api/ai-handler-registry.test.ts
T
SnapOtterandGitHub 301e6eb01a test: coverage campaign and mutation testing across five packages (#628)
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.
2026-07-24 17:36:57 +08:00

142 lines
5.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { AiJobOutput, AiPathJobInput } from "../../../apps/api/src/jobs/ai-handlers.js";
import {
hasAiJobHandler,
hasAiPathJobHandler,
registerAiJobHandler,
registerAiPathJobHandler,
runAiPathToolJob,
runAiToolJob,
} from "../../../apps/api/src/jobs/ai-handlers.js";
import type { ToolJobData } from "../../../apps/api/src/jobs/types.js";
import type { ToolProcessCtx } from "../../../apps/api/src/routes/tool-factory.js";
const ctx: ToolProcessCtx = {
signal: new AbortController().signal,
scratchDir: "/tmp/ai-handler-registry-test",
report: vi.fn(),
};
function job(toolId: string): ToolJobData {
return {
jobId: `job-${toolId}`,
toolId,
userId: null,
pool: "ai",
inputRefs: [`uploads/job-${toolId}/input.png`],
filename: "input.png",
settings: {},
kind: "ai-tool",
};
}
const output: AiJobOutput = {
buffer: Buffer.from("out"),
filename: "input_done.png",
contentType: "image/png",
};
// Unique per-test tool ids keep the module-level registry Maps isolated between
// cases so registration in one test never leaks into another.
describe("AI job handler registry", () => {
describe("buffer-backed handlers", () => {
it("reports no handler for a tool id that was never registered", () => {
expect(hasAiJobHandler("registry-buffer-unregistered")).toBe(false);
});
it("registers a handler and dispatches the job to it", async () => {
const handler = vi.fn(async () => output);
registerAiJobHandler("registry-buffer-dispatch", handler);
expect(hasAiJobHandler("registry-buffer-dispatch")).toBe(true);
const input = Buffer.from("in");
const data = job("registry-buffer-dispatch");
const result = await runAiToolJob(data, input, ctx);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(input, data, ctx);
expect(result).toBe(output);
});
it("throws a descriptive error when running a job with no registered handler", async () => {
expect(hasAiJobHandler("registry-buffer-missing")).toBe(false);
await expect(
runAiToolJob(job("registry-buffer-missing"), Buffer.from("x"), ctx),
).rejects.toThrow("No AI job handler for registry-buffer-missing");
});
it("overwrites an existing handler when the same tool id is registered twice", async () => {
const first = vi.fn(async () => output);
const second = vi.fn(async () => ({ ...output, filename: "second.png" }));
registerAiJobHandler("registry-buffer-overwrite", first);
registerAiJobHandler("registry-buffer-overwrite", second);
const result = await runAiToolJob(job("registry-buffer-overwrite"), Buffer.from("x"), ctx);
expect(first).not.toHaveBeenCalled();
expect(second).toHaveBeenCalledTimes(1);
expect(result.filename).toBe("second.png");
});
});
describe("path-backed handlers", () => {
const pathInput: AiPathJobInput = { path: "/tmp/scan.pdf", size: 42 };
it("reports no path handler for a tool id that was never registered", () => {
expect(hasAiPathJobHandler("registry-path-unregistered")).toBe(false);
});
it("keeps the buffer and path registries independent", () => {
registerAiJobHandler(
"registry-buffer-only",
vi.fn(async () => output),
);
// A buffer registration must not register the id as a path handler.
expect(hasAiJobHandler("registry-buffer-only")).toBe(true);
expect(hasAiPathJobHandler("registry-buffer-only")).toBe(false);
});
it("registers a path handler and dispatches the job to it", async () => {
const handler = vi.fn(async () => output);
registerAiPathJobHandler("registry-path-dispatch", handler);
expect(hasAiPathJobHandler("registry-path-dispatch")).toBe(true);
// The path registration must not leak into the buffer registry.
expect(hasAiJobHandler("registry-path-dispatch")).toBe(false);
const data = job("registry-path-dispatch");
const result = await runAiPathToolJob(data, pathInput, ctx);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(pathInput, data, ctx);
expect(result).toBe(output);
});
it("throws a descriptive error when running a path job with no registered handler", async () => {
expect(hasAiPathJobHandler("registry-path-missing")).toBe(false);
await expect(runAiPathToolJob(job("registry-path-missing"), pathInput, ctx)).rejects.toThrow(
"No path-backed AI job handler for registry-path-missing",
);
});
it("does not fall back to a buffer handler for a path job dispatch", async () => {
// Only a buffer handler exists for this id; the path dispatch must still fail.
registerAiJobHandler(
"registry-path-no-fallback",
vi.fn(async () => output),
);
expect(hasAiJobHandler("registry-path-no-fallback")).toBe(true);
expect(hasAiPathJobHandler("registry-path-no-fallback")).toBe(false);
await expect(
runAiPathToolJob(job("registry-path-no-fallback"), pathInput, ctx),
).rejects.toThrow("No path-backed AI job handler for registry-path-no-fallback");
});
});
});