Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/people/pages/people-primary-pages.test.tsx
T

526 lines
14 KiB
TypeScript

import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { executeHousekeepingCommand } from "@/actions/housekeeping-command";
import { PERMS } from "@/lib/permission-slugs";
vi.mock("@/actions/housekeeping-command", () => ({
executeHousekeepingCommand: vi.fn(),
}));
import {
fail,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingResult,
ok,
} from "../../../foundation/contracts";
import { HOUSEKEEPING_MANIFESTS } from "../../../manifests";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../../../route-handlers";
import { peopleManifest } from "../manifest";
import type { PeopleCommunityQueryData } from "../queries/community";
import type { PeopleStaffQueryData } from "../queries/staff";
import type { PeopleUsersQueryData } from "../queries/users";
import {
PEOPLE_PRIMARY_ROUTE_HANDLERS,
PEOPLE_PRIMARY_ROUTE_IDS,
} from "../route-handlers";
import { PEOPLE_ROUTE_IDS } from "../routes";
import { PeopleCommunityPage } from "./community";
import { PeopleMultiAccountsPage } from "./multi-accounts";
import { parsePeopleListInput } from "./page-state";
import {
PeopleCommandResult,
submitPeopleCommandForm,
} from "./people-command-form";
import { PeopleStaffPage } from "./staff";
import { PeopleUserDetailPage } from "./user-detail";
import { PeopleUserEditPage } from "./user-edit";
import { PeopleUsersPage } from "./users";
const correlationId = "people-pages-test";
function capabilityContext(
granted: readonly string[],
): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 6 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
const readContext = capabilityContext([PERMS.USERS_VIEW]);
const modReadContext = capabilityContext([PERMS.MOD_USERS_VIEW]);
const user = {
id: 7,
username: "Alice",
rank: 2,
online: true,
mail: "[email protected]",
ipCurrent: "198.51.100.7",
bannedUntil: null,
href: "/ase/people/users/7" as const,
};
const detail = {
...user,
motto: "Ready",
look: "hd-180-1",
accountCreated: "2026-08-01T00:00:00.000Z",
lastLogin: "2026-08-28T00:00:00.000Z",
sanctions: [],
watched: false,
permission: { rankName: "Member", ranks: [{ id: 2, name: "Member" }] },
};
type PageCase = {
readonly name: string;
readonly render: (
result?: HousekeepingResult<unknown>,
partialDependencies?: readonly string[],
) => string;
readonly empty: unknown;
readonly ready: unknown;
};
const cases: readonly PageCase[] = [
{
name: "users",
render: (result) =>
renderToStaticMarkup(
<PeopleUsersPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
/>,
),
empty: {
kind: "users",
page: { items: [], total: 0, pageSize: 20, offset: 0 },
},
ready: {
kind: "users",
page: { items: [user], total: 1, pageSize: 20, offset: 0 },
},
},
{
name: "user-detail",
render: (result) =>
renderToStaticMarkup(
<PeopleUserDetailPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
/>,
),
empty: { kind: "user", user: { ...detail, sanctions: [] } },
ready: {
kind: "user",
user: {
...detail,
sanctions: [
{
id: 3,
kind: "account",
reason: "spam",
createdAt: null,
expiresAt: 0,
active: true,
},
],
},
},
},
{
name: "user-edit",
render: (result) =>
renderToStaticMarkup(
<PeopleUserEditPage
context={capabilityContext([PERMS.USERS_EDIT, PERMS.USERS_VIEW])}
result={result as HousekeepingResult<PeopleUsersQueryData>}
/>,
),
empty: fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId),
ready: { kind: "user", user: { ...detail, watched: true } },
},
{
name: "multi-accounts",
render: (result) =>
renderToStaticMarkup(
<PeopleMultiAccountsPage
context={readContext}
result={result as HousekeepingResult<PeopleUsersQueryData>}
/>,
),
empty: {
kind: "multi-accounts",
page: { items: [], total: 0, pageSize: 20, offset: 0 },
},
ready: {
kind: "multi-accounts",
page: {
items: [{ key: "198.51.100.7", accountCount: 2, accounts: [user] }],
total: 1,
pageSize: 20,
offset: 0,
},
},
},
{
name: "community",
render: (result) =>
renderToStaticMarkup(
<PeopleCommunityPage
context={readContext}
result={result as HousekeepingResult<PeopleCommunityQueryData>}
/>,
),
empty: {
kind: "guilds",
page: { items: [], total: 0, pageSize: 20, offset: 0 },
},
ready: {
kind: "guilds",
page: {
items: [
{
id: 9,
name: "Builders",
description: "Rooms",
userId: 7,
ownerUsername: "Alice",
roomId: 4,
memberCount: 2,
createdAt: null,
href: "/ase/people/community/guilds/9",
},
],
total: 1,
pageSize: 20,
offset: 0,
},
},
},
{
name: "staff",
render: (result) =>
renderToStaticMarkup(
<PeopleStaffPage
context={readContext}
result={result as HousekeepingResult<PeopleStaffQueryData>}
/>,
),
empty: {
kind: "applications",
page: { items: [], total: 0, pageSize: 20, offset: 0 },
},
ready: {
kind: "applications",
page: {
items: [
{
id: "5",
userId: 7,
username: "Alice",
rankId: 4,
content: "Experienced",
createdAt: null,
},
],
total: 1,
pageSize: 20,
offset: 0,
},
},
},
];
describe.each(cases)("People $name page", ({ render, empty, ready }) => {
it("renders loading, forbidden, dependency error, empty, and ready states", () => {
expect(render()).toContain('data-housekeeping-state="loading"');
expect(
render(fail("FORBIDDEN", "errors.housekeeping.forbidden", correlationId)),
).toContain('data-housekeeping-state="forbidden"');
expect(
render(
fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
),
),
).toContain('data-housekeeping-state="error"');
const emptyResult =
typeof empty === "object" && empty !== null && "ok" in empty
? (empty as HousekeepingResult<unknown>)
: ok(empty, correlationId);
expect(render(emptyResult)).toContain('data-housekeeping-state="empty"');
expect(render(ok(ready, correlationId))).toContain(
'data-housekeeping-state="ready"',
);
});
});
describe("People primary route registration", () => {
it("keeps the nine primary handlers and registers the complete People catalog", () => {
const expected = [
"people.users.list",
"people.users.edit",
"people.users.multi-accounts",
"people.users.detail",
"people.community.online",
"people.community.guilds",
"people.community.guild-detail",
"people.staff.applications",
"people.staff.teams",
];
expect(PEOPLE_PRIMARY_ROUTE_IDS).toEqual(expected);
expect(peopleManifest.routes.map((route) => route.id)).toEqual(
PEOPLE_ROUTE_IDS,
);
expect(
PEOPLE_PRIMARY_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expected);
expect(
HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId),
).toEqual(expect.arrayContaining(expected));
expect(
(
HOUSEKEEPING_MANIFESTS as readonly HousekeepingDomainManifest[]
).flatMap((manifest) => manifest.routes.map((route) => route.id)),
).toEqual(HOUSEKEEPING_ROUTE_HANDLERS.map((handler) => handler.routeId));
});
it("uses canonical People links and exposes PII and actions only with exact capabilities", () => {
const redacted = {
...detail,
mail: null,
ipCurrent: null,
};
const modHtml = renderToStaticMarkup(
<PeopleUserDetailPage
context={modReadContext}
result={ok({ kind: "user", user: redacted }, correlationId)}
/>,
);
expect(modHtml).not.toContain("[email protected]");
expect(modHtml).not.toContain("198.51.100.7");
expect(modHtml).not.toContain("people.user.update");
const editorHtml = renderToStaticMarkup(
<PeopleUserDetailPage
context={capabilityContext([PERMS.USERS_VIEW, PERMS.USERS_EDIT])}
result={ok({ kind: "user", user: detail }, correlationId)}
/>,
);
expect(editorHtml).toContain('href="/ase/people/users/7/edit"');
expect(editorHtml).toContain("<form");
expect(editorHtml).not.toContain("<span data-housekeeping-command");
expect(editorHtml).not.toContain("/admin");
const editHtml = renderToStaticMarkup(
<PeopleUserEditPage
context={capabilityContext([PERMS.USERS_VIEW, PERMS.USERS_EDIT])}
result={ok({ kind: "user", user: detail }, correlationId)}
/>,
);
expect(editHtml).toContain("people.user.update");
expect(editHtml).toContain("<form");
});
});
describe("People URL list input", () => {
it("bounds and normalizes search, page, pageSize, sort, and direction", () => {
expect(
parsePeopleListInput({
search: [" Alice ", "ignored"],
page: "999999999999",
pageSize: "5000",
sort: "username",
direction: "desc",
}),
).toEqual({
search: "Alice",
pageSize: 100,
offset: 100000,
sort: "username",
order: "desc",
});
});
});
describe("People actionable form contract", () => {
it("submits bounded command input and reason through the actual form action adapter", async () => {
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok({ before: null, after: { amount: 100 } }, "form-action"),
);
const formData = new FormData();
formData.set("amount", "999");
formData.set("reason", " Planned grant ");
const result = await submitPeopleCommandForm(
{
commandId: "people.user.send-currency",
input: { userId: 7 },
fields: [
{
name: "amount",
label: "Amount",
type: "number",
min: 1,
max: 100,
},
],
requiresReason: true,
includeReasonInInput: true,
},
null,
formData,
);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "people.user.send-currency",
input: { userId: 7, amount: 100, reason: "Planned grant" },
reason: "Planned grant",
});
expect(result).toMatchObject({ ok: true });
});
it("renders a generated reset credential once in the current accessible result", () => {
const password = "one-time-secret";
const html = renderToStaticMarkup(
<PeopleCommandResult
result={ok(
{
before: { passwordReset: false },
after: { passwordReset: true },
output: { newPassword: password },
},
"reset-result",
)}
/>,
);
expect(html).toContain('aria-label="Generated temporary password"');
expect(html.match(new RegExp(password, "gu"))).toHaveLength(1);
});
it("renders typed external sync debt with an explicit do-not-retry warning", () => {
const completion = {
status: "partial",
external: "failed",
audit: "persisted",
} as const;
const html = renderToStaticMarkup(
<PeopleCommandResult
result={ok(
{
before: { completed: 0, total: 2, failedIds: [] },
after: {
completed: 2,
total: 2,
failedIds: [],
externalSyncFailures: [
{
userId: 7,
reason:
"Database applied; emulator sync failed. Do not retry automatically.",
},
{
userId: "8",
reason: "unsafe payload from an untyped result",
},
],
debug: "sensitive internal payload",
},
completion,
},
"bulk-sync-debt",
completion,
)}
/>,
);
expect(html).toContain('role="alert"');
expect(html).toContain("Do not retry automatically.");
expect(html).toContain('aria-label="External synchronization debt"');
expect(html).toContain("User #7");
expect(html).toContain("Database applied; emulator sync failed.");
expect(html).not.toContain("unsafe payload from an untyped result");
expect(html).not.toContain("sensitive internal payload");
});
it("keeps form-returned external sync debt visible in the operator result surface", async () => {
const completion = {
status: "partial",
external: "failed",
audit: "persisted",
} as const;
vi.mocked(executeHousekeepingCommand).mockResolvedValue(
ok(
{
before: { completed: 0, total: 1, failedIds: [] },
after: {
completed: 1,
total: 1,
failedIds: [],
externalSyncFailures: [
{
userId: 23,
reason:
"Database applied; emulator sync failed. Do not retry automatically.",
},
],
},
completion,
},
"form-sync-debt",
completion,
),
);
const formData = new FormData();
formData.set("userIds", "23");
formData.set("amount", "100");
formData.set("type", "credits");
formData.set("reason", "Planned grant");
const result = await submitPeopleCommandForm(
{
commandId: "people.users.bulk-currency",
input: {},
fields: [
{ name: "userIds", label: "User IDs", type: "number-list" },
{ name: "amount", label: "Amount", type: "number", min: 1 },
{
name: "type",
label: "Currency",
type: "select",
options: [{ value: "credits", label: "Credits" }],
},
],
requiresReason: true,
},
null,
formData,
);
const html = renderToStaticMarkup(<PeopleCommandResult result={result} />);
expect(executeHousekeepingCommand).toHaveBeenCalledWith({
commandId: "people.users.bulk-currency",
input: { userIds: [23], amount: 100, type: "credits" },
reason: "Planned grant",
});
expect(html).toContain("Do not retry automatically.");
expect(html).toContain("User #23");
});
it("provides a real Next loading boundary", () => {
const source = readFileSync(
"src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
"utf8",
);
expect(source).toContain('data-housekeeping-state="loading"');
expect(source).toContain('state="loading"');
});
});