fix(housekeeping): address task 14 review round 2
This commit is contained in:
1 parent
d09eaa33d6
commit
c524b305d7
17 files changed
+614
-130
No files matched your search
+53
-1
@@ -2,6 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { executeContentDatabaseMutation } from "./mutation-runtime-database";
|
||||
|
||||
const sqlMocks = vi.hoisted(() => ({
|
||||
join: vi.fn((chunks: unknown[], separator: unknown) => ({
|
||||
chunks,
|
||||
separator,
|
||||
})),
|
||||
raw: vi.fn((statement: string) => statement),
|
||||
tagged: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: Array.from(strings),
|
||||
values,
|
||||
})),
|
||||
}));
|
||||
|
||||
const database = vi.hoisted(() => {
|
||||
let selected: Record<string, unknown> = { id: 7, title: "Existing" };
|
||||
const set = vi.fn((values: Record<string, unknown>) => ({
|
||||
@@ -13,7 +25,9 @@ const database = vi.hoisted(() => {
|
||||
const where = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
const execute = vi.fn(async () => undefined);
|
||||
return {
|
||||
execute,
|
||||
set,
|
||||
update,
|
||||
select,
|
||||
@@ -25,12 +39,16 @@ const database = vi.hoisted(() => {
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: vi.fn(),
|
||||
sql: Object.assign(vi.fn(), { raw: vi.fn() }),
|
||||
sql: Object.assign(sqlMocks.tagged, {
|
||||
join: sqlMocks.join,
|
||||
raw: sqlMocks.raw,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const table = new Proxy({}, { get: (_target, key) => String(key) });
|
||||
return {
|
||||
db: {
|
||||
execute: database.execute,
|
||||
select: database.select,
|
||||
update: database.update,
|
||||
},
|
||||
@@ -142,4 +160,38 @@ describe("Content database mutation runtime partial updates", () => {
|
||||
expect(database.set.mock.calls[0]?.[0]).toHaveProperty(expectedKey);
|
||||
},
|
||||
);
|
||||
|
||||
it("updates only submitted prefix columns and preserves omitted icon, effect, and active", async () => {
|
||||
const snapshot = await executeContentDatabaseMutation(
|
||||
"prefix.change",
|
||||
{ action: "update", id: 7, text: "Renamed" },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const assignments = sqlMocks.join.mock.calls.at(-1)?.[0] as
|
||||
| Array<{ strings: string[]; values: unknown[] }>
|
||||
| undefined;
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments?.[0]?.strings.join(" ")).toContain("text =");
|
||||
expect(assignments?.[0]?.strings.join(" ")).not.toMatch(
|
||||
/icon|effect|active/u,
|
||||
);
|
||||
expect(snapshot?.after).toEqual({ id: 7, text: "Renamed" });
|
||||
});
|
||||
|
||||
it("submits an explicit false prefix active patch without touching other columns", async () => {
|
||||
const snapshot = await executeContentDatabaseMutation(
|
||||
"prefix.change",
|
||||
{ action: "update", id: 7, active: false },
|
||||
context,
|
||||
undefined,
|
||||
);
|
||||
const assignments = sqlMocks.join.mock.calls.at(-1)?.[0] as
|
||||
| Array<{ strings: string[]; values: unknown[] }>
|
||||
| undefined;
|
||||
expect(assignments).toHaveLength(1);
|
||||
expect(assignments?.[0]?.strings.join(" ")).toContain("active =");
|
||||
expect(assignments?.[0]?.values).toEqual([0]);
|
||||
expect(snapshot?.after).toEqual({ id: 7, active: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { eq, type SQL, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import {
|
||||
db,
|
||||
@@ -443,13 +443,11 @@ async function eventChange(
|
||||
if (action === "create") {
|
||||
const parsed = createEventSchema.safeParse(data);
|
||||
if (!parsed.success) throw validation();
|
||||
const [result] = await connection
|
||||
.insert(WebsiteEvent)
|
||||
.values({
|
||||
...parsed.data,
|
||||
hostUserId: context.capability.actor.id,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const [result] = await connection.insert(WebsiteEvent).values({
|
||||
...parsed.data,
|
||||
hostUserId: context.capability.actor.id,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const id = insertedId(result);
|
||||
return {
|
||||
before: null,
|
||||
@@ -653,14 +651,12 @@ async function tagChange(
|
||||
const name = text(data.name, 255, true);
|
||||
const backgroundColor = text(data.backgroundColor, 10) || "#888888";
|
||||
const now = new Date();
|
||||
const [result] = (await connection
|
||||
.insert(Tags)
|
||||
.values({
|
||||
name,
|
||||
backgroundColor,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
const [result] = (await connection.insert(Tags).values({
|
||||
name,
|
||||
backgroundColor,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})) as unknown as [ResultSetHeader];
|
||||
const id = insertedId(result);
|
||||
await activity(
|
||||
context,
|
||||
@@ -740,17 +736,35 @@ async function prefixChange(
|
||||
return { before: { id }, after: null };
|
||||
}
|
||||
if (action !== "update") throw validation();
|
||||
const prefixText = text(data.text, 255, true);
|
||||
const color = text(data.color, 32, true);
|
||||
const icon = text(data.icon, 255);
|
||||
const effect = text(data.effect, 255);
|
||||
const active = Math.min(1, nonNegativeInteger(data.active, 1));
|
||||
const values: Record<string, unknown> = {};
|
||||
const assignments: SQL[] = [];
|
||||
if (hasOwn(data, "text")) {
|
||||
values.text = text(data.text, 255, true);
|
||||
assignments.push(sql`text = ${values.text}`);
|
||||
}
|
||||
if (hasOwn(data, "color")) {
|
||||
values.color = text(data.color, 32, true);
|
||||
assignments.push(sql`color = ${values.color}`);
|
||||
}
|
||||
if (hasOwn(data, "icon")) {
|
||||
values.icon = text(data.icon, 255);
|
||||
assignments.push(sql`icon = ${values.icon}`);
|
||||
}
|
||||
if (hasOwn(data, "effect")) {
|
||||
values.effect = text(data.effect, 255);
|
||||
assignments.push(sql`effect = ${values.effect}`);
|
||||
}
|
||||
if (hasOwn(data, "active")) {
|
||||
values.active = booleanValue(data.active) ? 1 : 0;
|
||||
assignments.push(sql`active = ${values.active}`);
|
||||
}
|
||||
requirePatch(values);
|
||||
await connection.execute(
|
||||
sql`UPDATE custom_prefixes SET text = ${prefixText}, color = ${color}, icon = ${icon}, effect = ${effect}, active = ${active} WHERE id = ${id}`,
|
||||
sql`UPDATE custom_prefixes SET ${sql.join(assignments, sql`, `)} WHERE id = ${id}`,
|
||||
);
|
||||
return {
|
||||
before: { id },
|
||||
after: { id, text: prefixText, color, icon, effect, active },
|
||||
after: { id, ...values },
|
||||
output: { id: String(id) },
|
||||
};
|
||||
}
|
||||
|
||||
+92
-5
@@ -12,17 +12,42 @@ const fsMocks = vi.hoisted(() => ({
|
||||
|
||||
const dbMocks = vi.hoisted(() => {
|
||||
const onDuplicateKeyUpdate = vi.fn(async () => undefined);
|
||||
const values = vi.fn(() => ({ onDuplicateKeyUpdate }));
|
||||
const values = vi.fn((_value: Record<string, unknown>) => ({
|
||||
onDuplicateKeyUpdate,
|
||||
}));
|
||||
const insert = vi.fn(() => ({ values }));
|
||||
const where = vi.fn(async () => undefined);
|
||||
const deleteFn = vi.fn(() => ({ where }));
|
||||
return { deleteFn, insert, onDuplicateKeyUpdate, values, where };
|
||||
let selectedPhoto: Record<string, unknown> = {
|
||||
id: 7,
|
||||
url: "/photos/7.png",
|
||||
};
|
||||
const limit = vi.fn(async () => [selectedPhoto]);
|
||||
const selectWhere = vi.fn(() => ({ limit }));
|
||||
const from = vi.fn(() => ({ where: selectWhere }));
|
||||
const select = vi.fn(() => ({ from }));
|
||||
return {
|
||||
deleteFn,
|
||||
insert,
|
||||
limit,
|
||||
onDuplicateKeyUpdate,
|
||||
select,
|
||||
selectedPhoto(value: Record<string, unknown>) {
|
||||
selectedPhoto = value;
|
||||
},
|
||||
values,
|
||||
where,
|
||||
};
|
||||
});
|
||||
const siteMocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
const photoMocks = vi.hoisted(() => ({
|
||||
activity: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => fsMocks);
|
||||
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
|
||||
@@ -30,7 +55,11 @@ vi.mock("@/lib/db", () => ({
|
||||
CameraWeb: {},
|
||||
EmulatorSettings: {},
|
||||
WebsiteSetting: {},
|
||||
db: { delete: dbMocks.deleteFn, insert: dbMocks.insert },
|
||||
db: {
|
||||
delete: dbMocks.deleteFn,
|
||||
insert: dbMocks.insert,
|
||||
select: dbMocks.select,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/services/rcon", () => ({
|
||||
rcon: { updateConfig: vi.fn() },
|
||||
@@ -39,10 +68,10 @@ vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: siteMocks,
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: vi.fn(),
|
||||
logStaffActivity: photoMocks.activity,
|
||||
}));
|
||||
vi.mock("@/lib/admin/photo-files", () => ({
|
||||
tryRemoveLocalPhotoFile: vi.fn(),
|
||||
tryRemoveLocalPhotoFile: photoMocks.remove,
|
||||
}));
|
||||
|
||||
const capability = {
|
||||
@@ -62,6 +91,9 @@ describe("Content external mutation runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
siteMocks.get.mockResolvedValue(undefined);
|
||||
photoMocks.activity.mockResolvedValue(undefined);
|
||||
photoMocks.remove.mockResolvedValue(true);
|
||||
dbMocks.selectedPhoto({ id: 7, url: "/photos/7.png" });
|
||||
});
|
||||
|
||||
it("preserves media upload bytes, type, size, and canonical public URL", async () => {
|
||||
@@ -213,4 +245,59 @@ describe("Content external mutation runtime", () => {
|
||||
).rejects.toThrow("database offline");
|
||||
expect(fsMocks.unlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renames a custom theme without replacing its saved settings with the active site theme", async () => {
|
||||
const storedTheme = {
|
||||
id: "saved-theme",
|
||||
name: "Stored",
|
||||
createdAt: 123,
|
||||
settings: { primary: "#stored", accent: "#saved" },
|
||||
};
|
||||
siteMocks.get.mockImplementation(async (key: string) =>
|
||||
key === "custom_themes" ? JSON.stringify([storedTheme]) : "#active",
|
||||
);
|
||||
|
||||
await executeContentExternalMutation(
|
||||
"theme.custom-change",
|
||||
{ action: "rename", id: storedTheme.id, name: "Renamed" },
|
||||
context,
|
||||
);
|
||||
|
||||
const write = dbMocks.values.mock.calls.find(
|
||||
([value]) => value.key === "custom_themes",
|
||||
)?.[0];
|
||||
const persisted = JSON.parse(String(write?.value));
|
||||
expect(persisted).toEqual([
|
||||
{ ...storedTheme, name: "Renamed", settings: storedTheme.settings },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports typed partial completion when a committed photo delete cannot purge the file", async () => {
|
||||
photoMocks.remove.mockResolvedValueOnce(false);
|
||||
await expect(
|
||||
executeContentExternalMutation("photo.delete", { id: 7 }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { id: 7, url: "/photos/7.png" },
|
||||
after: null,
|
||||
},
|
||||
});
|
||||
expect(dbMocks.where).toHaveBeenCalled();
|
||||
expect(photoMocks.activity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports typed partial completion when photo audit fails after the delete", async () => {
|
||||
photoMocks.activity.mockRejectedValueOnce(new Error("audit offline"));
|
||||
await expect(
|
||||
executeContentExternalMutation("photo.delete", { id: 7 }, context),
|
||||
).rejects.toMatchObject({
|
||||
name: "ContentCommittedExternalFailure",
|
||||
snapshot: {
|
||||
before: { id: 7, url: "/photos/7.png" },
|
||||
after: null,
|
||||
},
|
||||
});
|
||||
expect(photoMocks.remove).toHaveBeenCalledWith("/photos/7.png");
|
||||
});
|
||||
});
|
||||
@@ -281,15 +281,24 @@ async function photoDelete(
|
||||
.limit(1);
|
||||
if (!row) throw notFound();
|
||||
await db.delete(CameraWeb).where(eq(CameraWeb.id, id));
|
||||
await tryRemoveLocalPhotoFile(row.url);
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "photo_delete",
|
||||
description: "Deleted camera photo #" + id,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
return { before: { id, url: row.url }, after: null };
|
||||
const snapshot: ContentMutationSnapshot = {
|
||||
before: { id, url: row.url },
|
||||
after: null,
|
||||
};
|
||||
try {
|
||||
const removed = await tryRemoveLocalPhotoFile(row.url);
|
||||
if (!removed) throw new Error("Photo file purge failed");
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
action: "photo_delete",
|
||||
description: "Deleted camera photo #" + id,
|
||||
targetType: "camera_web",
|
||||
targetId: id,
|
||||
});
|
||||
} catch {
|
||||
throw new ContentCommittedExternalFailure(snapshot);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function navigationUpdate(
|
||||
@@ -475,14 +484,22 @@ async function themeCustomChange(
|
||||
if (action !== "create" && action !== "update" && action !== "rename")
|
||||
throw validation();
|
||||
const name = text(data.name, 100, true);
|
||||
const settings = data.values
|
||||
? Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.values)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: await snapshotCurrentTheme();
|
||||
let settings: Record<string, string>;
|
||||
if (action === "rename") {
|
||||
if (!id) throw validation();
|
||||
const existing = await getCustomTheme(id);
|
||||
if (!existing) throw notFound();
|
||||
settings = existing.settings;
|
||||
} else {
|
||||
settings = data.values
|
||||
? Object.fromEntries(
|
||||
Object.entries(jsonRecord(data.values)).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
: await snapshotCurrentTheme();
|
||||
}
|
||||
const theme = await upsertCustomTheme(name, settings, id || undefined);
|
||||
await logStaffActivity({
|
||||
staffId: context.capability.actor.id,
|
||||
@@ -799,15 +816,6 @@ const EXTERNAL_HANDLERS: Partial<
|
||||
"translation.emulator.save": emulatorTranslationSave,
|
||||
};
|
||||
|
||||
export async function executeLegacyBrandAssetMutation(
|
||||
operation: "favicon.save" | "favicon.delete" | "logo.save",
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
if (operation === "favicon.save") return faviconSave(input);
|
||||
if (operation === "favicon.delete") return faviconDelete();
|
||||
return logoSave(input);
|
||||
}
|
||||
|
||||
export async function executeContentExternalMutation(
|
||||
operation: ContentMutationOperation,
|
||||
input: unknown,
|
||||
|
||||
@@ -122,6 +122,32 @@ describe("Content production mutation adapter", () => {
|
||||
expect(JSON.stringify(deps.writeAudit.mock.calls)).not.toContain("Hello");
|
||||
});
|
||||
|
||||
it("routes a logo write through correlated intent and outcome audit", async () => {
|
||||
const deps = dependencies();
|
||||
await deps.adapter.execute(
|
||||
"logo.save",
|
||||
{ file: { name: "logo.png" } },
|
||||
mutationContext,
|
||||
);
|
||||
expect(deps.executeOperation).toHaveBeenCalledWith(
|
||||
"logo.save",
|
||||
expect.anything(),
|
||||
mutationContext,
|
||||
);
|
||||
expect(deps.writeAudit.mock.calls.map(([entry]) => entry)).toEqual([
|
||||
expect.objectContaining({
|
||||
action: "content.logo.save",
|
||||
outcome: "intent",
|
||||
correlationId: "production-matrix",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
action: "content.logo.save",
|
||||
outcome: "success",
|
||||
correlationId: "production-matrix",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns typed partial when database committed but the external effect failed", async () => {
|
||||
const deps = dependencies();
|
||||
deps.executeOperation.mockRejectedValueOnce(
|
||||
|
||||
Reference in new issue
Block a user