526 lines
14 KiB
TypeScript
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"');
|
|
});
|
|
});
|