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); expect(fsMocks.unlink).not.toHaveBeenCalled(); }); it("rejects declared MIME, filename extension, and actual bytes that disagree", async () => { const disguisedSvg = new File( [''], "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(); }); });