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
@@ -2,6 +2,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { redirect } from "next/navigation";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||
import { requirePermission, requireStaff } from "@/lib/admin/guard";
|
||||
import { createAd } from "./admin-ads";
|
||||
import { createArticle } from "./admin-articles";
|
||||
@@ -9,12 +10,17 @@ import { uploadMedia } from "./admin-media";
|
||||
import { deleteFavicon, saveFavicon } from "./save-favicon";
|
||||
import { saveLogo } from "./save-logo";
|
||||
|
||||
const { execute } = vi.hoisted(() => ({
|
||||
const { execute, auditedBrandExecute } = vi.hoisted(() => ({
|
||||
execute: vi.fn(async () => ({
|
||||
ok: true,
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
correlationId: "legacy",
|
||||
})),
|
||||
auditedBrandExecute: vi.fn(async () => ({
|
||||
before: null,
|
||||
after: { value: "/api/media/x" },
|
||||
output: { url: "/api/media/x" },
|
||||
})),
|
||||
}));
|
||||
const { executeLegacyBrandAssetMutation } = vi.hoisted(() => ({
|
||||
executeLegacyBrandAssetMutation: vi.fn(async () => ({
|
||||
@@ -32,6 +38,21 @@ vi.mock("@/features/housekeeping/domains/content/services/mutations", () => ({
|
||||
legacy: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/features/housekeeping/domains/content/services/mutations-production",
|
||||
() => ({
|
||||
contentProductionMutationAdapter: { execute: auditedBrandExecute },
|
||||
}),
|
||||
);
|
||||
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: vi.fn(async () => ({
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
})),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/features/housekeeping/domains/content/services/mutation-runtime-external",
|
||||
() => ({
|
||||
@@ -108,6 +129,11 @@ beforeEach(() => {
|
||||
data: { before: null, after: { id: "1" }, output: { url: "/api/media/x" } },
|
||||
correlationId: "legacy",
|
||||
});
|
||||
auditedBrandExecute.mockResolvedValue({
|
||||
before: null,
|
||||
after: { value: "/api/media/x" },
|
||||
output: { url: "/api/media/x" },
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content legacy wrappers", () => {
|
||||
@@ -153,10 +179,17 @@ describe("Content legacy wrappers", () => {
|
||||
"media.upload",
|
||||
expect.objectContaining({ file }),
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).toHaveBeenCalledWith(
|
||||
expect(auditedBrandExecute).toHaveBeenCalledWith(
|
||||
"favicon.save",
|
||||
{ file },
|
||||
expect.objectContaining({
|
||||
capability: expect.objectContaining({
|
||||
actor: expect.objectContaining({ id: 42 }),
|
||||
}),
|
||||
legacy: true,
|
||||
}),
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the legacy favicon page gate and establishes a staff logo floor", async () => {
|
||||
@@ -170,10 +203,9 @@ describe("Content legacy wrappers", () => {
|
||||
expect(requirePermission).not.toHaveBeenCalledWith("settings.edit");
|
||||
expect(requireStaff).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
executeLegacyBrandAssetMutation.mock.calls.map(
|
||||
([operation]) => operation,
|
||||
),
|
||||
auditedBrandExecute.mock.calls.map(([operation]) => operation),
|
||||
).toEqual(["favicon.save", "favicon.delete", "logo.save"]);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mutate brand assets when either legacy guard denies access", async () => {
|
||||
@@ -185,12 +217,52 @@ describe("Content legacy wrappers", () => {
|
||||
"favicon denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(requireStaff).mockRejectedValueOnce(new Error("logo denied"));
|
||||
await expect(saveLogo(form({ file }) as FormData)).rejects.toThrow(
|
||||
"logo denied",
|
||||
);
|
||||
expect(executeLegacyBrandAssetMutation).not.toHaveBeenCalled();
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a requireStaff-approved actor without an additional ACL use the audited logo boundary", async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireStaff).mockResolvedValue(staff as never);
|
||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
||||
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
||||
success: true,
|
||||
url: "/api/media/x",
|
||||
});
|
||||
expect(requirePermission).not.toHaveBeenCalled();
|
||||
expect(requireStaff).toHaveBeenCalledOnce();
|
||||
expect(auditedBrandExecute).toHaveBeenCalledWith(
|
||||
"logo.save",
|
||||
{ file },
|
||||
expect.objectContaining({
|
||||
capability: expect.objectContaining({
|
||||
actor: expect.objectContaining({ id: 42 }),
|
||||
}),
|
||||
legacy: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a brand mutation when the rehydrated actor changes after the legacy guard", async () => {
|
||||
vi.mocked(getHousekeepingCapabilityContext).mockResolvedValueOnce({
|
||||
actor: { id: 99, username: "other", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: () => false,
|
||||
hasAny: () => false,
|
||||
hasAll: () => false,
|
||||
} as never);
|
||||
const file = new File(["bytes"], "logo.png", { type: "image/png" });
|
||||
await expect(saveLogo(form({ file }) as FormData)).resolves.toEqual({
|
||||
success: false,
|
||||
error: "Authenticated staff changed during logo mutation",
|
||||
});
|
||||
expect(auditedBrandExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps every listed legacy action as a thin shared-service wrapper", () => {
|
||||
@@ -218,7 +290,7 @@ describe("Content legacy wrappers", () => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(
|
||||
source.includes("contentMutationService") ||
|
||||
source.includes("executeLegacyBrandAssetMutation") ||
|
||||
source.includes("contentProductionMutationAdapter") ||
|
||||
source.includes('from "./banners"'),
|
||||
path,
|
||||
).toBe(true);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import type { ContentMutationSnapshot } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requirePermission, type StaffUser } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024;
|
||||
@@ -15,10 +16,34 @@ const ALLOWED = [
|
||||
"image/svg+xml",
|
||||
];
|
||||
|
||||
async function executeAuditedFaviconMutation(
|
||||
staff: StaffUser,
|
||||
operation: "favicon.save" | "favicon.delete",
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const [
|
||||
{ contentProductionMutationAdapter },
|
||||
{ getHousekeepingCapabilityContext },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
"@/features/housekeeping/domains/content/services/mutations-production"
|
||||
),
|
||||
import("@/features/housekeeping/foundation/server-capability-context"),
|
||||
]);
|
||||
const capability = await getHousekeepingCapabilityContext();
|
||||
if (capability.actor.id !== staff.id)
|
||||
throw new Error("Authenticated staff changed during favicon mutation");
|
||||
return contentProductionMutationAdapter.execute(operation, input, {
|
||||
capability,
|
||||
correlationId: createCorrelationId(),
|
||||
legacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveFavicon(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file || file.size === 0)
|
||||
@@ -30,7 +55,7 @@ export async function saveFavicon(
|
||||
success: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP, ICO, SVG",
|
||||
};
|
||||
const result = await executeLegacyBrandAssetMutation("favicon.save", {
|
||||
const result = await executeAuditedFaviconMutation(staff, "favicon.save", {
|
||||
file,
|
||||
});
|
||||
siteRevalidate();
|
||||
@@ -52,9 +77,9 @@ export async function deleteFavicon(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
const staff = await requirePermission(PERMS.SETTINGS_VIEW);
|
||||
try {
|
||||
await executeLegacyBrandAssetMutation("favicon.delete", {});
|
||||
await executeAuditedFaviconMutation(staff, "favicon.delete", {});
|
||||
siteRevalidate();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { executeLegacyBrandAssetMutation } from "@/features/housekeeping/domains/content/services/mutation-runtime-external";
|
||||
import { requireStaff } from "@/lib/admin/guard";
|
||||
import type { ContentMutationSnapshot } from "@/features/housekeeping/domains/content/services/mutations";
|
||||
import { createCorrelationId } from "@/features/housekeeping/foundation/contracts";
|
||||
import { requireStaff, type StaffUser } from "@/lib/admin/guard";
|
||||
|
||||
async function executeAuditedLogoMutation(
|
||||
staff: StaffUser,
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const [
|
||||
{ contentProductionMutationAdapter },
|
||||
{ getHousekeepingCapabilityContext },
|
||||
] = await Promise.all([
|
||||
import(
|
||||
"@/features/housekeeping/domains/content/services/mutations-production"
|
||||
),
|
||||
import("@/features/housekeeping/foundation/server-capability-context"),
|
||||
]);
|
||||
const capability = await getHousekeepingCapabilityContext();
|
||||
if (capability.actor.id !== staff.id)
|
||||
throw new Error("Authenticated staff changed during logo mutation");
|
||||
return contentProductionMutationAdapter.execute("logo.save", input, {
|
||||
capability,
|
||||
correlationId: createCorrelationId(),
|
||||
legacy: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveLogo(
|
||||
formData: FormData,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
await requireStaff();
|
||||
const staff = await requireStaff();
|
||||
try {
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return { success: false, error: "No file provided" };
|
||||
const result = await executeLegacyBrandAssetMutation("logo.save", { file });
|
||||
const result = await executeAuditedLogoMutation(staff, { file });
|
||||
revalidatePath("/", "layout");
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
|
||||
vi.mock("node:fs/promises", () => ({ readFile: vi.fn() }));
|
||||
vi.mock("@/lib/media-storage", () => ({
|
||||
MEDIA_ROOT: "C:\\media",
|
||||
resolveMediaPath: vi.fn(
|
||||
(name: string) => `C:\\media\\${name.replaceAll("/", "\\")}`,
|
||||
),
|
||||
}));
|
||||
|
||||
describe("GET /api/media/[...path]", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readFile).mockResolvedValue(Buffer.from([0, 0, 1, 0]));
|
||||
});
|
||||
|
||||
it("serves a stored genuine ICO with the canonical MIME and nosniff", async () => {
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/media/favicon/site.ico"),
|
||||
{
|
||||
params: Promise.resolve({ path: ["favicon", "site.ico"] }),
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("image/x-icon");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(readFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,16 @@ import path from "node:path";
|
||||
import { NextResponse } from "next/server";
|
||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
|
||||
const ALLOWED_EXT = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"];
|
||||
const ALLOWED_EXT = [
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
".svg",
|
||||
".bmp",
|
||||
".ico",
|
||||
];
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
@@ -41,6 +50,7 @@ export async function GET(
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
|
||||
@@ -142,6 +142,21 @@ describe("Content production providers", () => {
|
||||
).rejects.toThrow("Content widget query unavailable");
|
||||
});
|
||||
|
||||
it("marks an inbox dependency unavailable instead of returning an available empty list", async () => {
|
||||
run.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE", messageKey: "dependency" },
|
||||
correlationId: "unavailable",
|
||||
});
|
||||
await expect(
|
||||
loadContentInboxItems(
|
||||
"publication",
|
||||
context,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow("Content inbox query unavailable");
|
||||
});
|
||||
|
||||
it("emits only actionable publication statuses", async () => {
|
||||
run.mockResolvedValueOnce(
|
||||
result("content.editorial.articles", 2, [
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function loadContentInboxItems(
|
||||
routeId,
|
||||
list: { pageSize: 25, offset: 0 },
|
||||
});
|
||||
if (!result.ok) continue;
|
||||
if (!result.ok) throw new Error("Content inbox query unavailable");
|
||||
for (const item of result.data.items) {
|
||||
const status = item.status?.toLocaleLowerCase() ?? "";
|
||||
if (!ACTIONABLE_STATUS[kind].has(status)) continue;
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ContentCommandField {
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
readonly maxLength?: number;
|
||||
readonly defaultValue?: string | number;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
readonly options?: readonly Readonly<{
|
||||
value: string | number;
|
||||
label: string;
|
||||
@@ -41,8 +41,8 @@ const OMIT_FIELD = Symbol("omit optional Content command field");
|
||||
|
||||
function parseField(field: ContentCommandField, formData: FormData): unknown {
|
||||
const rawValue = formData.get(field.name);
|
||||
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "checkbox") return rawValue === "on";
|
||||
if (rawValue === null && !field.required) return OMIT_FIELD;
|
||||
if (field.type === "file") return rawValue instanceof File ? rawValue : null;
|
||||
const raw = String(rawValue ?? "")
|
||||
.normalize("NFC")
|
||||
@@ -136,6 +136,7 @@ export function ContentCommandForm({
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
type="checkbox"
|
||||
defaultChecked={field.defaultValue === true}
|
||||
/>{" "}
|
||||
{field.label}
|
||||
</>
|
||||
@@ -145,7 +146,11 @@ export function ContentCommandForm({
|
||||
<select
|
||||
id={`${commandId}-${field.name}`}
|
||||
name={field.name}
|
||||
defaultValue={field.defaultValue}
|
||||
defaultValue={
|
||||
typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
className="mt-1 block w-full"
|
||||
>
|
||||
@@ -182,7 +187,10 @@ export function ContentCommandForm({
|
||||
: "text"
|
||||
}
|
||||
defaultValue={
|
||||
field.type === "file" ? undefined : field.defaultValue
|
||||
field.type === "file" ||
|
||||
typeof field.defaultValue === "boolean"
|
||||
? undefined
|
||||
: field.defaultValue
|
||||
}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("Content actionable form wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("omits untouched optional fields from partial-update submissions", async () => {
|
||||
it("omits untouched optional text but submits a declared unchecked checkbox as false", async () => {
|
||||
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
|
||||
ok({ before: null, after: { id: "7" } }, "partial-form"),
|
||||
);
|
||||
@@ -177,10 +177,29 @@ describe("Content actionable form wiring", () => {
|
||||
|
||||
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
|
||||
commandId: "content.media.banner.change",
|
||||
input: { action: "update", id: "7", title: "Renamed" },
|
||||
input: {
|
||||
action: "update",
|
||||
id: "7",
|
||||
isActive: false,
|
||||
title: "Renamed",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("renders poll creation with show-results checked by default", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ContentEngagementPage
|
||||
context={context([PERMS.POLLS_EDIT])}
|
||||
result={ok(
|
||||
{ kind: "engagement", items: [], total: 0, partialDependencies: [] },
|
||||
"poll-create-checkbox",
|
||||
)}
|
||||
routeId="content.engagement.poll-create"
|
||||
/>,
|
||||
);
|
||||
expect(html).toMatch(/name="showResults"[^>]*checked=""/u);
|
||||
});
|
||||
|
||||
it("renders mutation forms only with the exact capability", () => {
|
||||
const result = ok(
|
||||
{
|
||||
|
||||
@@ -108,7 +108,12 @@ export function ContentEngagementPage({
|
||||
{ value: "closed", label: "Closed" },
|
||||
],
|
||||
},
|
||||
{ name: "showResults", label: "Show results", type: "checkbox" },
|
||||
{
|
||||
name: "showResults",
|
||||
label: "Show results",
|
||||
type: "checkbox",
|
||||
defaultValue: creatingPoll,
|
||||
},
|
||||
{
|
||||
name: "multipleChoice",
|
||||
label: "Allow multiple choices",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type ContentRouteId, contentRouteGroup } from "../routes";
|
||||
import type {
|
||||
ContentQueryData,
|
||||
@@ -18,102 +19,102 @@ interface QueryDefinition {
|
||||
export const CONTENT_QUERY_DEFINITIONS = {
|
||||
"content.editorial.articles": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase/content/editorial/articles/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.editorial.article-detail": {
|
||||
statement:
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, slug AS description, 'published' AS status, updated_at FROM website_articles ORDER BY created_at DESC",
|
||||
href: "/ase/content/editorial/articles/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.editorial.tags": {
|
||||
statement:
|
||||
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name LIMIT 500",
|
||||
"SELECT id, name AS title, background_color AS status, updated_at FROM tags ORDER BY name",
|
||||
href: "/ase/content/editorial/tags",
|
||||
},
|
||||
"content.editorial.writeable-boxes": {
|
||||
statement:
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id LIMIT 500",
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, updated_at FROM website_writeable_boxes ORDER BY position, id",
|
||||
href: "/ase/content/editorial/writeable-boxes",
|
||||
},
|
||||
"content.media.photos": {
|
||||
statement:
|
||||
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC LIMIT 500",
|
||||
"SELECT id, url AS title, 'published' AS status, FROM_UNIXTIME(timestamp) AS updated_at FROM camera_web ORDER BY id DESC",
|
||||
href: "/ase/content/media/photos",
|
||||
},
|
||||
"content.media.banners": {
|
||||
statement:
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id LIMIT 500",
|
||||
"SELECT id, title, CASE WHEN is_active = 1 THEN 'active' ELSE 'hidden' END AS status, NULL AS updated_at FROM website_banners ORDER BY sort_order, id",
|
||||
href: "/ase/content/media/banners",
|
||||
},
|
||||
"content.media.ads": {
|
||||
statement:
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
|
||||
href: "/ase/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.media.ad-detail": {
|
||||
statement:
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, image AS title, 'active' AS status, updated_at FROM website_ads ORDER BY created_at DESC",
|
||||
href: "/ase/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.events": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-types": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name LIMIT 500",
|
||||
"SELECT id, name AS title, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM website_event_types ORDER BY name",
|
||||
href: "/ase/content/engagement/events/types",
|
||||
},
|
||||
"content.engagement.polls": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.poll-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC LIMIT 500",
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.prefixes": {
|
||||
statement:
|
||||
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC LIMIT 500",
|
||||
"SELECT id, text AS title, color AS description, CASE WHEN active = 1 THEN 'active' ELSE 'inactive' END AS status, NULL AS updated_at FROM custom_prefixes ORDER BY id DESC",
|
||||
href: "/ase/content/engagement/prefixes",
|
||||
},
|
||||
"content.help.questions": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
|
||||
href: "/ase/content/help/questions/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.help.question-detail": {
|
||||
statement:
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id LIMIT 500",
|
||||
"SELECT id, name AS title, CONCAT('position-', position) AS status, NULL AS updated_at FROM website_help_center_categories ORDER BY position, id",
|
||||
href: "/ase/content/help/questions/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.help.email-templates": {
|
||||
statement:
|
||||
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name LIMIT 500",
|
||||
"SELECT id, name AS title, subject AS description, CASE WHEN is_active = 1 THEN 'active' ELSE 'inactive' END AS status, updated_at FROM email_templates ORDER BY name",
|
||||
href: "/ase/content/help/email-templates",
|
||||
},
|
||||
"content.localization.emulator": {
|
||||
statement:
|
||||
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key LIMIT 500",
|
||||
"SELECT emulator_settings.key AS id, emulator_settings.key AS title, LEFT(emulator_settings.value, 120) AS description, 'configured' AS status, NULL AS updated_at FROM emulator_settings ORDER BY emulator_settings.key",
|
||||
href: "/ase/content/localization/emulator",
|
||||
},
|
||||
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
|
||||
@@ -161,7 +162,7 @@ function response(
|
||||
};
|
||||
}
|
||||
|
||||
function matches(
|
||||
function matchesListSearch(
|
||||
input: NormalizedContentQueryInput,
|
||||
item: ContentQueryItem,
|
||||
): boolean {
|
||||
@@ -174,28 +175,38 @@ function matches(
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
input: NormalizedContentQueryInput,
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly ContentQueryItem[] {
|
||||
let items = rawRows
|
||||
.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
})
|
||||
.filter((item) => matches(input, item));
|
||||
if (input.params.id) {
|
||||
items = items.filter((item) => item.id === input.params.id);
|
||||
}
|
||||
return items;
|
||||
return rawRows.map((row) => {
|
||||
const id = value(row.id);
|
||||
if (!id) throw new Error("invalid Content identifier");
|
||||
return {
|
||||
id,
|
||||
title: value(row.title, "Untitled content"),
|
||||
description: value(row.description) || undefined,
|
||||
status: value(row.status) || undefined,
|
||||
updatedAt: updatedAt(row.updated_at),
|
||||
href: definition.appendId ? definition.href + id : definition.href,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function statementParts(statement: string): {
|
||||
readonly selection: string;
|
||||
readonly ordering: string;
|
||||
} {
|
||||
const match = /^(.*?)(\s+ORDER BY\s+.+)$/iu.exec(statement);
|
||||
return match
|
||||
? { selection: match[1], ordering: match[2] }
|
||||
: { selection: statement, ordering: "" };
|
||||
}
|
||||
|
||||
function totalFromRows(rawRows: readonly RawRow[]): number {
|
||||
const total = Number(rawRows[0]?.total ?? 0);
|
||||
if (!Number.isSafeInteger(total) || total < 0)
|
||||
throw new Error("invalid Content query total");
|
||||
return total;
|
||||
}
|
||||
|
||||
async function databaseRoute(
|
||||
@@ -206,16 +217,41 @@ async function databaseRoute(
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const items = mapRows(
|
||||
input,
|
||||
definition,
|
||||
const { selection, ordering } = statementParts(definition.statement);
|
||||
const filters: SQL[] = [];
|
||||
if (input.list.search) {
|
||||
const pattern = `%${input.list.search.toLocaleLowerCase()}%`;
|
||||
filters.push(
|
||||
/\bAS\s+description\b/iu.test(selection)
|
||||
? sql`(LOWER(COALESCE(title, '')) LIKE ${pattern} OR LOWER(COALESCE(description, '')) LIKE ${pattern})`
|
||||
: sql`LOWER(COALESCE(title, '')) LIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
if (input.params.id) filters.push(sql`CAST(id AS CHAR) = ${input.params.id}`);
|
||||
const filtered = filters.length
|
||||
? sql`${sql.raw(selection)} HAVING ${sql.join(filters, sql` AND `)}`
|
||||
: sql.raw(selection);
|
||||
const total = totalFromRows(
|
||||
rows(
|
||||
await db.execute(
|
||||
sql.raw(definition.statement.replace(/\s+LIMIT 500$/u, "")),
|
||||
sql`SELECT COUNT(*) AS total FROM (${filtered}) AS content_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return response(input, items, items.length);
|
||||
const items = mapRows(
|
||||
definition,
|
||||
rows(
|
||||
await db.execute(
|
||||
sql`${filtered} ${sql.raw(ordering)} LIMIT ${input.list.pageSize} OFFSET ${input.list.offset}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
kind: contentRouteGroup(input.routeId),
|
||||
items,
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function mediaLibrary(
|
||||
@@ -252,7 +288,7 @@ async function mediaLibrary(
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase/content/media/library",
|
||||
}))
|
||||
.filter((item) => matches(input, item));
|
||||
.filter((item) => matchesListSearch(input, item));
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,23 @@ import {
|
||||
loadProductionContentQuery,
|
||||
} from "./content-queries-production";
|
||||
|
||||
const queryMocks = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
const queryMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(),
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
sql: { raw: (statement: string) => statement },
|
||||
sql: Object.assign(queryMocks.tagged, {
|
||||
join: queryMocks.join,
|
||||
raw: queryMocks.raw,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||
|
||||
@@ -45,15 +59,15 @@ const ready: ContentQueryData = {
|
||||
|
||||
describe("Content query", () => {
|
||||
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||
const allRows = Array.from({ length: 600 }, (_, index) => ({
|
||||
const pageRows = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `Article ${index + 1}`,
|
||||
status: "published",
|
||||
updated_at: null,
|
||||
}));
|
||||
queryMocks.execute.mockImplementationOnce(async (statement: string) => [
|
||||
statement.includes("LIMIT 500") ? allRows.slice(0, 500) : allRows,
|
||||
]);
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 600 }]])
|
||||
.mockResolvedValueOnce([pageRows]);
|
||||
const result = await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
@@ -61,6 +75,41 @@ describe("Content query", () => {
|
||||
});
|
||||
expect(result.total).toBe(600);
|
||||
expect(result.items).toHaveLength(25);
|
||||
expect(queryMocks.execute).toHaveBeenCalledTimes(2);
|
||||
const pageQuery = queryMocks.execute.mock.calls[1]?.[0];
|
||||
expect(JSON.stringify(pageQuery)).toContain("25");
|
||||
expect(JSON.stringify(pageQuery)).toContain("0");
|
||||
});
|
||||
|
||||
it("binds search, limit, and offset into the bounded page query", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 1 }]])
|
||||
.mockResolvedValueOnce([
|
||||
[{ id: 9, title: "Launch", status: "published", updated_at: null }],
|
||||
]);
|
||||
await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "Launch", pageSize: 7, offset: 14 },
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls);
|
||||
expect(serialized).toContain("%launch%");
|
||||
expect(serialized).toContain("7");
|
||||
expect(serialized).toContain("14");
|
||||
});
|
||||
|
||||
it("searches only projected aliases for routes without descriptions", async () => {
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 0 }]])
|
||||
.mockResolvedValueOnce([[]]);
|
||||
await loadProductionContentQuery({
|
||||
routeId: "content.engagement.events",
|
||||
params: {},
|
||||
list: { search: "launch", pageSize: 25, offset: 0 },
|
||||
});
|
||||
const serialized = JSON.stringify(queryMocks.execute.mock.calls.slice(-2));
|
||||
expect(serialized).toContain("COALESCE(title");
|
||||
expect(serialized).not.toContain("COALESCE(description");
|
||||
});
|
||||
|
||||
it("never selects email template bodies into summary query results", () => {
|
||||
|
||||
+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