Files
Ekklesia/surfaces/shell/tests/dashboard-routes.test.ts
T
ekklesiaandClaude Opus 5.5 89ef4b5ff6
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
feat(shell): Settings hierarchy, Discover, and /settings/<module-id>
The Settings foundation (ADR 0061):

- URL model. /settings is a real landing page. The platform sections
  are /settings/general, /branding, /displays, and /system. Module
  lifecycle is Discover: /settings/discover/browse, /installed, and
  /updates. /settings/discover is respelled in place to Browse's one
  canonical address. A module's settings are /settings/<module-id>,
  never nested under Discover. Deeper Settings addresses are
  reserved. "Modules" stays the user-facing word: "Discover modules",
  "Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
  names (branding, discover, displays, general, system) as a second
  documented group, so no module id can shadow a section; `modules` is
  an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
  into Platform and Modules (and Discover's three views) while in
  Settings, and the phone drawer has the same hierarchy. The rest of
  the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
  Core's evaluator, which destinations the viewer may see:
  - Displays: core.devices.manage
  - Discover: core.modules.lifecycle.manage
  - a module's settings: core.modules.configuration.manage, for each
    module that declares configuration (ADR 0029)
  - General, Branding, System: the platform owner, until each has its
    own permission
  These are presentation hints; every data route keeps its own
  enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
  module's settings say it is turned off; a section or module the
  person cannot see says so without detail; an unknown name is not
  found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
  enters Discover, opens the module's settings, uses the phone
  drawer, and returns to the Dashboard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 22:22:50 -05:00

173 lines
6.6 KiB
TypeScript

// @vitest-environment node
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
DASHBOARD_PLATFORM_PAGES,
dashboardPath,
parseDashboardPath,
DISCOVER_VIEWS,
SETTINGS_SECTIONS,
} from "../src/dashboard-routes.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("the Dashboard's own top-level pages", () => {
it("are exactly Settings and Account, frozen", () => {
expect(Object.isFrozen(DASHBOARD_PLATFORM_PAGES)).toBe(true);
expect(DASHBOARD_PLATFORM_PAGES).toEqual(["settings", "account"]);
});
});
describe("parseDashboardPath", () => {
it("makes the root the Overview", () => {
expect(parseDashboardPath("/")).toEqual({ kind: "overview" });
});
it("names a module page by its id at the top level, tolerating one trailing slash", () => {
expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" });
expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" });
});
it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => {
expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" });
});
it("never reads a platform page as a module page", () => {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
for (const pathname of ["/account", "/account/appearance"]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("treats every other shape as not found rather than guessing", () => {
for (const pathname of [
"",
"//",
"//signage",
"/signage//",
// A module's own sub-pages need a contract that does not exist yet (ADR 0057).
"/signage/timelines",
"/signage/timelines/abc",
"/%E0%A4%A",
// Deeper Settings pages are reserved for later (ADR 0061).
"/settings/displays/add",
"/settings/signage/timelines",
"/settings//signage",
"signage",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
});
describe("Settings routes (ADR 0061)", () => {
it("names exactly the platform's own Settings sections, frozen", () => {
expect(Object.isFrozen(SETTINGS_SECTIONS)).toBe(true);
expect(SETTINGS_SECTIONS).toEqual(["general", "branding", "displays", "discover", "system"]);
expect(Object.isFrozen(DISCOVER_VIEWS)).toBe(true);
expect(DISCOVER_VIEWS).toEqual(["browse", "installed", "updates"]);
});
it("has a landing page of its own, tolerating one trailing slash", () => {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
expect(parseDashboardPath("/settings/")).toEqual({ kind: "settings" });
});
it("reads each platform section, and nothing else, as a platform section", () => {
for (const section of ["general", "branding", "displays", "system"] as const) {
expect(parseDashboardPath(`/settings/${section}`), section).toEqual({
kind: "settings-section",
section,
});
}
});
it("reads Discover's three views, entering Discover at Browse, its one canonical address", () => {
for (const view of DISCOVER_VIEWS) {
expect(parseDashboardPath(`/settings/discover/${view}`), view).toEqual({
kind: "discover",
view,
});
}
// `/settings/discover` means Browse, and the shell respells it `/settings/discover/browse` in
// place, so there is one meaningful Browse address rather than two.
expect(parseDashboardPath("/settings/discover")).toEqual({ kind: "discover", view: "browse" });
expect(dashboardPath({ kind: "discover", view: "browse" })).toBe("/settings/discover/browse");
for (const pathname of [
"/settings/discover/catalog",
"/settings/discover/browse/signage",
"/settings/discover//",
]) {
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
}
});
it("reads every other name as a module's settings — never under /settings/modules/", () => {
expect(parseDashboardPath("/settings/signage")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
expect(parseDashboardPath("/settings/sign%61ge/")).toEqual({
kind: "module-settings",
moduleId: "signage",
});
expect(parseDashboardPath("/settings/modules/signage")).toEqual({ kind: "not-found" });
// `modules` is no longer a platform section, so it is simply a module id like any other.
expect(parseDashboardPath("/settings/modules")).toEqual({
kind: "module-settings",
moduleId: "modules",
});
});
it("spells each Settings destination canonically and round-trips it", () => {
for (const [route, path] of [
[{ kind: "settings" }, "/settings"],
[{ kind: "settings-section", section: "displays" }, "/settings/displays"],
[{ kind: "discover", view: "installed" }, "/settings/discover/installed"],
[{ kind: "module-settings", moduleId: "signage" }, "/settings/signage"],
] as const) {
expect(dashboardPath(route)).toBe(path);
expect(parseDashboardPath(path)).toEqual(route);
}
});
});
describe("dashboardPath", () => {
it("spells each route canonically, with no implementation prefix", () => {
expect(dashboardPath({ kind: "overview" })).toBe("/");
expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage");
});
it("encodes a module id so it can never escape its own segment", () => {
expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb");
expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({
kind: "module",
moduleId: "a/../b",
});
});
it("round-trips through the parser", () => {
for (const route of [
{ kind: "overview" } as const,
{ kind: "module", moduleId: "example" } as const,
]) {
expect(parseDashboardPath(dashboardPath(route))).toEqual(route);
}
});
});
describe("the route locator is published for the API that serves the page", () => {
it("is a narrow package subpath, not the shell root barrel", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { exports?: Record<string, unknown> };
expect(manifest.exports?.["./dashboard-routes"]).toEqual({
import: "./dist/dashboard-routes.js",
types: "./dist/dashboard-routes.d.ts",
});
});
});