Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/content/services/mutation-runtime-external.test.ts
T

217 lines
6.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { executeContentExternalMutation } from "./mutation-runtime-external";
import type { ContentMutationFailure } from "./mutations";
const fsMocks = vi.hoisted(() => ({
mkdir: vi.fn(),
readFile: vi.fn(),
unlink: vi.fn(),
writeFile: vi.fn(),
}));
const dbMocks = vi.hoisted(() => {
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
const values = vi.fn(() => ({ onDuplicateKeyUpdate }));
const insert = vi.fn(() => ({ values }));
const where = vi.fn(async () => undefined);
const deleteFn = vi.fn(() => ({ where }));
return { deleteFn, insert, onDuplicateKeyUpdate, values, where };
});
const siteMocks = vi.hoisted(() => ({
get: vi.fn(),
reload: vi.fn(),
update: vi.fn(),
}));
vi.mock("node:fs/promises", () => fsMocks);
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
vi.mock("@/lib/db", () => ({
CameraWeb: {},
EmulatorSettings: {},
WebsiteSetting: {},
db: { delete: dbMocks.deleteFn, insert: dbMocks.insert },
}));
vi.mock("@/lib/services/rcon", () => ({
rcon: { updateConfig: vi.fn() },
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: siteMocks,
}));
vi.mock("@/lib/services/staff-activity", () => ({
logStaffActivity: vi.fn(),
}));
vi.mock("@/lib/admin/photo-files", () => ({
tryRemoveLocalPhotoFile: vi.fn(),
}));
const capability = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: () => true,
hasAny: () => true,
hasAll: () => true,
} satisfies HousekeepingCapabilityContext;
const context = {
capability,
correlationId: "external-runtime",
legacy: false,
};
describe("Content external mutation runtime", () => {
beforeEach(() => {
vi.clearAllMocks();
siteMocks.get.mockResolvedValue(undefined);
});
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"photo.png",
{ type: "image/png" },
);
const snapshot = await executeContentExternalMutation(
"media.upload",
{ file },
context,
);
expect(snapshot).toMatchObject({
before: null,
after: { size: file.size, type: "image/png" },
output: { url: expect.stringMatching(/^\/api\/media\/.+[.]png$/u) },
});
expect(fsMocks.mkdir).toHaveBeenCalledTimes(1);
expect(fsMocks.writeFile).toHaveBeenCalledWith(
expect.stringContaining("storage"),
expect.any(Buffer),
);
});
it("propagates a real storage failure before optimistic success", async () => {
fsMocks.writeFile.mockRejectedValueOnce(new Error("disk offline"));
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"photo.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("media.upload", { file }, context),
).rejects.toThrow("disk offline");
});
it("maps normalized media traversal to validation instead of dependency failure", async () => {
await expect(
executeContentExternalMutation(
"media.delete",
{ filename: "../private.txt" },
context,
),
).rejects.toMatchObject({
code: "VALIDATION",
messageKey: "errors.housekeeping.validation",
} satisfies Partial<ContentMutationFailure>);
expect(fsMocks.unlink).not.toHaveBeenCalled();
});
it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => {
const disguisedSvg = new File(
['<svg xmlns="http://www.w3.org/2000/svg"></svg>'],
"photo.svg",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation(
"media.upload",
{ file: disguisedSvg },
context,
),
).rejects.toMatchObject({ code: "VALIDATION" });
expect(fsMocks.writeFile).not.toHaveBeenCalled();
});
it("rejects an oversized logo before reading its bytes", async () => {
const arrayBuffer = vi.fn();
const file = {
name: "logo.png",
type: "image/png",
size: 5 * 1024 * 1024 + 1,
arrayBuffer,
};
await expect(
executeContentExternalMutation("logo.save", { file }, context),
).rejects.toMatchObject({ code: "VALIDATION" });
expect(arrayBuffer).not.toHaveBeenCalled();
expect(fsMocks.writeFile).not.toHaveBeenCalled();
});
it("removes a newly written favicon when the database write fails", async () => {
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
new Error("database offline"),
);
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"favicon.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("favicon.save", { file }, context),
).rejects.toThrow("database offline");
const writtenPath = fsMocks.writeFile.mock.calls[0]?.[0];
expect(fsMocks.unlink).toHaveBeenCalledWith(writtenPath);
});
it("reports a partial favicon result when database failure compensation also fails", async () => {
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
new Error("database offline"),
);
fsMocks.unlink.mockRejectedValueOnce(new Error("cleanup failed"));
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"favicon.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("favicon.save", { file }, context),
).rejects.toMatchObject({
name: "ContentCommittedExternalFailure",
snapshot: {
before: { value: null },
after: { value: null },
output: { compensation: "failed" },
},
});
});
it("removes a newly written logo when the database write fails", async () => {
dbMocks.onDuplicateKeyUpdate.mockRejectedValueOnce(
new Error("database offline"),
);
const file = new File(
[new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])],
"logo.png",
{ type: "image/png" },
);
await expect(
executeContentExternalMutation("logo.save", { file }, context),
).rejects.toThrow("database offline");
expect(fsMocks.unlink).toHaveBeenCalledWith(
fsMocks.writeFile.mock.calls[0]?.[0],
);
});
it("does not delete the existing favicon before the database delete commits", async () => {
siteMocks.get.mockResolvedValueOnce("/api/media/favicon/old.png");
dbMocks.where.mockRejectedValueOnce(new Error("database offline"));
await expect(
executeContentExternalMutation("favicon.delete", {}, context),
).rejects.toThrow("database offline");
expect(fsMocks.unlink).not.toHaveBeenCalled();
});
});