Files
SnapOtter/tests/unit/api/jobs/enqueue.behavior.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

522 lines
17 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToolJobData } from "../../../../apps/api/src/jobs/types.js";
const insertedValues = vi.hoisted(() => vi.fn());
const queueAdd = vi.hoisted(() => vi.fn());
const getJob = vi.hoisted(() => vi.fn());
const queueEventClose = vi.hoisted(() => vi.fn());
const flowProducerClose = vi.hoisted(() => vi.fn());
const assertAiJobQuotaMock = vi.hoisted(() => vi.fn());
const isFeatureEnabledMock = vi.hoisted(() => vi.fn());
const propagationInjectMock = vi.hoisted(() => vi.fn());
const selectMock = vi.hoisted(() => vi.fn());
const updateSetMock = vi.hoisted(() => vi.fn());
const updateMock = vi.hoisted(() => vi.fn());
// Chain builder for `db.select(...).from(...).where(...).limit(...)`.
function selectChain<T>(result: T) {
const chain = {
from: vi.fn(() => chain),
where: vi.fn(() => chain),
limit: vi.fn(() => Promise.resolve(result)),
};
return chain;
}
async function loadEnqueueModule(
options: {
/** Whether the enterprise team_retention_overrides feature is enabled. */
teamRetentionEnabled?: boolean;
/** Rows returned by the users-table select in computeDeleteAfter. */
userRow?: Array<{ team: string | null }>;
/** Rows returned by the teams-table select in computeDeleteAfter. */
teamRow?: Array<{ retentionHours: number | null }>;
/** Carrier populated by the mocked propagation.inject. */
injectCarrier?: Record<string, string>;
/** Force the dynamic enterprise import to throw. */
enterpriseImportThrows?: boolean;
} = {},
) {
vi.resetModules();
insertedValues.mockReset();
queueAdd.mockReset();
getJob.mockReset();
queueEventClose.mockReset();
flowProducerClose.mockReset();
assertAiJobQuotaMock.mockReset();
isFeatureEnabledMock.mockReset();
propagationInjectMock.mockReset();
selectMock.mockReset();
updateSetMock.mockReset();
updateMock.mockReset();
queueAdd.mockResolvedValue({ id: "job-1" });
queueEventClose.mockResolvedValue(undefined);
flowProducerClose.mockResolvedValue(undefined);
assertAiJobQuotaMock.mockResolvedValue(undefined);
isFeatureEnabledMock.mockReturnValue(options.teamRetentionEnabled ?? false);
// Default: two sequential selects (users then teams).
selectMock
.mockReturnValueOnce(selectChain(options.userRow ?? [{ team: null }]))
.mockReturnValueOnce(selectChain(options.teamRow ?? [{ retentionHours: null }]));
const updateWhere = vi.fn(() => Promise.resolve(undefined));
updateSetMock.mockReturnValue({ where: updateWhere });
updateMock.mockReturnValue({ set: updateSetMock });
// The mocked propagation.inject copies the requested carrier keys into the
// carrier object enqueueToolJob passes in.
propagationInjectMock.mockImplementation((_ctx: unknown, carrier: Record<string, string>) => {
const src = options.injectCarrier ?? {};
for (const [k, v] of Object.entries(src)) carrier[k] = v;
});
vi.doMock("bullmq", () => ({
QueueEvents: vi.fn(() => ({
close: queueEventClose,
waitUntilReady: vi.fn().mockResolvedValue(undefined),
})),
FlowProducer: vi.fn(() => ({
close: flowProducerClose,
})),
}));
vi.doMock("@opentelemetry/api", () => ({
context: { active: vi.fn(() => ({})) },
propagation: { inject: propagationInjectMock },
}));
vi.doMock("drizzle-orm", () => ({
eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })),
}));
if (options.enterpriseImportThrows) {
vi.doMock("@snapotter/enterprise", () => {
throw new Error("enterprise unavailable");
});
} else {
vi.doMock("@snapotter/enterprise", () => ({
isFeatureEnabled: isFeatureEnabledMock,
}));
}
vi.doMock("../../../../apps/api/src/lib/ai-quota.js", () => ({
assertAiJobQuota: assertAiJobQuotaMock,
}));
vi.doMock("../../../../apps/api/src/config.js", () => ({
env: { SYNC_WAIT_MS: 50, FILE_MAX_AGE_HOURS: 24 },
}));
vi.doMock("../../../../apps/api/src/db/index.js", () => ({
db: {
insert: vi.fn(() => ({
values: insertedValues.mockResolvedValue(undefined),
})),
select: selectMock,
update: updateMock,
},
schema: {
jobs: { id: "jobs.id", deleteAfter: "jobs.deleteAfter" },
users: { id: "users.id", team: "users.team" },
teams: { id: "teams.id", retentionHours: "teams.retentionHours" },
},
}));
vi.doMock("../../../../apps/api/src/jobs/connection.js", () => ({
createBullMQConnection: vi.fn(() => ({ mocked: "connection" })),
}));
vi.doMock("../../../../apps/api/src/jobs/queues.js", () => ({
getQueue: vi.fn(() => ({
add: queueAdd,
getJob,
})),
}));
const mod = await import("../../../../apps/api/src/jobs/enqueue.js");
return { mod, updateWhere };
}
/**
* Yield the event loop so the fire-and-forget computeDeleteAfter chain can
* settle. The chain awaits a dynamic import plus up to three DB calls, so drain
* a batch of microtasks then a macrotask, twice, to cover import scheduling.
*/
async function flushMicrotasks(): Promise<void> {
for (let round = 0; round < 2; round++) {
for (let i = 0; i < 12; i++) await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
describe("job enqueue helpers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("strips NUL bytes recursively before persisting settings but keeps queue data intact", async () => {
const { mod } = await loadEnqueueModule();
const data = {
jobId: "job-1",
userId: null,
toolId: "tool-a",
pool: "image",
kind: "single",
inputRefs: ["uploads/job-1/input.png"],
filename: "input.png",
settings: {
title: "a\0b",
nested: { value: "c\0d" },
list: ["e\0f", 1],
},
} as never;
await mod.enqueueToolJob(data);
expect(insertedValues).toHaveBeenCalledWith(
expect.objectContaining({
id: "job-1",
settings: {
title: "ab",
nested: { value: "cd" },
list: ["ef", 1],
},
}),
);
expect(queueAdd).toHaveBeenCalledWith(
"tool-a",
expect.objectContaining({
settings: {
title: "a\0b",
nested: { value: "c\0d" },
list: ["e\0f", 1],
},
}),
{ jobId: "job-1" },
);
});
it("persists redacted dbSettings while enqueueing real settings", async () => {
const { mod } = await loadEnqueueModule();
await mod.enqueueToolJob({
jobId: "job-2",
userId: null,
toolId: "ftp-upload",
pool: "system",
kind: "single",
inputRefs: [],
filename: "file.txt",
settings: { password: "secret" },
dbSettings: { password: "[redacted]" },
} as never);
expect(insertedValues).toHaveBeenCalledWith(
expect.objectContaining({ settings: { password: "[redacted]" } }),
);
expect(queueAdd).toHaveBeenCalledWith(
"ftp-upload",
expect.objectContaining({ settings: { password: "secret" } }),
{ jobId: "job-2" },
);
});
it("waitForJob returns null when the job is missing or the sync window times out", async () => {
const { mod } = await loadEnqueueModule();
getJob.mockResolvedValueOnce(undefined);
await expect(mod.waitForJob("image", "missing")).resolves.toBeNull();
getJob.mockResolvedValueOnce({
waitUntilFinished: vi.fn().mockRejectedValue(new Error("job timed out before finishing")),
});
await expect(mod.waitForJob("image", "slow", 25)).resolves.toBeNull();
});
it("waitForJob rethrows real job failures", async () => {
const { mod } = await loadEnqueueModule();
getJob.mockResolvedValueOnce({
waitUntilFinished: vi.fn().mockRejectedValue(new Error("processor failed")),
});
await expect(mod.waitForJob("image", "failed")).rejects.toThrow("processor failed");
});
it("waitForJob returns the job result on the success path", async () => {
const { mod } = await loadEnqueueModule();
const result = {
outputRefs: ["outputs/ok/out.png"],
filename: "out.png",
contentType: "image/png",
originalSize: 10,
processedSize: 8,
};
const waitUntilFinished = vi.fn().mockResolvedValue(result);
getJob.mockResolvedValueOnce({ waitUntilFinished });
await expect(mod.waitForJob("image", "ok")).resolves.toEqual(result);
// Uses the pool's queueEvents consumer and the default SYNC_WAIT_MS window.
expect(waitUntilFinished).toHaveBeenCalledWith(expect.anything(), 50);
});
it("waitForJob rethrows non-Error rejections stringified and non-timeout", async () => {
const { mod } = await loadEnqueueModule();
getJob.mockResolvedValueOnce({
waitUntilFinished: vi.fn().mockRejectedValue("kaboom"),
});
await expect(mod.waitForJob("image", "weird")).rejects.toBe("kaboom");
});
it("closes lazy QueueEvents and FlowProducer singletons", async () => {
const { mod } = await loadEnqueueModule();
await mod.warmQueueEvents();
mod.getFlowProducer();
getJob.mockResolvedValueOnce(undefined);
await mod.waitForJob("image", "job-1");
await mod.closeQueueEvents();
await mod.closeFlowProducer();
expect(queueEventClose).toHaveBeenCalled();
expect(flowProducerClose).toHaveBeenCalledTimes(1);
});
it("getFlowProducer returns the same cached instance on repeated calls", async () => {
const { mod } = await loadEnqueueModule();
const a = mod.getFlowProducer();
const b = mod.getFlowProducer();
expect(a).toBe(b);
});
it("closeFlowProducer is a no-op when no producer was created", async () => {
const { mod } = await loadEnqueueModule();
await expect(mod.closeFlowProducer()).resolves.toBeUndefined();
expect(flowProducerClose).not.toHaveBeenCalled();
});
});
describe("enqueueToolJob AI quota gate", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("checks the AI quota before inserting for ai-tool jobs", async () => {
const { mod } = await loadEnqueueModule();
await mod.enqueueToolJob({
jobId: "ai-1",
userId: "user-1",
toolId: "colorize",
pool: "ai",
kind: "ai-tool",
inputRefs: ["uploads/ai-1/x.png"],
filename: "x.png",
settings: {},
} as never);
expect(assertAiJobQuotaMock).toHaveBeenCalledWith("user-1");
expect(insertedValues).toHaveBeenCalled();
});
it("does not check the AI quota for non-ai-tool kinds", async () => {
const { mod } = await loadEnqueueModule();
await mod.enqueueToolJob({
jobId: "img-1",
userId: "user-1",
toolId: "resize",
pool: "image",
kind: "tool",
inputRefs: ["uploads/img-1/x.png"],
filename: "x.png",
settings: {},
} as never);
expect(assertAiJobQuotaMock).not.toHaveBeenCalled();
});
it("propagates the quota rejection and never inserts the job row", async () => {
const { mod } = await loadEnqueueModule();
const quotaErr = Object.assign(new Error("Too many concurrent AI jobs."), { statusCode: 429 });
assertAiJobQuotaMock.mockRejectedValueOnce(quotaErr);
await expect(
mod.enqueueToolJob({
jobId: "ai-2",
userId: "user-1",
toolId: "colorize",
pool: "ai",
kind: "ai-tool",
inputRefs: ["uploads/ai-2/x.png"],
filename: "x.png",
settings: {},
} as never),
).rejects.toThrow("Too many concurrent AI jobs.");
expect(insertedValues).not.toHaveBeenCalled();
expect(queueAdd).not.toHaveBeenCalled();
});
});
describe("injectTraceContext", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("sets _otel with traceparent and tracestate when both are present", async () => {
const { mod } = await loadEnqueueModule({
injectCarrier: { traceparent: "00-abc-def-01", tracestate: "vendor=1" },
});
const data = { jobId: "t-1" } as unknown as ToolJobData;
mod.injectTraceContext(data);
expect(data._otel).toEqual({ traceparent: "00-abc-def-01", tracestate: "vendor=1" });
});
it("sets _otel with an undefined tracestate when only traceparent is present", async () => {
const { mod } = await loadEnqueueModule({
injectCarrier: { traceparent: "00-abc-def-01" },
});
const data = { jobId: "t-2" } as unknown as ToolJobData;
mod.injectTraceContext(data);
expect(data._otel).toEqual({ traceparent: "00-abc-def-01", tracestate: undefined });
});
it("leaves _otel unset when no traceparent is produced", async () => {
const { mod } = await loadEnqueueModule({ injectCarrier: {} });
const data = { jobId: "t-3" } as unknown as ToolJobData;
mod.injectTraceContext(data);
expect(data._otel).toBeUndefined();
});
});
describe("computeDeleteAfter (via enqueueToolJob fire-and-forget)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
type EnqueueModule = Awaited<ReturnType<typeof loadEnqueueModule>>["mod"];
async function enqueueWithUser(mod: EnqueueModule): Promise<void> {
await mod.enqueueToolJob({
jobId: "ret-1",
userId: "user-1",
toolId: "resize",
pool: "image",
kind: "tool",
inputRefs: ["uploads/ret-1/x.png"],
filename: "x.png",
settings: {},
} as never);
await flushMicrotasks();
}
it("does nothing when team_retention_overrides is disabled", async () => {
const { mod, updateWhere } = await loadEnqueueModule({ teamRetentionEnabled: false });
await enqueueWithUser(mod);
expect(selectMock).not.toHaveBeenCalled();
expect(updateWhere).not.toHaveBeenCalled();
});
it("does nothing when the enterprise import throws (feature stays disabled)", async () => {
const { mod, updateWhere } = await loadEnqueueModule({ enterpriseImportThrows: true });
await enqueueWithUser(mod);
expect(selectMock).not.toHaveBeenCalled();
expect(updateWhere).not.toHaveBeenCalled();
});
it("returns early when the user has no team row", async () => {
const { mod, updateWhere } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [],
});
await enqueueWithUser(mod);
expect(selectMock).toHaveBeenCalledTimes(1); // only the users lookup ran
expect(updateWhere).not.toHaveBeenCalled();
});
it("returns early when the user's team is null", async () => {
const { mod, updateWhere } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: null }],
});
await enqueueWithUser(mod);
expect(selectMock).toHaveBeenCalledTimes(1);
expect(updateWhere).not.toHaveBeenCalled();
});
it("uses the team retentionHours when set", async () => {
const start = Date.now();
const { mod, updateWhere } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [{ retentionHours: 2 }],
});
await enqueueWithUser(mod);
expect(selectMock).toHaveBeenCalledTimes(2);
expect(updateSetMock).toHaveBeenCalledTimes(1);
const arg = updateSetMock.mock.calls[0][0] as { deleteAfter: Date };
expect(arg.deleteAfter).toBeInstanceOf(Date);
// 2 hours out, within a generous tolerance of the enqueue instant.
const expected = start + 2 * 60 * 60 * 1000;
expect(Math.abs(arg.deleteAfter.getTime() - expected)).toBeLessThan(5000);
expect(updateWhere).toHaveBeenCalledTimes(1);
});
it("falls back to FILE_MAX_AGE_HOURS when the team retentionHours is null", async () => {
const start = Date.now();
const { mod } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [{ retentionHours: null }],
});
await enqueueWithUser(mod);
const arg = updateSetMock.mock.calls[0][0] as { deleteAfter: Date };
const expected = start + 24 * 60 * 60 * 1000; // FILE_MAX_AGE_HOURS mocked to 24
expect(Math.abs(arg.deleteAfter.getTime() - expected)).toBeLessThan(5000);
});
it("falls back to FILE_MAX_AGE_HOURS when the teams lookup returns no row", async () => {
const start = Date.now();
const { mod } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [],
});
await enqueueWithUser(mod);
const arg = updateSetMock.mock.calls[0][0] as { deleteAfter: Date };
const expected = start + 24 * 60 * 60 * 1000;
expect(Math.abs(arg.deleteAfter.getTime() - expected)).toBeLessThan(5000);
});
it("swallows a computeDeleteAfter failure without rejecting enqueueToolJob", async () => {
const { mod } = await loadEnqueueModule({
teamRetentionEnabled: true,
userRow: [{ team: "team-1" }],
teamRow: [{ retentionHours: 5 }],
});
// Make the update leg throw; the .catch(() => {}) must absorb it.
updateSetMock.mockReturnValueOnce({
where: vi.fn(() => Promise.reject(new Error("db down"))),
});
await expect(
mod.enqueueToolJob({
jobId: "ret-err",
userId: "user-1",
toolId: "resize",
pool: "image",
kind: "tool",
inputRefs: ["uploads/ret-err/x.png"],
filename: "x.png",
settings: {},
} as never),
).resolves.toBeDefined();
await flushMicrotasks();
});
});