1032 lines
27 KiB
TypeScript
1032 lines
27 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
|
import { PEOPLE_ROUTES } from "../routes";
|
|
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("includes watched state and permission-rank data required by detail and edit", async () => {
|
|
const query = createPeopleUsersQuery({
|
|
loadUsers: async () => ({ rows: [], total: 0 }),
|
|
loadUser: async () => ({
|
|
...rawUser(9, "Nine"),
|
|
motto: "",
|
|
look: "hd-180-1",
|
|
accountCreated: 1_700_000_000,
|
|
lastLogin: 1_700_000_100,
|
|
}),
|
|
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
|
loadSanctions: async () => [],
|
|
loadWatchedUserIds: async () => new Set([9]),
|
|
loadPermissionRanks: async () => [
|
|
{ id: 1, name: "User" },
|
|
{ id: 5, name: "Moderator" },
|
|
],
|
|
} as never);
|
|
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
|
routeId: "people.users.detail",
|
|
id: 9,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
user: {
|
|
watched: true,
|
|
permission: {
|
|
rankName: "Moderator",
|
|
ranks: [
|
|
{ id: 1, name: "User" },
|
|
{ id: 5, name: "Moderator" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves a DB-paged username window without locale re-sort or offset slicing", async () => {
|
|
const query = createPeopleUsersQuery({
|
|
loadUsers: async () => ({
|
|
rows: [rawUser(10, "user10"), rawUser(2, "user2")],
|
|
total: 20,
|
|
}),
|
|
loadUser: async () => null,
|
|
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
|
loadSanctions: async () => [],
|
|
loadWatchedUserIds: async () => new Set(),
|
|
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
|
});
|
|
|
|
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
|
routeId: "people.users.list",
|
|
list: { sort: "username", order: "asc", offset: 2, pageSize: 2 },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
kind: "users",
|
|
page: { items: [{ id: 10 }, { id: 2 }], offset: 2, pageSize: 2 },
|
|
},
|
|
});
|
|
});
|
|
|
|
it("normalizes list input and projects PII for admin users view", async () => {
|
|
const loadUsers = vi.fn(async () => ({
|
|
rows: [rawUser(1, "Alice"), rawUser(2, "alice"), rawUser(3, "alice")],
|
|
total: 3,
|
|
}));
|
|
const query = createPeopleUsersQuery({
|
|
loadUsers,
|
|
loadUser: async () => null,
|
|
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
|
loadSanctions: async () => [],
|
|
loadWatchedUserIds: async () => new Set(),
|
|
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
|
});
|
|
|
|
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 () => [],
|
|
loadWatchedUserIds: async () => new Set(),
|
|
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
|
});
|
|
|
|
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 () => [],
|
|
loadWatchedUserIds: async () => new Set(),
|
|
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
|
});
|
|
|
|
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,
|
|
},
|
|
],
|
|
loadWatchedUserIds: async () => new Set(),
|
|
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
|
});
|
|
|
|
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("accepts only the guild user and room zero sentinels", async () => {
|
|
const guild = {
|
|
id: 12,
|
|
name: "Unassigned guild",
|
|
description: "",
|
|
userId: 0,
|
|
ownerUsername: null,
|
|
roomId: 0,
|
|
memberCount: 0,
|
|
threadCount: 0,
|
|
createdAt: null,
|
|
href: "/ase/people/community/guilds/12" as const,
|
|
members: [],
|
|
};
|
|
const query = createPeopleCommunityQuery({
|
|
loadOnline: async () => ({ rows: [], total: 0 }),
|
|
loadGuilds: async () => ({ rows: [], total: 0 }),
|
|
loadGuild: async () => guild as never,
|
|
});
|
|
|
|
expect(
|
|
await query.run(context([PERMS.USERS_VIEW]), {
|
|
routeId: "people.community.guild-detail",
|
|
id: 12,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
data: { guild: { id: 12, userId: 0, roomId: 0 } },
|
|
});
|
|
|
|
const invalidQuery = createPeopleCommunityQuery({
|
|
loadOnline: async () => ({ rows: [], total: 0 }),
|
|
loadGuilds: async () => ({ rows: [], total: 0 }),
|
|
loadGuild: async () => ({ ...guild, userId: -1 }) as never,
|
|
});
|
|
expect(
|
|
await invalidQuery.run(context([PERMS.USERS_VIEW]), {
|
|
routeId: "people.community.guild-detail",
|
|
id: 12,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it("fails closed when a community adapter returns a corrupt entity id", async () => {
|
|
const query = createPeopleCommunityQuery({
|
|
loadOnline: async () => ({ rows: [], total: 0 }),
|
|
loadGuilds: async () => ({ rows: [], total: 0 }),
|
|
loadGuild: async () =>
|
|
({
|
|
id: 0,
|
|
name: "Corrupt",
|
|
description: "",
|
|
userId: -1,
|
|
ownerUsername: null,
|
|
memberCount: 0,
|
|
createdAt: null,
|
|
href: "/ase/people/community/guilds/0",
|
|
roomId: 0,
|
|
threadCount: 0,
|
|
members: [],
|
|
}) as never,
|
|
});
|
|
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
|
routeId: "people.community.guild-detail",
|
|
id: 1,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
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",
|
|
userId: 4,
|
|
ownerUsername: "Owner",
|
|
roomId: 7,
|
|
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",
|
|
userId: 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("routes the ticket desk to its CMS-only context loader", async () => {
|
|
const loadTickets = vi.fn(async () => ({
|
|
rows: [
|
|
{
|
|
source: "help" as const,
|
|
id: 12,
|
|
subject: "Help row",
|
|
status: "open",
|
|
priority: "normal",
|
|
creatorId: 7,
|
|
creatorUsername: "Seven",
|
|
updatedAt: null,
|
|
href: "/ase/people/support/help-tickets/12" as const,
|
|
},
|
|
],
|
|
total: 1,
|
|
}));
|
|
const loadTicketDesk = vi.fn(async () => ({
|
|
rows: [
|
|
{
|
|
source: "cms" as const,
|
|
id: 91,
|
|
subject: "Urgent CMS ticket",
|
|
status: "open",
|
|
priority: "urgent",
|
|
creatorId: 7,
|
|
creatorUsername: "Seven",
|
|
updatedAt: null,
|
|
href: "/ase/people/support/tickets/91" as const,
|
|
category: "safety",
|
|
assigneeId: 8,
|
|
messageCount: 4,
|
|
},
|
|
{
|
|
source: "cms" as const,
|
|
id: 92,
|
|
subject: "High-priority CMS ticket",
|
|
status: "open",
|
|
priority: "high",
|
|
creatorId: 9,
|
|
creatorUsername: "Nine",
|
|
updatedAt: null,
|
|
href: "/ase/people/support/tickets/92" as const,
|
|
category: "account",
|
|
assigneeId: null,
|
|
messageCount: 2,
|
|
},
|
|
],
|
|
total: 2,
|
|
}));
|
|
const query = createPeopleSupportQuery({
|
|
loadQueue: async () => ({
|
|
tickets: 1,
|
|
helpTickets: 1,
|
|
cfh: 0,
|
|
activeBans: 0,
|
|
}),
|
|
loadTickets,
|
|
loadTicketDesk,
|
|
loadTicket: async () => null,
|
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTicket: async () => null,
|
|
loadSupportStaff: async () => [],
|
|
loadActiveBan: async () => null,
|
|
} as never);
|
|
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
|
routeId: "people.support.ticket-desk",
|
|
list: {},
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
page: {
|
|
items: [
|
|
{
|
|
id: 91,
|
|
priority: "urgent",
|
|
category: "safety",
|
|
assigneeId: 8,
|
|
messageCount: 4,
|
|
},
|
|
{
|
|
id: 92,
|
|
priority: "high",
|
|
category: "account",
|
|
assigneeId: null,
|
|
messageCount: 2,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
expect(loadTicketDesk).toHaveBeenCalledOnce();
|
|
expect(loadTickets).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hydrates desk/detail support context and the help-ticket active ban", async () => {
|
|
const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 };
|
|
const staff = [
|
|
{
|
|
id: 8,
|
|
username: "Helper",
|
|
rank: 5,
|
|
href: "/ase/people/users/8" as const,
|
|
},
|
|
];
|
|
const activeBan = {
|
|
id: 31,
|
|
userId: 7,
|
|
username: "Seven",
|
|
staffId: 8,
|
|
staffUsername: "Helper",
|
|
type: "account",
|
|
reason: "permanent",
|
|
createdAt: "2026-08-01T00:00:00.000Z",
|
|
expiresAt: 0 as const,
|
|
active: true,
|
|
};
|
|
const query = createPeopleSupportQuery({
|
|
loadQueue: async () => queue,
|
|
loadTickets: async () => ({ rows: [], total: 0 }),
|
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
|
loadTicket: async () => ({
|
|
id: 11,
|
|
subject: "CMS ticket",
|
|
status: "open",
|
|
priority: "normal",
|
|
creatorId: 7,
|
|
creatorUsername: "Seven",
|
|
updatedAt: "2026-08-20T00:00:00.000Z",
|
|
replyCount: 0,
|
|
href: "/ase/people/support/tickets/11",
|
|
category: "general",
|
|
assigneeId: null,
|
|
messages: [],
|
|
}),
|
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTicket: async () => ({
|
|
id: 12,
|
|
title: "Help ticket",
|
|
open: true,
|
|
userId: 7,
|
|
username: "Seven",
|
|
updatedAt: "2026-08-20T00:00:00.000Z",
|
|
href: "/ase/people/support/help-tickets/12",
|
|
categoryId: 2,
|
|
categoryName: "Help",
|
|
content: "Body",
|
|
replies: [],
|
|
}),
|
|
loadSupportStaff: async () => staff,
|
|
loadActiveBan: async () => activeBan,
|
|
} as never);
|
|
|
|
const desk = await query.run(context([PERMS.TICKETS_VIEW]), {
|
|
routeId: "people.support.ticket-desk",
|
|
list: {},
|
|
});
|
|
const detail = await query.run(context([PERMS.TICKETS_VIEW]), {
|
|
routeId: "people.support.ticket-detail",
|
|
id: 11,
|
|
});
|
|
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
|
|
routeId: "people.support.help-ticket-detail",
|
|
id: 12,
|
|
});
|
|
expect(desk).toMatchObject({
|
|
ok: true,
|
|
data: { kind: "ticket-desk", queue, staff },
|
|
});
|
|
expect(detail).toMatchObject({
|
|
ok: true,
|
|
data: { ticket: { queue, staff } },
|
|
});
|
|
expect(help).toMatchObject({
|
|
ok: true,
|
|
data: { ticket: { queue, staff, activeBan } },
|
|
});
|
|
});
|
|
|
|
it("fails closed when support returns a corrupt detail shape", async () => {
|
|
const query = createPeopleSupportQuery({
|
|
loadQueue: async () => ({
|
|
tickets: 0,
|
|
helpTickets: 0,
|
|
cfh: 0,
|
|
activeBans: 0,
|
|
}),
|
|
loadTickets: async () => ({ rows: [], total: 0 }),
|
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
|
loadTicket: async () =>
|
|
({ id: 0, href: "/ase/people/support/tickets/0" }) as never,
|
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTicket: async () => null,
|
|
loadSupportStaff: async () => [],
|
|
loadActiveBan: async () => null,
|
|
});
|
|
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
|
routeId: "people.support.ticket-detail",
|
|
id: 1,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["status", [7, 3]],
|
|
["updatedAt", [8, 4]],
|
|
] as const)(
|
|
"preserves the DB-paged ticket window for %s ordering",
|
|
async (sort, ids) => {
|
|
const rows = ids.map((id, index) => ({
|
|
source: "cms" as const,
|
|
id,
|
|
subject: `Ticket ${id}`,
|
|
status: index === 0 ? "open" : "pending",
|
|
priority: "normal",
|
|
creatorId: 1,
|
|
creatorUsername: "Reporter",
|
|
updatedAt: `2026-08-${20 - index}T00:00:00.000Z`,
|
|
href: `/ase/people/support/tickets/${id}` as const,
|
|
}));
|
|
const query = createPeopleSupportQuery({
|
|
loadQueue: async () => ({
|
|
tickets: 0,
|
|
helpTickets: 0,
|
|
cfh: 0,
|
|
activeBans: 0,
|
|
}),
|
|
loadTickets: async () => ({ rows, total: 40 }),
|
|
loadTicketDesk: async () => ({
|
|
rows: rows.map((row) => ({
|
|
...row,
|
|
category: "general",
|
|
assigneeId: null,
|
|
messageCount: 0,
|
|
})),
|
|
total: 40,
|
|
}),
|
|
loadTicket: async () => null,
|
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTicket: async () => null,
|
|
loadSupportStaff: async () => [],
|
|
loadActiveBan: async () => null,
|
|
});
|
|
|
|
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
|
routeId: "people.support.ticket-desk",
|
|
list: { sort, order: "desc", offset: 10, pageSize: 2 },
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
data: { page: { items: ids.map((id) => ({ id })), offset: 10 } },
|
|
});
|
|
},
|
|
);
|
|
|
|
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 }),
|
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
|
loadTicket: async () => null,
|
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTicket: async () => null,
|
|
loadSupportStaff: async () => [],
|
|
loadActiveBan: 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 }),
|
|
loadTicketDesk: async () => ({ rows: [], total: 0 }),
|
|
loadTicket: async () => null,
|
|
loadTemplates: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
|
loadHelpTicket: async () => null,
|
|
loadSupportStaff: async () => [],
|
|
loadActiveBan: 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("accepts only the CFH participant and room zero sentinels", async () => {
|
|
const ticket = {
|
|
id: 41,
|
|
state: 0,
|
|
senderId: 0,
|
|
senderUsername: null,
|
|
reportedId: 0,
|
|
reportedUsername: null,
|
|
moderatorId: 0,
|
|
issue: "unassigned",
|
|
createdAt: null,
|
|
href: "/ase/people/moderation/cfh/41" as const,
|
|
roomId: 0,
|
|
activeBan: null,
|
|
};
|
|
const adapters = {
|
|
loadOverview: async () => ({
|
|
tickets: 0,
|
|
helpTickets: 0,
|
|
cfh: 0,
|
|
activeBans: 0,
|
|
staffOnline: 0,
|
|
recentActions: 0,
|
|
}),
|
|
loadCfh: async () => ({ rows: [], total: 0 }),
|
|
loadCfhDetail: async () => ticket,
|
|
loadBans: async () => ({ rows: [], total: 0 }),
|
|
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
|
loadVpnSettings: async () => [],
|
|
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
|
};
|
|
const query = createPeopleModerationQuery(adapters);
|
|
expect(
|
|
await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
|
routeId: "people.moderation.cfh-detail",
|
|
id: 41,
|
|
}),
|
|
).toMatchObject({
|
|
ok: true,
|
|
data: {
|
|
ticket: {
|
|
id: 41,
|
|
senderId: 0,
|
|
reportedId: 0,
|
|
moderatorId: 0,
|
|
roomId: 0,
|
|
},
|
|
},
|
|
});
|
|
|
|
const invalidQuery = createPeopleModerationQuery({
|
|
...adapters,
|
|
loadCfhDetail: async () => ({ ...ticket, reportedId: -1 }),
|
|
});
|
|
expect(
|
|
await invalidQuery.run(context([PERMS.MOD_CFH_VIEW]), {
|
|
routeId: "people.moderation.cfh-detail",
|
|
id: 41,
|
|
}),
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
it("declares exactly the same eleven-permission union as the moderation overview route", () => {
|
|
const query = createPeopleModerationQuery({
|
|
loadOverview: async () => ({
|
|
tickets: 0,
|
|
helpTickets: 0,
|
|
cfh: 0,
|
|
activeBans: 0,
|
|
staffOnline: 0,
|
|
recentActions: 0,
|
|
}),
|
|
loadCfh: async () => ({ rows: [], total: 0 }),
|
|
loadCfhDetail: async () => null,
|
|
loadBans: async () => ({ rows: [], total: 0 }),
|
|
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
|
loadVpnSettings: async () => [],
|
|
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
|
});
|
|
const route = PEOPLE_ROUTES.find(
|
|
(candidate) => candidate.id === "people.moderation.overview",
|
|
);
|
|
expect(route).toBeDefined();
|
|
expect(query.capability).toEqual(route?.capability);
|
|
expect(query.capability.slugs).toHaveLength(11);
|
|
});
|
|
|
|
it("fails closed when moderation returns a corrupt detail shape", async () => {
|
|
const query = createPeopleModerationQuery({
|
|
loadOverview: async () => ({
|
|
tickets: 0,
|
|
helpTickets: 0,
|
|
cfh: 0,
|
|
activeBans: 0,
|
|
staffOnline: 0,
|
|
recentActions: 0,
|
|
}),
|
|
loadCfh: async () => ({ rows: [], total: 0 }),
|
|
loadCfhDetail: async () =>
|
|
({ id: -1, href: "/ase/people/moderation/cfh/-1" }) as never,
|
|
loadBans: async () => ({ rows: [], total: 0 }),
|
|
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
|
loadVpnSettings: async () => [],
|
|
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
|
});
|
|
const result = await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
|
routeId: "people.moderation.cfh-detail",
|
|
id: 1,
|
|
});
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
|
});
|
|
});
|
|
|
|
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",
|
|
},
|
|
});
|
|
});
|
|
});
|