From 08f8d5488838e461e965d67bf02a5d48101441c0 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 26 Aug 2026 19:16:34 +0200 Subject: [PATCH] fix: close housekeeping foundation review findings --- .../housekeeping/domains/content/manifest.ts | 11 + .../housekeeping/domains/economy/manifest.ts | 10 +- .../housekeeping/domains/hotel/manifest.ts | 8 + .../domains/operations/manifest.ts | 3 + .../housekeeping/domains/people/manifest.ts | 15 ++ .../housekeeping/domains/system/manifest.ts | 5 + .../foundation/contracts/contracts.test.ts | 32 +++ .../foundation/contracts/domain.ts | 6 + .../foundation/contracts/inbox.ts | 3 + .../foundation/contracts/index.ts | 5 +- .../foundation/contracts/search.ts | 8 +- .../foundation/contracts/widget.ts | 12 +- .../foundation-source-contract.test.ts | 10 +- .../foundation/navigation.test.ts | 26 +- .../page/housekeeping-page-state.test.tsx | 60 +++-- .../page/housekeeping-page-state.tsx | 19 +- .../foundation/preview-route-contract.test.ts | 35 ++- .../housekeeping/foundation/registry.test.ts | 229 +++++++++++++++++- .../housekeeping/foundation/registry.ts | 64 ++++- .../migration/validate-matrix.test.ts | 42 ++++ .../housekeeping/migration/validate-matrix.ts | 36 ++- 21 files changed, 593 insertions(+), 46 deletions(-) diff --git a/src/features/housekeeping/domains/content/manifest.ts b/src/features/housekeeping/domains/content/manifest.ts index 729dd646..f367587c 100644 --- a/src/features/housekeeping/domains/content/manifest.ts +++ b/src/features/housekeeping/domains/content/manifest.ts @@ -17,6 +17,17 @@ export const contentManifest = { PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW, PERMS.PREFIXES_VIEW, + PERMS.NEWS_EDIT, + PERMS.PAGES_EDIT, + PERMS.BANNERS_EDIT, + PERMS.EVENTS_EDIT, + PERMS.POLLS_EDIT, + PERMS.PREFIXES_EDIT, + PERMS.SETTINGS_VIEW, + PERMS.SETTINGS_EDIT, ), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts index 728a3f1c..8eddf3b5 100644 --- a/src/features/housekeeping/domains/economy/manifest.ts +++ b/src/features/housekeeping/domains/economy/manifest.ts @@ -10,6 +10,14 @@ export const economyManifest = { descriptionKey: "pages.housekeeping.domains.economy.description", iconId: "gem", previewHref: "/admin-next/economy", - capability: anyCapability(PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW), + capability: anyCapability( + PERMS.CATALOG_VIEW, + PERMS.SHOP_VIEW, + PERMS.CATALOG_EDIT, + PERMS.SHOP_EDIT, + ), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index 3e55782d..8c0ef95b 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -14,6 +14,14 @@ export const hotelManifest = { PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, PERMS.ASSETS_IMPORT, + PERMS.ROOMS_EDIT, + PERMS.ROOMS_DELETE, + PERMS.RADIO_EDIT, + PERMS.PAGES_VIEW, + PERMS.CATALOG_EDIT, ), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/operations/manifest.ts b/src/features/housekeeping/domains/operations/manifest.ts index d5b170a6..c06d2687 100644 --- a/src/features/housekeeping/domains/operations/manifest.ts +++ b/src/features/housekeeping/domains/operations/manifest.ts @@ -12,4 +12,7 @@ export const operationsManifest = { previewHref: "/admin-next/operations", capability: anyCapability(PERMS.ADMIN_DASHBOARD), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts index 5b914e58..21d1f3ea 100644 --- a/src/features/housekeeping/domains/people/manifest.ts +++ b/src/features/housekeeping/domains/people/manifest.ts @@ -21,6 +21,21 @@ export const peopleManifest = { PERMS.MOD_TICKETS_VIEW, PERMS.MOD_USERS_VIEW, PERMS.MOD_BANS_VIEW, + PERMS.USERS_EDIT, + PERMS.USERS_BAN, + PERMS.USERS_RESET_PASSWORD, + PERMS.MODERATION_EDIT, + PERMS.TICKETS_EDIT, + PERMS.SETTINGS_VIEW, + PERMS.SETTINGS_EDIT, + PERMS.WORDFILTER_VIEW, + PERMS.WORDFILTER_EDIT, + PERMS.MOD_ACTIONS, + PERMS.MOD_CFH_EDIT, + PERMS.MOD_TICKETS_EDIT, ), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts index 91d4402e..f4ba2345 100644 --- a/src/features/housekeeping/domains/system/manifest.ts +++ b/src/features/housekeeping/domains/system/manifest.ts @@ -18,6 +18,11 @@ export const systemManifest = { PERMS.NOTIFICATIONS_VIEW, PERMS.PERMISSIONS_MANAGE, PERMS.RCON_EXECUTE, + PERMS.SETTINGS_EDIT, + PERMS.NOTIFICATIONS_EDIT, ), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], } satisfies HousekeepingDomainManifest; diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts index bd538226..caf21dd0 100644 --- a/src/features/housekeeping/foundation/contracts/contracts.test.ts +++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts @@ -40,6 +40,9 @@ const searchResult = { } satisfies HousekeepingSearchResult; const searchProvider: HousekeepingSearchProvider = { + id: "people.users", + owner: "people", + capability, search: async () => ok([searchResult], "search-1"), }; @@ -49,10 +52,17 @@ const inboxSourceResult = { } satisfies HousekeepingInboxSourceResult; const inboxSource: HousekeepingInboxSource = { + id: "people.tickets", + owner: "people", + capability, getItems: async () => ok(inboxSourceResult, "inbox-1"), }; const widget: HousekeepingWidgetDefinition = { + id: "people.queue", + owner: "people", + capability, + kind: "mandatory", load: async () => ok({ count: 2 }, "widget-1"), }; @@ -80,6 +90,9 @@ const manifest: HousekeepingDomainManifest = { previewHref: "/admin-next/people", capability, routes: [], + searchProviders: [searchProvider], + inboxSources: [inboxSource], + widgets: [widget], }; describe("housekeeping foundation contracts", () => { @@ -118,4 +131,23 @@ describe("housekeeping foundation contracts", () => { requirements, ]).toHaveLength(8); }); + + it("carries stable ownership and capability metadata for registry entries", () => { + expect(searchProvider).toMatchObject({ + id: "people.users", + owner: "people", + capability, + }); + expect(inboxSource).toMatchObject({ + id: "people.tickets", + owner: "people", + capability, + }); + expect(widget).toMatchObject({ + id: "people.queue", + owner: "people", + capability, + kind: "mandatory", + }); + }); }); diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts index 8c548b56..9ccb4f54 100644 --- a/src/features/housekeeping/foundation/contracts/domain.ts +++ b/src/features/housekeeping/foundation/contracts/domain.ts @@ -1,5 +1,8 @@ import type { HousekeepingDomainId } from "../../migration/types"; import type { CapabilityRequirement } from "./capability"; +import type { HousekeepingInboxSource } from "./inbox"; +import type { HousekeepingSearchProvider } from "./search"; +import type { HousekeepingWidgetDefinition } from "./widget"; export interface HousekeepingRouteDefinition { id: string; @@ -17,4 +20,7 @@ export interface HousekeepingDomainManifest { previewHref: `/admin-next/${HousekeepingDomainId}`; capability: CapabilityRequirement; routes: readonly HousekeepingRouteDefinition[]; + searchProviders: readonly HousekeepingSearchProvider[]; + inboxSources: readonly HousekeepingInboxSource[]; + widgets: readonly HousekeepingWidgetDefinition[]; } diff --git a/src/features/housekeeping/foundation/contracts/inbox.ts b/src/features/housekeeping/foundation/contracts/inbox.ts index c402c0f8..72bc4e89 100644 --- a/src/features/housekeeping/foundation/contracts/inbox.ts +++ b/src/features/housekeeping/foundation/contracts/inbox.ts @@ -29,6 +29,9 @@ export interface HousekeepingInboxSourceResult { } export interface HousekeepingInboxSource { + id: string; + owner: HousekeepingDomainId; + capability: CapabilityRequirement; getItems( context: HousekeepingCapabilityContext, signal: AbortSignal, diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts index ab48192f..55f607fb 100644 --- a/src/features/housekeeping/foundation/contracts/index.ts +++ b/src/features/housekeeping/foundation/contracts/index.ts @@ -30,4 +30,7 @@ export type { HousekeepingSearchProvider, HousekeepingSearchResult, } from "./search"; -export type { HousekeepingWidgetDefinition } from "./widget"; +export type { + HousekeepingWidgetDefinition, + HousekeepingWidgetKind, +} from "./widget"; diff --git a/src/features/housekeeping/foundation/contracts/search.ts b/src/features/housekeeping/foundation/contracts/search.ts index 744aa3d8..b12ca334 100644 --- a/src/features/housekeeping/foundation/contracts/search.ts +++ b/src/features/housekeeping/foundation/contracts/search.ts @@ -1,5 +1,8 @@ import type { HousekeepingDomainId } from "../../migration/types"; -import type { HousekeepingCapabilityContext } from "./capability"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, +} from "./capability"; import type { HousekeepingResult } from "./result"; export interface HousekeepingSearchInput { @@ -15,6 +18,9 @@ export interface HousekeepingSearchResult { } export interface HousekeepingSearchProvider { + id: string; + owner: HousekeepingDomainId; + capability: CapabilityRequirement; search( context: HousekeepingCapabilityContext, input: HousekeepingSearchInput, diff --git a/src/features/housekeeping/foundation/contracts/widget.ts b/src/features/housekeeping/foundation/contracts/widget.ts index af3929e3..3fee4323 100644 --- a/src/features/housekeeping/foundation/contracts/widget.ts +++ b/src/features/housekeeping/foundation/contracts/widget.ts @@ -1,7 +1,17 @@ -import type { HousekeepingCapabilityContext } from "./capability"; +import type { HousekeepingDomainId } from "../../migration/types"; +import type { + CapabilityRequirement, + HousekeepingCapabilityContext, +} from "./capability"; import type { HousekeepingResult } from "./result"; +export type HousekeepingWidgetKind = "mandatory" | "optional"; + export interface HousekeepingWidgetDefinition { + id: string; + owner: HousekeepingDomainId; + capability: CapabilityRequirement; + kind: HousekeepingWidgetKind; load( context: HousekeepingCapabilityContext, ): Promise>; diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 8f00c82c..3c7f541a 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -265,6 +265,8 @@ function scanModuleAccesses(source: string): ModuleAccessScan { } } else if (value.type === "ImportExpression") { recordArgument(value.source, "import"); + } else if (value.type === "TSImportType") { + recordArgument(value.argument, "import"); } else if ( value.type === "CallExpression" || value.type === "OptionalCallExpression" @@ -445,6 +447,12 @@ describe("housekeeping runtime import boundary", () => { 'import workflow from "../domains/people/workflow";', "src/features/housekeeping/domains/people/workflow", ], + [ + "TypeScript import type database access", + "src/features/housekeeping/foundation/registry.ts", + 'type PrismaClient = import("@prisma/client").PrismaClient;', + "@prisma/client", + ], ] as const)("detects %s", (_name, sourceFile, source, expectedPath) => { expect( findHousekeepingImportBoundaryViolations(source, sourceFile), @@ -510,7 +518,7 @@ describe("housekeeping runtime import boundary", () => { 'import database from "@/lib/database";', 'import authentication from "@/lib/authentication";', 'import commandKit from "cmdkit";', - 'import manifest from "../domains/people/manifest";', + 'import manifest from "./domains/people/manifest";', "const documentation = \"import db from '@/lib/db'\";", '// import action from "@/actions/users";', ].join("\n"); diff --git a/src/features/housekeeping/foundation/navigation.test.ts b/src/features/housekeeping/foundation/navigation.test.ts index 82ae3c0d..88dce236 100644 --- a/src/features/housekeeping/foundation/navigation.test.ts +++ b/src/features/housekeeping/foundation/navigation.test.ts @@ -26,6 +26,9 @@ describe("housekeeping navigation", () => { previewHref: "/admin-next/people", capability: anyCapability(PERMS.MOD_CFH_VIEW), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], }, { id: "economy", @@ -35,6 +38,9 @@ describe("housekeeping navigation", () => { previewHref: "/admin-next/economy", capability: anyCapability(PERMS.CATALOG_VIEW), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], }, ]); @@ -79,6 +85,9 @@ describe("housekeeping navigation", () => { capability: anyCapability(PERMS.BANS_VIEW), }, ], + searchProviders: [], + inboxSources: [], + widgets: [], }, { id: "system", @@ -88,6 +97,9 @@ describe("housekeeping navigation", () => { previewHref: "/admin-next/system", capability: anyCapability(PERMS.SETTINGS_VIEW), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], }, ]); const translate = vi.fn((key: string) => `translated:${key}`); @@ -118,7 +130,7 @@ describe("housekeeping navigation", () => { expect(translate).not.toHaveBeenCalledWith("system.title"); expect(translate).not.toHaveBeenCalledWith("system.description"); }); - it("shows the real People manifest only for its view capabilities", () => { + it("shows real domains to operators authorized by owned migration capabilities", () => { const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); const translate = (key: string) => key; @@ -137,15 +149,21 @@ describe("housekeeping navigation", () => { ).map((domain) => domain.id), ).not.toContain("economy"); - for (const slug of [PERMS.MOD_ACTIONS, PERMS.MOD_CFH_EDIT]) { + for (const [slug, expectedDomain] of [ + [PERMS.MOD_ACTIONS, "people"], + [PERMS.USERS_EDIT, "people"], + [PERMS.SETTINGS_VIEW, "people"], + [PERMS.NEWS_EDIT, "content"], + [PERMS.SHOP_EDIT, "economy"], + [PERMS.ROOMS_EDIT, "hotel"], + ] as const) { const visibleDomainIds = buildHousekeepingNavigation( registry, context([slug]), translate, ).map((domain) => domain.id); - expect(visibleDomainIds).not.toContain("people"); - expect(visibleDomainIds).not.toContain("economy"); + expect(visibleDomainIds, slug).toContain(expectedDomain); } }); }); diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx index cbb7cd48..b6c9ecbf 100644 --- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx +++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx @@ -1,5 +1,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import en from "@/messages/en.json"; +import itMessages from "@/messages/it.json"; import { HousekeepingPageShell } from "./housekeeping-page-shell"; import { HousekeepingPageState } from "./housekeeping-page-state"; @@ -10,20 +12,29 @@ describe("HousekeepingPageState", () => { ["partial", "status"], ["error", "alert"], ] as const)("renders %s with the %s role", (state, role) => { - const html = renderToStaticMarkup( - , - ); + const pageState = + state === "partial" ? ( + + ) : ( + + ); + const html = renderToStaticMarkup(pageState); expect(html).toContain(`role="${role}"`); expect(html).toContain(`>${state} title<`); expect(html).toContain(`>${state} description<`); }); - it("announces loading politely and makes the partial warning visible", () => { + it("announces loading politely", () => { const loading = renderToStaticMarkup( { description="Loading description" />, ); - const partial = renderToStaticMarkup( - , - ); expect(loading).toContain('aria-live="polite"'); - expect(partial).toContain("--admin-warning-border"); - expect(partial).toContain("--admin-warning-subtle"); - expect(partial).toContain(">Partial title<"); }); + it.each([ + ["English", en.pages.housekeeping.states.partial], + ["Italian", itMessages.pages.housekeeping.states.partial], + ] as const)( + "preserves the partial-state heading and separate localized warning in %s", + (_locale, copy) => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toMatch(new RegExp(`]*>${copy.title}`)); + expect(html).toContain(`>${copy.label}

`); + expect(html).toContain('data-state-tone="warning"'); + expect(html).toContain("--admin-warning-border"); + expect(html).toContain("--admin-warning-subtle"); + }, + ); + it("renders supplied retry content for an error without owning a callback", () => { const html = renderToStaticMarkup( - {title} + {partialLabel}

- ) : ( -

{title}

- )} + ) : null} +

{title}

{description}

diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 52a8d753..ae9bd9b6 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -98,6 +98,9 @@ const forbiddenModuleRoots = [ "src/app/actions", "src/app/admin", "src/app/mod", + "@prisma/client", + "drizzle-orm", + "mysql2", ] as const; interface BabelNode { @@ -287,10 +290,6 @@ function canonicalizeLocalSpecifier( const suffixIndex = specifier.search(/[?#]/); const withoutSuffix = suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex); - const slashNormalized = withoutSuffix.replaceAll("\\", "/"); - if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) { - return { candidates: [], violation: null }; - } let decoded: string; try { @@ -305,12 +304,15 @@ function canonicalizeLocalSpecifier( } else if (decoded.startsWith(".")) { normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded)); } else { - return { candidates: [], violation: "" }; + normalized = posix.normalize(decoded); } const extensionless = normalized.replace(resolverExtensionPattern, ""); return { - candidates: [...new Set([normalized, extensionless])], + candidates: + decoded.startsWith("@/") || decoded.startsWith(".") + ? [...new Set([normalized, extensionless])] + : [normalized], violation: null, }; } @@ -785,6 +787,24 @@ describe("preview route import boundary", () => { 'import("../mod/users/page")', "src/app/mod/users/page", ], + [ + "Prisma TypeScript import type", + "src/app/admin-next/page.tsx", + 'type PrismaClient = import("@prisma/client").PrismaClient;', + "@prisma/client", + ], + [ + "Drizzle package import", + "src/app/admin-next/page.tsx", + 'import { sql } from "drizzle-orm";', + "drizzle-orm", + ], + [ + "mysql2 package import", + "src/app/admin-next/page.tsx", + 'import type { Pool } from "mysql2";', + "mysql2", + ], ] as const)("detects %s", (_name, routeFile, source, expectedPath) => { expect(findRouteImportBoundaryViolations(source, routeFile)).toContain( expectedPath, @@ -824,6 +844,9 @@ describe("preview route import boundary", () => { 'import dbTools from "../../lib/db-tools.ts";', 'import auth from "../../lib/authentication";', 'import preview from "../admin-next-shared";', + 'import prismaTools from "@prisma/client-tools";', + 'import drizzleTools from "drizzle-orm-kit";', + 'import mysqlTools from "mysql2-wrapper";', "const documentation = \"import db from '../../lib/db'\";", 'const rawTemplate = `import("../../actions/users")`;', '// import db from "../../lib/db";', diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 97c3d07d..5bbc528d 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -1,11 +1,20 @@ import { describe, expect, it } from "vitest"; import { PERMS } from "@/lib/permission-slugs"; import { HOUSEKEEPING_MANIFESTS } from "../manifests"; +import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix"; import { HOUSEKEEPING_DOMAIN_IDS, type HousekeepingDomainId, } from "../migration/types"; -import { anyCapability, type HousekeepingDomainManifest } from "./contracts"; +import { + anyCapability, + type CapabilityRequirement, + type HousekeepingDomainManifest, + type HousekeepingInboxSource, + type HousekeepingSearchProvider, + type HousekeepingWidgetDefinition, + ok, +} from "./contracts"; import { createHousekeepingRegistry } from "./registry"; const manifest = ( @@ -19,6 +28,45 @@ const manifest = ( previewHref: `/admin-next/${id}`, capability: anyCapability(capabilitySlug), routes: [], + searchProviders: [], + inboxSources: [], + widgets: [], +}); + +const providerCapability = anyCapability(PERMS.USERS_VIEW); + +const searchProvider = ( + id: string, + owner: HousekeepingDomainId = "people", + capability: CapabilityRequirement = providerCapability, +): HousekeepingSearchProvider => ({ + id, + owner, + capability, + search: async () => ok([], "search"), +}); + +const inboxSource = ( + id: string, + owner: HousekeepingDomainId = "people", + capability: CapabilityRequirement = providerCapability, +): HousekeepingInboxSource => ({ + id, + owner, + capability, + getItems: async () => ok({ items: [], availability: "available" }, "inbox"), +}); + +const widget = ( + id: string, + owner: HousekeepingDomainId = "people", + capability: CapabilityRequirement = providerCapability, +): HousekeepingWidgetDefinition => ({ + id, + owner, + capability, + kind: "optional", + load: async () => ok(null, "widget"), }); const expectedManifests = [ @@ -47,6 +95,18 @@ const expectedManifests = [ PERMS.MOD_TICKETS_VIEW, PERMS.MOD_USERS_VIEW, PERMS.MOD_BANS_VIEW, + PERMS.USERS_EDIT, + PERMS.USERS_BAN, + PERMS.USERS_RESET_PASSWORD, + PERMS.MODERATION_EDIT, + PERMS.TICKETS_EDIT, + PERMS.SETTINGS_VIEW, + PERMS.SETTINGS_EDIT, + PERMS.WORDFILTER_VIEW, + PERMS.WORDFILTER_EDIT, + PERMS.MOD_ACTIONS, + PERMS.MOD_CFH_EDIT, + PERMS.MOD_TICKETS_EDIT, ], }, { @@ -62,6 +122,14 @@ const expectedManifests = [ PERMS.EVENTS_VIEW, PERMS.POLLS_VIEW, PERMS.PREFIXES_VIEW, + PERMS.NEWS_EDIT, + PERMS.PAGES_EDIT, + PERMS.BANNERS_EDIT, + PERMS.EVENTS_EDIT, + PERMS.POLLS_EDIT, + PERMS.PREFIXES_EDIT, + PERMS.SETTINGS_VIEW, + PERMS.SETTINGS_EDIT, ], }, { @@ -70,7 +138,12 @@ const expectedManifests = [ previewHref: "/admin-next/economy", labelKey: "pages.housekeeping.domains.economy.title", descriptionKey: "pages.housekeeping.domains.economy.description", - slugs: [PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW], + slugs: [ + PERMS.CATALOG_VIEW, + PERMS.SHOP_VIEW, + PERMS.CATALOG_EDIT, + PERMS.SHOP_EDIT, + ], }, { id: "hotel", @@ -78,7 +151,16 @@ const expectedManifests = [ previewHref: "/admin-next/hotel", labelKey: "pages.housekeeping.domains.hotel.title", descriptionKey: "pages.housekeeping.domains.hotel.description", - slugs: [PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, PERMS.ASSETS_IMPORT], + slugs: [ + PERMS.ROOMS_VIEW, + PERMS.RADIO_VIEW, + PERMS.ASSETS_IMPORT, + PERMS.ROOMS_EDIT, + PERMS.ROOMS_DELETE, + PERMS.RADIO_EDIT, + PERMS.PAGES_VIEW, + PERMS.CATALOG_EDIT, + ], }, { id: "system", @@ -94,6 +176,8 @@ const expectedManifests = [ PERMS.NOTIFICATIONS_VIEW, PERMS.PERMISSIONS_MANAGE, PERMS.RCON_EXECUTE, + PERMS.SETTINGS_EDIT, + PERMS.NOTIFICATIONS_EDIT, ], }, ] as const; @@ -240,10 +324,149 @@ describe("housekeeping registry", () => { descriptionKey: expected.descriptionKey, }); expect(actual.routes).toEqual([]); + expect(actual.searchProviders).toEqual([]); + expect(actual.inboxSources).toEqual([]); + expect(actual.widgets).toEqual([]); expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs }); } }); + it("covers every capability attributed to each domain's migration rows", () => { + for (const manifest of HOUSEKEEPING_MANIFESTS) { + const attributedCapabilities = new Set( + HOUSEKEEPING_MIGRATION_MATRIX.filter( + (entry) => entry.targetDomain === manifest.id, + ).flatMap((entry) => [ + ...entry.capabilities.read, + ...entry.capabilities.mutate, + ]), + ); + + for (const slug of attributedCapabilities) { + expect( + manifest.capability.slugs, + `domain ${manifest.id}: ${slug}`, + ).toContain(slug); + } + } + }); + + it("rejects duplicate provider and widget ids across domain manifests", () => { + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), searchProviders: [searchProvider("shared")] }, + { + ...manifest("content"), + searchProviders: [searchProvider("shared", "content")], + }, + ]), + ).toThrow("duplicate search provider id: shared"); + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), inboxSources: [inboxSource("shared")] }, + { + ...manifest("content"), + inboxSources: [inboxSource("shared", "content")], + }, + ]), + ).toThrow("duplicate inbox source id: shared"); + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), widgets: [widget("shared")] }, + { + ...manifest("content"), + widgets: [widget("shared", "content")], + }, + ]), + ).toThrow("duplicate widget id: shared"); + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), searchProviders: [searchProvider("shared")] }, + { + ...manifest("content"), + widgets: [widget("shared", "content")], + }, + ]), + ).toThrow("duplicate widget id: shared"); + }); + + it("rejects provider and widget ownership outside their manifest", () => { + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + searchProviders: [searchProvider("people.users", "content")], + }, + ]), + ).toThrow("search provider owner mismatch: people.users"); + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + inboxSources: [inboxSource("people.tickets", "content")], + }, + ]), + ).toThrow("inbox source owner mismatch: people.tickets"); + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + widgets: [widget("people.queue", "content")], + }, + ]), + ).toThrow("widget owner mismatch: people.queue"); + }); + + it("rejects empty ids and invalid capability shapes for registry metadata", () => { + const invalidCapability = { + mode: "some", + slugs: [PERMS.USERS_VIEW], + } as unknown as CapabilityRequirement; + + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), searchProviders: [searchProvider(" ")] }, + ]), + ).toThrow("empty search provider id"); + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + inboxSources: [ + inboxSource("people.tickets", "people", invalidCapability), + ], + }, + ]), + ).toThrow("invalid capability requirement"); + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + widgets: [ + widget( + "people.queue", + "people", + anyCapability("admin.ghost.widget"), + ), + ], + }, + ]), + ).toThrow("unknown capability slug: admin.ghost.widget"); + }); + + it("rejects widget kinds outside the mandatory or optional contract", () => { + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + widgets: [ + { ...widget("people.queue"), kind: "fixed" as "mandatory" }, + ], + }, + ]), + ).toThrow("invalid widget kind: people.queue"); + }); + it("rejects empty domain and route capability requirements", () => { expect(() => createHousekeepingRegistry([ diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index dc6c58ae..f301ebbe 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -7,6 +7,11 @@ import type { const approvedDomainIds = new Set(HOUSEKEEPING_DOMAIN_IDS); const knownCapabilitySlugs = new Set(Object.values(PERMS)); +interface OwnedRegistryEntry { + id: string; + owner: HousekeepingDomainManifest["id"]; + capability: CapabilityRequirement; +} export interface HousekeepingRegistry { domains: readonly HousekeepingDomainManifest[]; @@ -18,6 +23,7 @@ export function createHousekeepingRegistry( const domainIds = new Set(); const routeIds = new Set(); const routeHrefs = new Set(); + const registryEntryIds = new Set(); for (const manifest of manifests) { if (!approvedDomainIds.has(manifest.id)) { @@ -34,6 +40,29 @@ export function createHousekeepingRegistry( validateNonEmpty(manifest.labelKey, "label key"); validateNonEmpty(manifest.descriptionKey, "description key"); validateCapability(manifest.capability); + validateOwnedRegistryEntries( + manifest.searchProviders, + manifest.id, + "search provider", + registryEntryIds, + ); + validateOwnedRegistryEntries( + manifest.inboxSources, + manifest.id, + "inbox source", + registryEntryIds, + ); + validateOwnedRegistryEntries( + manifest.widgets, + manifest.id, + "widget", + registryEntryIds, + ); + for (const widget of manifest.widgets) { + if (widget.kind !== "mandatory" && widget.kind !== "optional") { + throw new Error(`invalid widget kind: ${widget.id}`); + } + } for (const route of manifest.routes) { validateNonEmpty(route.id, "route id"); @@ -56,18 +85,51 @@ export function createHousekeepingRegistry( return { domains: Object.freeze([...manifests]) }; } +function validateOwnedRegistryEntries( + entries: readonly OwnedRegistryEntry[], + owner: HousekeepingDomainManifest["id"], + kind: "search provider" | "inbox source" | "widget", + ids: Set, +): void { + for (const entry of entries) { + validateNonEmpty(entry.id, `${kind} id`); + if (ids.has(entry.id)) { + throw new Error(`duplicate ${kind} id: ${entry.id}`); + } + ids.add(entry.id); + + if (entry.owner !== owner) { + throw new Error(`${kind} owner mismatch: ${entry.id}`); + } + + validateCapability(entry.capability); + } +} + function validateNonEmpty(value: string, name: string): void { - if (!value.trim()) { + if (typeof value !== "string" || !value.trim()) { throw new Error(`empty ${name}`); } } function validateCapability(requirement: CapabilityRequirement): void { + if ( + typeof requirement !== "object" || + requirement === null || + (requirement.mode !== "any" && requirement.mode !== "all") || + !Array.isArray(requirement.slugs) + ) { + throw new Error("invalid capability requirement"); + } + if (requirement.slugs.length === 0) { throw new Error("empty capability requirement"); } for (const slug of requirement.slugs) { + if (typeof slug !== "string") { + throw new Error("invalid capability requirement"); + } if (!knownCapabilitySlugs.has(slug)) { throw new Error(`unknown capability slug: ${slug}`); } diff --git a/src/features/housekeeping/migration/validate-matrix.test.ts b/src/features/housekeeping/migration/validate-matrix.test.ts index f2d0f3d5..87e4a1d2 100644 --- a/src/features/housekeeping/migration/validate-matrix.test.ts +++ b/src/features/housekeeping/migration/validate-matrix.test.ts @@ -50,6 +50,48 @@ describe("validateMigrationEntries", () => { expect(issues).toContain("REBUILD requires targetPath: /admin"); }); + it("rejects a discovery surface mismatch for the same legacy path", () => { + expect( + validateMigrationEntries( + [page("/admin")], + [{ ...entry("/admin"), surface: "mod" }], + ), + ).toEqual([ + "surface mismatch for legacyPath /admin: expected admin, received mod", + ]); + }); + + it("rejects a discovery source file mismatch for the same legacy path", () => { + expect( + validateMigrationEntries( + [page("/admin")], + [ + { + ...entry("/admin"), + sourceFile: "src/app/admin/renamed/page.tsx", + }, + ], + ), + ).toEqual([ + "sourceFile mismatch for legacyPath /admin: expected src/app/admin/page.tsx, received src/app/admin/renamed/page.tsx", + ]); + }); + + it("rejects removal with a non-null target", () => { + expect( + validateMigrationEntries( + [page("/admin")], + [ + { + ...entry("/admin"), + decision: "REMOVE", + targetPath: "/admin/system/legacy", + }, + ], + ), + ).toEqual(["REMOVE requires null targetPath: /admin"]); + }); + it("enforces migration safety evidence", () => { const issues = validateMigrationEntries( [page("/admin")], diff --git a/src/features/housekeeping/migration/validate-matrix.ts b/src/features/housekeeping/migration/validate-matrix.ts index 222e9d02..0e6f9231 100644 --- a/src/features/housekeeping/migration/validate-matrix.ts +++ b/src/features/housekeeping/migration/validate-matrix.ts @@ -16,7 +16,13 @@ export function validateMigrationEntries( entries: readonly MigrationEntry[], ): string[] { const issues = new Set(); - const discoveredPaths = new Set(discovered.map((page) => page.legacyPath)); + const discoveredByPath = new Map(); + for (const page of discovered) { + const matchingPages = discoveredByPath.get(page.legacyPath) ?? []; + matchingPages.push(page); + discoveredByPath.set(page.legacyPath, matchingPages); + } + const entriesByPath = new Map(); for (const entry of entries) { @@ -24,8 +30,30 @@ export function validateMigrationEntries( matchingEntries.push(entry); entriesByPath.set(entry.legacyPath, matchingEntries); - if (!discoveredPaths.has(entry.legacyPath)) { + const matchingPages = discoveredByPath.get(entry.legacyPath); + if (!matchingPages) { issues.add(`unknown legacyPath: ${entry.legacyPath}`); + continue; + } + + const surfaceMatch = matchingPages.find( + (page) => page.surface === entry.surface, + ); + const identityMatch = matchingPages.some( + (page) => + page.surface === entry.surface && page.sourceFile === entry.sourceFile, + ); + if (!surfaceMatch) { + const expectedSurface = matchingPages + .map((page) => page.surface) + .join(" or "); + issues.add( + `surface mismatch for legacyPath ${entry.legacyPath}: expected ${expectedSurface}, received ${entry.surface}`, + ); + } else if (!identityMatch) { + issues.add( + `sourceFile mismatch for legacyPath ${entry.legacyPath}: expected ${surfaceMatch.sourceFile}, received ${entry.sourceFile}`, + ); } } @@ -45,6 +73,10 @@ export function validateMigrationEntries( for (const entry of entries) { const { legacyPath } = entry; + if (entry.decision === "REMOVE" && entry.targetPath !== null) { + issues.add(`REMOVE requires null targetPath: ${legacyPath}`); + } + if (entry.targetPath === null && entry.decision !== "REMOVE") { issues.add(`${entry.decision} requires targetPath: ${legacyPath}`); }