feat(housekeeping): model people workflows
This commit is contained in:
1 parent
c325c53774
commit
e3f8b51d31
13 files changed
+3736
No files matched your search
@@ -0,0 +1,492 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { createPeopleCommunityQuery } from "./community";
|
||||
import { createPeopleModerationQuery } from "./moderation";
|
||||
import { createPeopleStaffQuery } from "./staff";
|
||||
import { createPeopleSupportQuery } from "./support";
|
||||
import { createPeopleUsersQuery } from "./users";
|
||||
|
||||
function context(granted: readonly string[]): HousekeepingCapabilityContext {
|
||||
const permissions = new Set(granted);
|
||||
return {
|
||||
actor: { id: 42, username: "operator", rank: 99 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
}
|
||||
|
||||
const rawUser = (id: number, username: string) => ({
|
||||
id,
|
||||
username,
|
||||
rank: 5,
|
||||
online: "1",
|
||||
mail: `${username.toLowerCase()}@example.test`,
|
||||
ipCurrent: `203.0.113.${id}`,
|
||||
bannedUntil: null,
|
||||
});
|
||||
|
||||
describe("People users query", () => {
|
||||
it("normalizes list input, sorts stable ties, and projects PII for admin users view", async () => {
|
||||
const loadUsers = vi.fn(async () => ({
|
||||
rows: [rawUser(3, "alice"), rawUser(1, "Alice"), rawUser(2, "alice")],
|
||||
total: 3,
|
||||
}));
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers,
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.list",
|
||||
list: {
|
||||
pageSize: 5_000,
|
||||
offset: -9,
|
||||
search: ` ali\u0000${"x".repeat(200)} `,
|
||||
sort: "username",
|
||||
order: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(loadUsers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageSize: 100,
|
||||
offset: 0,
|
||||
search: `alix${"x".repeat(124)}`,
|
||||
sort: "username",
|
||||
}),
|
||||
{ includeMail: true, includeIp: true },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
kind: "users",
|
||||
page: {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
mail: "[email protected]",
|
||||
ipCurrent: "203.0.113.1",
|
||||
href: "/ase/people/users/1",
|
||||
},
|
||||
{ id: 2 },
|
||||
{ id: 3 },
|
||||
],
|
||||
total: 3,
|
||||
pageSize: 100,
|
||||
offset: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a base-only projection to mod users and fails closed for neither permission", async () => {
|
||||
const loadUsers = vi.fn(async () => ({
|
||||
rows: [rawUser(1, "Alice")],
|
||||
total: 1,
|
||||
}));
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers,
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
});
|
||||
|
||||
const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), {
|
||||
routeId: "people.users.list",
|
||||
list: {},
|
||||
});
|
||||
expect(loadUsers).toHaveBeenLastCalledWith(expect.anything(), {
|
||||
includeMail: false,
|
||||
includeIp: false,
|
||||
});
|
||||
expect(moderator).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
page: { items: [{ mail: null, ipCurrent: null }] },
|
||||
},
|
||||
});
|
||||
|
||||
loadUsers.mockClear();
|
||||
const forbidden = await query.run(context([]), {
|
||||
routeId: "people.users.list",
|
||||
list: {},
|
||||
});
|
||||
expect(forbidden).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FORBIDDEN" },
|
||||
});
|
||||
expect(loadUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps missing users, invalid ids, and adapter failures to stable errors", async () => {
|
||||
const loadUser = vi.fn(async () => null);
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers: async () => {
|
||||
throw new Error("count unavailable");
|
||||
},
|
||||
loadUser,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
});
|
||||
|
||||
const invalid = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.detail",
|
||||
id: -1,
|
||||
});
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "VALIDATION", fieldErrors: { id: ["invalid"] } },
|
||||
});
|
||||
expect(loadUser).not.toHaveBeenCalled();
|
||||
|
||||
const missing = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.detail",
|
||||
id: 404,
|
||||
});
|
||||
expect(missing).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "NOT_FOUND" },
|
||||
});
|
||||
|
||||
const unavailable = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.list",
|
||||
list: {},
|
||||
});
|
||||
expect(unavailable).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns JSON-safe user details with sanctions and canonical links", async () => {
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers: async () => ({ rows: [], total: 0 }),
|
||||
loadUser: async () => ({
|
||||
...rawUser(9, "Nine"),
|
||||
motto: "Hello",
|
||||
look: "hd-180-1",
|
||||
accountCreated: 1_700_000_000,
|
||||
lastLogin: new Date("2026-08-29T09:00:00.000Z"),
|
||||
}),
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [
|
||||
{
|
||||
id: 8,
|
||||
kind: "account",
|
||||
reason: "test",
|
||||
createdAt: "2026-08-20T00:00:00.000Z",
|
||||
expiresAt: null,
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.detail",
|
||||
id: 9,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
kind: "user",
|
||||
user: {
|
||||
id: 9,
|
||||
href: "/ase/people/users/9",
|
||||
accountCreated: "2023-11-14T22:13:20.000Z",
|
||||
lastLogin: "2026-08-29T09:00:00.000Z",
|
||||
sanctions: [{ id: 8 }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(() => JSON.stringify(result)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("People community and staff queries", () => {
|
||||
it("loads online, guild list, and guild detail workflows through narrow adapters", async () => {
|
||||
const loadOnline = vi.fn(async () => ({ rows: [], total: 0 }));
|
||||
const loadGuilds = vi.fn(async () => ({
|
||||
rows: [
|
||||
{
|
||||
id: 2,
|
||||
name: "Builders",
|
||||
description: "Build",
|
||||
ownerId: 4,
|
||||
ownerUsername: "Owner",
|
||||
memberCount: 3,
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
href: "/ase/people/community/guilds/2" as const,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}));
|
||||
const loadGuild = vi.fn(async (id: number) =>
|
||||
id === 2
|
||||
? {
|
||||
id: 2,
|
||||
name: "Builders",
|
||||
description: "Build",
|
||||
ownerId: 4,
|
||||
ownerUsername: "Owner",
|
||||
memberCount: 3,
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
href: "/ase/people/community/guilds/2" as const,
|
||||
roomId: 7,
|
||||
threadCount: 1,
|
||||
members: [],
|
||||
}
|
||||
: null,
|
||||
);
|
||||
const query = createPeopleCommunityQuery({
|
||||
loadOnline,
|
||||
loadGuilds,
|
||||
loadGuild,
|
||||
});
|
||||
|
||||
expect(
|
||||
await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.community.online",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { kind: "online" } });
|
||||
expect(
|
||||
await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.community.guilds",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
data: { kind: "guilds", page: { items: [{ id: 2 }] } },
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.community.guild-detail",
|
||||
id: 404,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
|
||||
});
|
||||
|
||||
it("loads applications, teams, and moderation-team data with serializable DTOs", async () => {
|
||||
const query = createPeopleStaffQuery({
|
||||
loadApplications: async () => ({
|
||||
rows: [
|
||||
{
|
||||
id: 11,
|
||||
userId: 5,
|
||||
username: "Applicant",
|
||||
rankId: 3,
|
||||
content: "Why me",
|
||||
createdAt: "2026-08-29T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
loadTeams: async () => ({ rows: [], total: 0 }),
|
||||
loadModerationTeam: async () => ({ rows: [], total: 0 }),
|
||||
});
|
||||
|
||||
const applications = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.staff.applications",
|
||||
list: {},
|
||||
});
|
||||
const teams = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.staff.teams",
|
||||
list: {},
|
||||
});
|
||||
const moderationTeam = await query.run(context([PERMS.MOD_TEAM_VIEW]), {
|
||||
routeId: "people.staff.moderation-team",
|
||||
list: {},
|
||||
});
|
||||
|
||||
expect(applications).toMatchObject({
|
||||
ok: true,
|
||||
data: { kind: "applications", page: { items: [{ id: 11 }] } },
|
||||
});
|
||||
expect(teams).toMatchObject({ ok: true, data: { kind: "teams" } });
|
||||
expect(moderationTeam).toMatchObject({
|
||||
ok: true,
|
||||
data: { kind: "moderation-team" },
|
||||
});
|
||||
expect(() => JSON.stringify(applications)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("People support query", () => {
|
||||
it("returns the four-source queue snapshot and fails closed when a count fails", async () => {
|
||||
const loadQueue = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
tickets: 2,
|
||||
helpTickets: 3,
|
||||
cfh: 4,
|
||||
activeBans: 5,
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("count failed"));
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue,
|
||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadTicket: async () => null,
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
});
|
||||
|
||||
expect(
|
||||
await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.queue",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
kind: "queue",
|
||||
queue: { tickets: 2, helpTickets: 3, cfh: 4, activeBans: 5 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.queue",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("covers ticket desk/templates/detail and help-ticket list/detail reads", async () => {
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
}),
|
||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadTicket: async () => null,
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
});
|
||||
|
||||
for (const routeId of [
|
||||
"people.support.tickets",
|
||||
"people.support.ticket-desk",
|
||||
"people.support.help-tickets",
|
||||
] as const) {
|
||||
expect(
|
||||
await query.run(context([PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW]), {
|
||||
routeId,
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
}
|
||||
expect(
|
||||
await query.run(context([PERMS.TICKETS_EDIT]), {
|
||||
routeId: "people.support.ticket-templates",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
|
||||
for (const input of [
|
||||
{ routeId: "people.support.ticket-detail" as const, id: 91 },
|
||||
{ routeId: "people.support.help-ticket-detail" as const, id: 92 },
|
||||
]) {
|
||||
expect(
|
||||
await query.run(context([PERMS.TICKETS_VIEW]), input),
|
||||
).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("People moderation query", () => {
|
||||
it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
tickets: 1,
|
||||
helpTickets: 2,
|
||||
cfh: 3,
|
||||
activeBans: 4,
|
||||
staffOnline: 5,
|
||||
recentActions: 6,
|
||||
}),
|
||||
loadCfh: async () => ({ rows: [], total: 0 }),
|
||||
loadCfhDetail: async () => null,
|
||||
loadBans: async () => ({ rows: [], total: 0 }),
|
||||
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
||||
loadVpnSettings: async () => [],
|
||||
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
||||
});
|
||||
|
||||
expect(
|
||||
await query.run(context([PERMS.MODERATION_VIEW]), {
|
||||
routeId: "people.moderation.overview",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
data: { kind: "overview", snapshot: { cfh: 3, activeBans: 4 } },
|
||||
});
|
||||
expect(
|
||||
await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
||||
routeId: "people.moderation.cfh",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { kind: "cfh" } });
|
||||
expect(
|
||||
await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
||||
routeId: "people.moderation.cfh-detail",
|
||||
id: 77,
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
|
||||
expect(
|
||||
await query.run(context([PERMS.MOD_BANS_VIEW]), {
|
||||
routeId: "people.moderation.bans",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { kind: "bans" } });
|
||||
for (const input of [
|
||||
{ routeId: "people.moderation.ip" as const },
|
||||
{ routeId: "people.moderation.vpn" as const },
|
||||
]) {
|
||||
expect(
|
||||
await query.run(context([PERMS.SETTINGS_VIEW]), input),
|
||||
).toMatchObject({ ok: true });
|
||||
}
|
||||
expect(
|
||||
await query.run(context([PERMS.WORDFILTER_VIEW]), {
|
||||
routeId: "people.moderation.word-filter",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({ ok: true, data: { kind: "word-filter" } });
|
||||
});
|
||||
|
||||
it("maps adapter exceptions to dependency unavailable without partial data", async () => {
|
||||
const down = async () => {
|
||||
throw new Error("database unavailable");
|
||||
};
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: down,
|
||||
loadCfh: down,
|
||||
loadCfhDetail: down,
|
||||
loadBans: down,
|
||||
loadIpRules: down,
|
||||
loadVpnSettings: down,
|
||||
loadWordFilter: down,
|
||||
});
|
||||
|
||||
expect(
|
||||
await query.run(context([PERMS.MOD_BANS_VIEW]), {
|
||||
routeId: "people.moderation.bans",
|
||||
list: {},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "DEPENDENCY_UNAVAILABLE",
|
||||
messageKey: "errors.housekeeping.dependencyUnavailable",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user