301 lines
8.6 KiB
TypeScript
301 lines
8.6 KiB
TypeScript
import { revalidatePath } from "next/cache";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { execute, registrations, staff } = vi.hoisted(() => ({
|
|
execute: vi.fn(),
|
|
registrations: [] as Array<{ permission: string | readonly string[] }>,
|
|
staff: { id: 42, rank: 4, username: "moderator" },
|
|
}));
|
|
|
|
function wrapper(
|
|
options: {
|
|
permission: string | readonly string[];
|
|
schema?: {
|
|
safeParse(
|
|
value: unknown,
|
|
): { success: true; data: unknown } | { success: false; error: unknown };
|
|
};
|
|
},
|
|
handler: (context: {
|
|
data: unknown;
|
|
session: { user: typeof staff };
|
|
}) => unknown,
|
|
) {
|
|
registrations.push(options);
|
|
return async (data: unknown) => {
|
|
const parsed = options.schema?.safeParse(data);
|
|
if (parsed && !parsed.success) {
|
|
return { ok: false, error: "Validation failed" };
|
|
}
|
|
try {
|
|
return await handler({
|
|
data: parsed?.data ?? data,
|
|
session: { user: staff },
|
|
});
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : "Internal server error",
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
vi.mock("@/features/housekeeping/domains/people/services/mutations", () => ({
|
|
createPeopleMutationInvocation: vi.fn((actor, correlationId) => ({
|
|
expectedActorId: actor.id,
|
|
correlationId,
|
|
legacy: true,
|
|
})),
|
|
peopleMutationService: { execute },
|
|
}));
|
|
vi.mock("@/lib/safe-action", () => ({ adminAction: wrapper }));
|
|
vi.mock("@/lib/safe-action-shared", () => ({
|
|
ActionError: class ActionError extends Error {},
|
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
|
}));
|
|
vi.mock("@/lib/foundation/action", () => ({
|
|
adminAction: wrapper,
|
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
|
}));
|
|
vi.mock("@/lib/permissions", () => ({
|
|
PERMS: {
|
|
TICKETS_EDIT: "admin.tickets.edit",
|
|
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
|
USERS_BAN: "admin.users.ban",
|
|
MODERATION_EDIT: "admin.moderation.edit",
|
|
MOD_CFH_EDIT: "mod.cfh.edit",
|
|
MOD_ACTIONS: "mod.actions",
|
|
},
|
|
}));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
|
|
import {
|
|
closeHelpCenterTicket,
|
|
liftBanFromHelpTicket,
|
|
reopenHelpCenterTicket,
|
|
replyHelpCenterTicket,
|
|
} from "./admin-help-tickets";
|
|
import {
|
|
assignCfhTicket,
|
|
broadcastAlert,
|
|
closeCfhTicket,
|
|
quickAlert,
|
|
quickKick,
|
|
quickMute,
|
|
quickRoomKick,
|
|
quickUnmute,
|
|
updateCfhState,
|
|
} from "./moderation";
|
|
import {
|
|
createTemplate,
|
|
deleteTemplate,
|
|
updateTemplate,
|
|
} from "./ticket-templates";
|
|
import {
|
|
adminReplyTicket,
|
|
assignTicket,
|
|
updateTicketPriority,
|
|
updateTicketStatus,
|
|
} from "./tickets";
|
|
|
|
type LegacyAction = (input: unknown) => Promise<unknown>;
|
|
const call = (action: unknown, input: unknown) =>
|
|
(action as LegacyAction)(input);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
execute.mockImplementation(async (invocation, operation) => ({
|
|
ok: true,
|
|
data: {
|
|
before: null,
|
|
after: operation === "ticket-template.change" ? { id: "88" } : {},
|
|
output:
|
|
operation === "help-ticket.unban"
|
|
? { removed: 2, userId: 7 }
|
|
: undefined,
|
|
},
|
|
correlationId: invocation.correlationId,
|
|
}));
|
|
});
|
|
|
|
describe("legacy People support and moderation wrappers", () => {
|
|
it("keeps mid-rank ACL alternatives without an admin.dashboard dependency", () => {
|
|
const permissions = registrations.flatMap((entry) =>
|
|
typeof entry.permission === "string"
|
|
? [entry.permission]
|
|
: entry.permission,
|
|
);
|
|
expect(permissions).toEqual(
|
|
expect.arrayContaining([
|
|
"admin.tickets.edit",
|
|
"mod.tickets.edit",
|
|
"admin.moderation.edit",
|
|
"mod.cfh.edit",
|
|
"mod.actions",
|
|
]),
|
|
);
|
|
expect(permissions).not.toContain("admin.dashboard");
|
|
});
|
|
|
|
it("delegates tickets and templates with their established result shapes", async () => {
|
|
await expect(
|
|
call(adminReplyTicket, { ticketId: 7, message: "Handled" }),
|
|
).resolves.toEqual({ ok: true, data: {} });
|
|
await call(assignTicket, { ticketId: 7, assigneeId: 42 });
|
|
await call(updateTicketStatus, { ticketId: 7, status: "closed" });
|
|
await call(updateTicketPriority, { ticketId: 7, priority: "urgent" });
|
|
await expect(
|
|
call(createTemplate, {
|
|
title: "Greeting",
|
|
content: "Hello",
|
|
category: "general",
|
|
sortOrder: 0,
|
|
}),
|
|
).resolves.toEqual({ ok: true, data: { id: 88 } });
|
|
await expect(
|
|
call(updateTemplate, { id: 88, title: "Updated" }),
|
|
).resolves.toEqual({ ok: true, data: { id: 88 } });
|
|
await expect(call(deleteTemplate, { id: 88 })).resolves.toEqual({
|
|
ok: true,
|
|
data: {},
|
|
});
|
|
|
|
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
|
|
"ticket.reply",
|
|
"ticket.assign",
|
|
"ticket.status",
|
|
"ticket.priority",
|
|
"ticket-template.change",
|
|
"ticket-template.change",
|
|
"ticket-template.change",
|
|
]);
|
|
});
|
|
|
|
it("preserves BIGINT help-ticket IDs, outputs, and every legacy refresh", async () => {
|
|
const ticketId = 9_007_199_254_740_993n;
|
|
await call(replyHelpCenterTicket, { ticketId, content: " Handled " });
|
|
await call(closeHelpCenterTicket, { ticketId });
|
|
await call(reopenHelpCenterTicket, { ticketId });
|
|
await expect(call(liftBanFromHelpTicket, { ticketId })).resolves.toEqual({
|
|
ok: true,
|
|
data: { removed: 2, userId: 7 },
|
|
});
|
|
|
|
expect(execute.mock.calls.map((entry) => entry[2])).toEqual([
|
|
{ ticketId: "9007199254740993", content: "Handled" },
|
|
{ ticketId: "9007199254740993", status: "close" },
|
|
{ ticketId: "9007199254740993", status: "reopen" },
|
|
{ ticketId: "9007199254740993" },
|
|
]);
|
|
expect(revalidatePath).toHaveBeenCalledWith(
|
|
"/ase/people/support/help-tickets",
|
|
);
|
|
expect(revalidatePath).toHaveBeenCalledWith(
|
|
"/ase/people/support/help-tickets/9007199254740993",
|
|
);
|
|
expect(revalidatePath).toHaveBeenCalledWith(
|
|
"/help/tickets/9007199254740993",
|
|
);
|
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/moderation/bans");
|
|
expect(revalidatePath).toHaveBeenCalledWith("/ase/people/users/7");
|
|
});
|
|
|
|
it("delegates every CFH and moderation transport action", async () => {
|
|
await call(assignCfhTicket, { ticketId: 9 });
|
|
await call(updateCfhState, { ticketId: 9, state: 3 });
|
|
await call(closeCfhTicket, { ticketId: 9 });
|
|
await call(quickKick, { userId: 7 });
|
|
await call(quickMute, { userId: 7, duration: 60 });
|
|
await call(quickUnmute, { userId: 7 });
|
|
await call(quickAlert, { userId: 7, message: "Stop" });
|
|
await call(quickRoomKick, { roomId: 12 });
|
|
await call(broadcastAlert, { message: "Notice", type: "staff" });
|
|
|
|
expect(execute.mock.calls.map((entry) => entry[1])).toEqual([
|
|
"cfh.resolve",
|
|
"cfh.resolve",
|
|
"cfh.resolve",
|
|
"moderation.action",
|
|
"moderation.action",
|
|
"moderation.action",
|
|
"moderation.action",
|
|
"moderation.action",
|
|
"moderation.action",
|
|
]);
|
|
expect(execute.mock.calls.map((entry) => entry[0].expectedActorId)).toEqual(
|
|
Array(9).fill(42),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["kick", quickKick, { userId: 7 }],
|
|
["mute", quickMute, { userId: 7, duration: 60 }],
|
|
["unmute", quickUnmute, { userId: 7 }],
|
|
["alert", quickAlert, { userId: 7, message: "Stop" }],
|
|
["room kick", quickRoomKick, { roomId: 12 }],
|
|
["broadcast", broadcastAlert, { message: "Notice", type: "staff" }],
|
|
] as const)(
|
|
"maps a non-ok %s service result to the historical legacy failure boundary",
|
|
async (_label, action, input) => {
|
|
execute.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: {
|
|
code: "DEPENDENCY_UNAVAILABLE",
|
|
messageKey: "errors.housekeeping.dependencyUnavailable",
|
|
},
|
|
correlationId: "quick-action-failure",
|
|
});
|
|
await expect(call(action, input)).resolves.toEqual({
|
|
ok: false,
|
|
error: "Could not execute moderation action",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("maps a thrown moderation service failure instead of reporting success", async () => {
|
|
execute.mockRejectedValueOnce(new Error("RCON unavailable"));
|
|
await expect(call(quickKick, { userId: 7 })).resolves.toEqual({
|
|
ok: false,
|
|
error: "RCON unavailable",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
9_007_199_254_740_992,
|
|
"01",
|
|
"0",
|
|
0,
|
|
-1,
|
|
"18446744073709551616",
|
|
] as const)(
|
|
"rejects noncanonical help-ticket identifier %s at every legacy action schema",
|
|
async (ticketId) => {
|
|
for (const [action, input] of [
|
|
[replyHelpCenterTicket, { ticketId, content: "Handled" }],
|
|
[closeHelpCenterTicket, { ticketId }],
|
|
[reopenHelpCenterTicket, { ticketId }],
|
|
[liftBanFromHelpTicket, { ticketId }],
|
|
] as const) {
|
|
await expect(call(action, input)).resolves.toEqual({
|
|
ok: false,
|
|
error: "Validation failed",
|
|
});
|
|
}
|
|
expect(execute).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("keeps close-CFH missing rows as a successful legacy no-op", async () => {
|
|
execute.mockResolvedValueOnce({
|
|
ok: false,
|
|
error: { code: "NOT_FOUND", messageKey: "errors.housekeeping.notFound" },
|
|
correlationId: "missing-cfh",
|
|
});
|
|
await expect(call(closeCfhTicket, { ticketId: 404 })).resolves.toEqual({
|
|
ok: true,
|
|
data: {},
|
|
});
|
|
});
|
|
});
|