fix(housekeeping): address task 14 review round 1
This commit is contained in:
1 parent
fd68819d9b
commit
d09eaa33d6
23 files changed
+2503
-488
No files matched your search
@@ -134,7 +134,11 @@ function rows(result: unknown): readonly RawRow[] {
|
||||
}
|
||||
|
||||
function value(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : value == null ? fallback : String(value);
|
||||
return typeof value === "string"
|
||||
? value
|
||||
: value == null
|
||||
? fallback
|
||||
: String(value);
|
||||
}
|
||||
|
||||
function updatedAt(value: unknown): string | null {
|
||||
@@ -157,7 +161,10 @@ function response(
|
||||
};
|
||||
}
|
||||
|
||||
function matches(input: NormalizedContentQueryInput, item: ContentQueryItem): boolean {
|
||||
function matches(
|
||||
input: NormalizedContentQueryInput,
|
||||
item: ContentQueryItem,
|
||||
): boolean {
|
||||
const needle = input.list.search.toLocaleLowerCase();
|
||||
return (
|
||||
needle.length === 0 ||
|
||||
@@ -171,18 +178,20 @@ function mapRows(
|
||||
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));
|
||||
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);
|
||||
}
|
||||
@@ -200,7 +209,11 @@ async function databaseRoute(
|
||||
const items = mapRows(
|
||||
input,
|
||||
definition,
|
||||
rows(await db.execute(sql.raw(definition.statement))),
|
||||
rows(
|
||||
await db.execute(
|
||||
sql.raw(definition.statement.replace(/\s+LIMIT 500$/u, "")),
|
||||
),
|
||||
),
|
||||
);
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
@@ -219,54 +232,67 @@ async function mediaLibrary(
|
||||
/[.](png|jpe?g|gif|webp|svg|bmp)$/iu.test(name),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return response(input, []);
|
||||
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)) })),
|
||||
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/content/media/library",
|
||||
})).filter((item) => matches(input, item));
|
||||
const items = entries
|
||||
.map(({ name, metadata }) => ({
|
||||
id: name,
|
||||
title: name,
|
||||
description: String(metadata.size) + " bytes",
|
||||
status: "stored",
|
||||
updatedAt: metadata.mtime.toISOString(),
|
||||
href: "/ase/content/media/library",
|
||||
}))
|
||||
.filter((item) => matches(input, item));
|
||||
return response(input, items, items.length);
|
||||
}
|
||||
|
||||
async function brand(input: NormalizedContentQueryInput): Promise<ContentQueryData> {
|
||||
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/content/brand/favicon",
|
||||
}]);
|
||||
return response(input, [
|
||||
{
|
||||
id: "favicon",
|
||||
title: favicon || "Default favicon",
|
||||
status: favicon ? "custom" : "default",
|
||||
href: "/ase/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/content/brand/theme",
|
||||
}, ...customThemes.map((theme) => ({
|
||||
id: theme.id,
|
||||
title: theme.name,
|
||||
status: "saved",
|
||||
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||
href: "/ase/content/brand/theme",
|
||||
}))]);
|
||||
return response(input, [
|
||||
{
|
||||
id: "active-theme",
|
||||
title: preset || "Atom (golden)",
|
||||
status: "active",
|
||||
href: "/ase/content/brand/theme",
|
||||
},
|
||||
...customThemes.map((theme) => ({
|
||||
id: theme.id,
|
||||
title: theme.name,
|
||||
status: "saved",
|
||||
updatedAt: new Date(theme.createdAt).toISOString(),
|
||||
href: "/ase/content/brand/theme",
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
async function localizationFiles(
|
||||
@@ -276,13 +302,16 @@ async function localizationFiles(
|
||||
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/content/localization/client",
|
||||
})));
|
||||
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/content/localization/client",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const [{ readdir }, { join }] = await Promise.all([
|
||||
import("node:fs/promises"),
|
||||
@@ -319,12 +348,15 @@ async function localizationFiles(
|
||||
},
|
||||
]);
|
||||
}
|
||||
return response(input, locales.map((locale) => ({
|
||||
id: locale,
|
||||
title: locale,
|
||||
status: "editable",
|
||||
href: "/ase/content/localization/cms",
|
||||
})));
|
||||
return response(
|
||||
input,
|
||||
locales.map((locale) => ({
|
||||
id: locale,
|
||||
title: locale,
|
||||
status: "editable",
|
||||
href: "/ase/content/localization/cms",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadProductionContentQuery(
|
||||
@@ -340,9 +372,11 @@ export async function loadProductionContentQuery(
|
||||
) {
|
||||
return localizationFiles(input);
|
||||
}
|
||||
const definition = (CONTENT_QUERY_DEFINITIONS as Partial<
|
||||
Record<ContentRouteId, QueryDefinition>
|
||||
>)[input.routeId];
|
||||
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);
|
||||
}
|
||||
@@ -7,7 +7,16 @@ import {
|
||||
type ContentQueryInput,
|
||||
createContentQuery,
|
||||
} from "./content-queries";
|
||||
import { CONTENT_QUERY_DEFINITIONS } from "./content-queries-production";
|
||||
import {
|
||||
CONTENT_QUERY_DEFINITIONS,
|
||||
loadProductionContentQuery,
|
||||
} from "./content-queries-production";
|
||||
|
||||
const queryMocks = vi.hoisted(() => ({ execute: vi.fn() }));
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
sql: { raw: (statement: string) => statement },
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ db: { execute: queryMocks.execute } }));
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
@@ -35,6 +44,25 @@ const ready: ContentQueryData = {
|
||||
};
|
||||
|
||||
describe("Content query", () => {
|
||||
it("reports totals beyond the former 500-row adapter cap", async () => {
|
||||
const allRows = Array.from({ length: 600 }, (_, 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,
|
||||
]);
|
||||
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);
|
||||
});
|
||||
|
||||
it("never selects email template bodies into summary query results", () => {
|
||||
const emailTemplates =
|
||||
CONTENT_QUERY_DEFINITIONS["content.help.email-templates"];
|
||||
|
||||
Reference in new issue
Block a user