feat(housekeeping): unify complete rebuild in gated preview
This commit is contained in:
1 parent
4ad007d241
commit
43470ebf82
402 files changed
+78313
-1663
No files matched your search
@@ -0,0 +1,418 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { type ContentRouteId, contentRouteGroup } from "../routes";
|
||||
import type {
|
||||
ContentQueryData,
|
||||
ContentQueryItem,
|
||||
NormalizedContentQueryInput,
|
||||
} from "./content-queries";
|
||||
|
||||
type RawRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
interface QueryDefinition {
|
||||
readonly statement: string;
|
||||
readonly href: string;
|
||||
readonly appendId?: boolean;
|
||||
}
|
||||
|
||||
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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/content/media/ads/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.events": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase-next/content/engagement/events/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.event-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_events ORDER BY starts_at DESC",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/content/engagement/events/types",
|
||||
},
|
||||
"content.engagement.polls": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase-next/content/engagement/polls/",
|
||||
appendId: true,
|
||||
},
|
||||
"content.engagement.poll-detail": {
|
||||
statement:
|
||||
"SELECT id, title, status, updated_at FROM website_polls ORDER BY created_at DESC",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/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",
|
||||
href: "/ase-next/content/localization/emulator",
|
||||
},
|
||||
} as const satisfies Partial<Record<ContentRouteId, QueryDefinition>>;
|
||||
|
||||
const EMPTY_ROUTES = new Set<ContentRouteId>([
|
||||
"content.editorial.article-create",
|
||||
"content.media.ad-create",
|
||||
"content.engagement.event-create",
|
||||
"content.engagement.poll-create",
|
||||
"content.help.question-create",
|
||||
]);
|
||||
|
||||
function rows(result: unknown): readonly RawRow[] {
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid Content query result");
|
||||
}
|
||||
return result[0] as readonly RawRow[];
|
||||
}
|
||||
|
||||
function value(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string"
|
||||
? value
|
||||
: value == null
|
||||
? fallback
|
||||
: String(value);
|
||||
}
|
||||
|
||||
function updatedAt(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const parsed = value instanceof Date ? value : new Date(String(value));
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
}
|
||||
|
||||
function response(
|
||||
input: NormalizedContentQueryInput,
|
||||
items: readonly ContentQueryItem[],
|
||||
total = items.length,
|
||||
): ContentQueryData {
|
||||
const start = input.list.offset;
|
||||
return {
|
||||
kind: contentRouteGroup(input.routeId),
|
||||
items: items.slice(start, start + input.list.pageSize),
|
||||
total,
|
||||
partialDependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
function matchesListSearch(
|
||||
input: NormalizedContentQueryInput,
|
||||
item: ContentQueryItem,
|
||||
): boolean {
|
||||
const needle = input.list.search.toLocaleLowerCase();
|
||||
return (
|
||||
needle.length === 0 ||
|
||||
item.title.toLocaleLowerCase().includes(needle) ||
|
||||
item.description?.toLocaleLowerCase().includes(needle) === true
|
||||
);
|
||||
}
|
||||
|
||||
function mapRows(
|
||||
definition: QueryDefinition,
|
||||
rawRows: readonly RawRow[],
|
||||
): readonly ContentQueryItem[] {
|
||||
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(
|
||||
input: NormalizedContentQueryInput,
|
||||
definition: QueryDefinition,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
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`SELECT COUNT(*) AS total FROM (${filtered}) AS content_rows`,
|
||||
),
|
||||
),
|
||||
);
|
||||
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(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ readdir, stat }, { resolve }, { MEDIA_ROOT }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
import("@/lib/media-storage"),
|
||||
]);
|
||||
let names: string[];
|
||||
try {
|
||||
names = (await readdir(MEDIA_ROOT)).filter((name) =>
|
||||
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
||||
return response(input, []);
|
||||
throw error;
|
||||
}
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => ({
|
||||
name,
|
||||
metadata: await stat(resolve(MEDIA_ROOT, name)),
|
||||
})),
|
||||
);
|
||||
entries.sort((left, right) => right.metadata.mtimeMs - left.metadata.mtimeMs);
|
||||
const items = entries
|
||||
.map(({ name, metadata }) => ({
|
||||
id: name,
|
||||
title: name,
|
||||
description: `${String(metadata.size)} bytes`,
|
||||
status: "stored",
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase-next/content/media/library",
|
||||
}))
|
||||
.filter((item) => matchesListSearch(input, item));
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
|
||||
async function brand(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
const [{ siteSettings }, { listCustomThemes }] = await Promise.all([
|
||||
import("@/lib/services/site-settings"),
|
||||
import("@/lib/theme-custom-store"),
|
||||
]);
|
||||
if (input.routeId === "content.brand.favicon") {
|
||||
const favicon = await siteSettings.get("cms_favicon", null);
|
||||
return response(input, [
|
||||
{
|
||||
id: "favicon",
|
||||
title: favicon || "Default favicon",
|
||||
status: favicon ? "custom" : "default",
|
||||
href: "/ase-next/content/brand/favicon",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const [preset, customThemes] = await Promise.all([
|
||||
siteSettings.get("theme_preset", "Atom (golden)"),
|
||||
listCustomThemes(),
|
||||
]);
|
||||
return response(input, [
|
||||
{
|
||||
id: "active-theme",
|
||||
title: preset || "Atom (golden)",
|
||||
status: "active",
|
||||
href: "/ase-next/content/brand/theme",
|
||||
},
|
||||
...customThemes.map((theme) => ({
|
||||
id: theme.id,
|
||||
title: theme.name,
|
||||
status: "saved",
|
||||
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||
href: "/ase-next/content/brand/theme",
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
async function localizationFiles(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
if (input.routeId === "content.localization.client") {
|
||||
const { CLIENT_TRANSLATION_FILES } = await import(
|
||||
"@/lib/client-translation-files"
|
||||
);
|
||||
return response(
|
||||
input,
|
||||
CLIENT_TRANSLATION_FILES.map((file) => ({
|
||||
id: file.id,
|
||||
title: file.id,
|
||||
description: file.relPath,
|
||||
status: file.readOnly ? "read-only" : "editable",
|
||||
href: "/ase-next/content/localization/client",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const [{ readdir }, { join }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
import("node:path"),
|
||||
]);
|
||||
let locales: string[] = [];
|
||||
try {
|
||||
locales = (await readdir(join(process.cwd(), "src", "messages")))
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => name.slice(0, -5))
|
||||
.sort();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
if (input.routeId === "content.localization.overview") {
|
||||
return response(input, [
|
||||
{
|
||||
id: "cms",
|
||||
title: "CMS translations",
|
||||
status: `${String(locales.length)} locales`,
|
||||
href: "/ase-next/content/localization/cms",
|
||||
},
|
||||
{
|
||||
id: "client",
|
||||
title: "Client translations",
|
||||
status: "configured sources",
|
||||
href: "/ase-next/content/localization/client",
|
||||
},
|
||||
{
|
||||
id: "emulator",
|
||||
title: "Emulator translations",
|
||||
status: "database store",
|
||||
href: "/ase-next/content/localization/emulator",
|
||||
},
|
||||
]);
|
||||
}
|
||||
return response(
|
||||
input,
|
||||
locales.map((locale) => ({
|
||||
id: locale,
|
||||
title: locale,
|
||||
status: "editable",
|
||||
href: "/ase-next/content/localization/cms",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadProductionContentQuery(
|
||||
input: NormalizedContentQueryInput,
|
||||
): Promise<ContentQueryData> {
|
||||
if (EMPTY_ROUTES.has(input.routeId)) return response(input, []);
|
||||
if (input.routeId === "content.media.library") return mediaLibrary(input);
|
||||
if (input.routeId.startsWith("content.brand.")) return brand(input);
|
||||
if (
|
||||
input.routeId === "content.localization.overview" ||
|
||||
input.routeId === "content.localization.client" ||
|
||||
input.routeId === "content.localization.cms"
|
||||
) {
|
||||
return localizationFiles(input);
|
||||
}
|
||||
const definition = (
|
||||
CONTENT_QUERY_DEFINITIONS as Partial<
|
||||
Record<ContentRouteId, QueryDefinition>
|
||||
>
|
||||
)[input.routeId];
|
||||
if (!definition) throw new Error("missing Content query adapter");
|
||||
return databaseRoute(input, definition);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { CONTENT_ROUTE_IDS } from "../routes";
|
||||
import {
|
||||
type ContentQueryData,
|
||||
type ContentQueryInput,
|
||||
createContentQuery,
|
||||
} from "./content-queries";
|
||||
import {
|
||||
CONTENT_QUERY_DEFINITIONS,
|
||||
loadProductionContentQuery,
|
||||
} from "./content-queries-production";
|
||||
|
||||
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: Object.assign(queryMocks.tagged, {
|
||||
join: queryMocks.join,
|
||||
raw: queryMocks.raw,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 7 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const ready: ContentQueryData = {
|
||||
kind: "editorial",
|
||||
items: [
|
||||
{
|
||||
id: "18446744073709551615",
|
||||
title: "Published article",
|
||||
status: "published",
|
||||
href: "/ase-next/content/editorial/articles/18446744073709551615",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
};
|
||||
|
||||
describe("Content query", () => {
|
||||
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||
const pageRows = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
title: `Article ${index + 1}`,
|
||||
status: "published",
|
||||
updated_at: null,
|
||||
}));
|
||||
queryMocks.execute
|
||||
.mockResolvedValueOnce([[{ total: 600 }]])
|
||||
.mockResolvedValueOnce([pageRows]);
|
||||
const result = await loadProductionContentQuery({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
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", () => {
|
||||
const emailTemplates =
|
||||
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
||||
expect(emailTemplates.statement).not.toMatch(/\bbody\b|private_payload/iu);
|
||||
});
|
||||
|
||||
it("fails closed before production adapters are invoked", async () => {
|
||||
const load = vi.fn(async () => ready);
|
||||
const query = createContentQuery({ load });
|
||||
|
||||
const result = await query.run(context([]), {
|
||||
routeId: "content.editorial.articles",
|
||||
list: { search: "", pageSize: 25, offset: 0 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds list input, preserves canonical BIGINT strings, and returns real adapter data", async () => {
|
||||
const load = vi.fn(async (_input: ContentQueryInput) => ready);
|
||||
const query = createContentQuery({ load });
|
||||
|
||||
const result = await query.run(context([PERMS.NEWS_VIEW]), {
|
||||
routeId: "content.editorial.articles",
|
||||
list: {
|
||||
search: " launch ",
|
||||
pageSize: 1000,
|
||||
offset: 999_999,
|
||||
},
|
||||
});
|
||||
|
||||
expect(load).toHaveBeenCalledWith({
|
||||
routeId: "content.editorial.articles",
|
||||
params: {},
|
||||
list: { search: "launch", pageSize: 100, offset: 100_000 },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [{ id: "18446744073709551615" }],
|
||||
total: 1,
|
||||
partialDependencies: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a fake partial result and maps complete adapter failure", async () => {
|
||||
const malformed = createContentQuery({
|
||||
load: async () =>
|
||||
({
|
||||
kind: "media",
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: ["imaginary"],
|
||||
}) as ContentQueryData,
|
||||
});
|
||||
const unavailable = createContentQuery({
|
||||
load: async () => {
|
||||
throw new Error("database unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await malformed.run(context([PERMS.PAGES_VIEW]), {
|
||||
routeId: "content.media.library",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
expect(
|
||||
await unavailable.run(context([PERMS.PAGES_VIEW]), {
|
||||
routeId: "content.media.library",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("has an authorized production query path for every Content route", async () => {
|
||||
const load = vi.fn(async (input: ContentQueryInput) => ({
|
||||
kind: input.routeId.split(".")[1] as ContentQueryData["kind"],
|
||||
items: [],
|
||||
total: 0,
|
||||
partialDependencies: [],
|
||||
}));
|
||||
const query = createContentQuery({ load });
|
||||
const all = context(Object.values(PERMS));
|
||||
|
||||
for (const routeId of CONTENT_ROUTE_IDS) {
|
||||
const result = await query.run(all, { routeId });
|
||||
expect(result.ok, routeId).toBe(true);
|
||||
}
|
||||
expect(load).toHaveBeenCalledTimes(CONTENT_ROUTE_IDS.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { authorizeHousekeeping } from "../../../foundation/authorization";
|
||||
import {
|
||||
fail,
|
||||
type HousekeepingQuery,
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import { isSafeHousekeepingHref } from "../../../foundation/housekeeping-href";
|
||||
import {
|
||||
type ContentRouteGroup,
|
||||
type ContentRouteId,
|
||||
contentRouteById,
|
||||
contentRouteGroup,
|
||||
} from "../routes";
|
||||
|
||||
export interface ContentQueryListInput {
|
||||
readonly search?: string;
|
||||
readonly pageSize?: number;
|
||||
readonly offset?: number;
|
||||
}
|
||||
|
||||
export interface ContentQueryInput {
|
||||
readonly routeId: ContentRouteId;
|
||||
readonly params?: Readonly<Record<string, string>>;
|
||||
readonly list?: ContentQueryListInput;
|
||||
}
|
||||
|
||||
export interface ContentQueryItem {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly status?: string;
|
||||
readonly description?: string;
|
||||
readonly href?: string;
|
||||
readonly updatedAt?: string | null;
|
||||
readonly privatePayload?: unknown;
|
||||
}
|
||||
|
||||
export interface ContentQueryData {
|
||||
readonly kind: ContentRouteGroup;
|
||||
readonly items: readonly ContentQueryItem[];
|
||||
readonly total: number;
|
||||
readonly partialDependencies: readonly string[];
|
||||
}
|
||||
|
||||
export interface NormalizedContentQueryInput {
|
||||
readonly routeId: ContentRouteId;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
readonly list: Readonly<{
|
||||
search: string;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ContentQueryAdapters {
|
||||
load(input: NormalizedContentQueryInput): Promise<ContentQueryData>;
|
||||
}
|
||||
|
||||
function boundedInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) return fallback;
|
||||
return Math.min(maximum, Math.max(minimum, parsed));
|
||||
}
|
||||
|
||||
function normalizeInput(input: ContentQueryInput): NormalizedContentQueryInput {
|
||||
return {
|
||||
routeId: input.routeId,
|
||||
params: Object.fromEntries(
|
||||
Object.entries(input.params ?? {}).map(([key, value]) => [
|
||||
key.normalize("NFC").trim().slice(0, 128),
|
||||
value.normalize("NFC").trim().slice(0, 128),
|
||||
]),
|
||||
),
|
||||
list: {
|
||||
search: String(input.list?.search ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 128),
|
||||
pageSize: boundedInteger(input.list?.pageSize, 25, 1, 100),
|
||||
offset: boundedInteger(input.list?.offset, 0, 0, 100_000),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isValidData(
|
||||
data: ContentQueryData,
|
||||
input: NormalizedContentQueryInput,
|
||||
): boolean {
|
||||
if (
|
||||
data.kind !== contentRouteGroup(input.routeId) ||
|
||||
!Array.isArray(data.items) ||
|
||||
!Number.isSafeInteger(data.total) ||
|
||||
data.total < 0 ||
|
||||
!Array.isArray(data.partialDependencies) ||
|
||||
data.partialDependencies.length !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return data.items.every(
|
||||
(item) =>
|
||||
typeof item.id === "string" &&
|
||||
item.id.length > 0 &&
|
||||
typeof item.title === "string" &&
|
||||
item.title.length > 0 &&
|
||||
(item.href === undefined || isSafeHousekeepingHref(item.href)),
|
||||
);
|
||||
}
|
||||
|
||||
export function createContentQuery(
|
||||
adapters: ContentQueryAdapters,
|
||||
): HousekeepingQuery<ContentQueryInput, ContentQueryData> {
|
||||
return {
|
||||
id: "content.query",
|
||||
owner: "content",
|
||||
capability: contentRouteById("content.editorial.articles").capability,
|
||||
async run(context, input) {
|
||||
const route = contentRouteById(input.routeId);
|
||||
const authorization = authorizeHousekeeping(context, route.capability);
|
||||
if (!authorization.ok) return authorization;
|
||||
const normalized = normalizeInput(input);
|
||||
try {
|
||||
const data = await adapters.load(normalized);
|
||||
if (!isValidData(data, normalized)) {
|
||||
throw new Error("invalid Content query data");
|
||||
}
|
||||
return ok(data, authorization.correlationId);
|
||||
} catch {
|
||||
return fail(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"errors.housekeeping.dependencyUnavailable",
|
||||
authorization.correlationId,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const contentQuery = createContentQuery({
|
||||
async load(input) {
|
||||
const { loadProductionContentQuery } = await import(
|
||||
"./content-queries-production"
|
||||
);
|
||||
return loadProductionContentQuery(input);
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user