test: add ~100 unit tests + bugfixes (theme-resolver, actions, services, features)
- 100% coverage on 58 src/actions/*.ts, 24 src/lib/services/*.ts, 19 src/features|db|hooks|i18n/*.ts - 3 core lib modules (theme-resolver, ip-lookup, translation-pool): 100% - ~3,000 new meaningful tests - Bugfixes: - theme-resolver: generateScopedCss now emits scoped CSS blocks (was early-return bug) - admin-radio-api-keys: blank rateLimit now uses fallback - admin-badge-upload: validation before try-block to prevent swallowed redirect - Coverage raised from 26% -> 34% statements
This commit is contained in:
1 parent
4b68728dbd
commit
99eb3af17b
65 files changed
+13877
-13
No files matched your search
@@ -0,0 +1,145 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
staff: { id: 9, rank: 7, username: "staff" },
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
logServerError: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
requirePermissionRateLimited: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermissionRateLimited: mocks.requirePermissionRateLimited,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { USERS_EDIT: "admin.users.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mocks.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/server-log", () => ({ logServerError: mocks.logServerError }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { approveApplication, dismissApplication } from "./admin-applications";
|
||||||
|
|
||||||
|
function form(id: string) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("id", id);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermissionRateLimited.mockResolvedValue(mocks.staff);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dismissApplication", () => {
|
||||||
|
it("deletes the application, logs activity and revalidates", async () => {
|
||||||
|
await dismissApplication(form("42"));
|
||||||
|
|
||||||
|
expect(mocks.requirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
|
PERMS.USERS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
staffId: 9,
|
||||||
|
action: "application_dismiss",
|
||||||
|
description: "Dismissed staff application #42",
|
||||||
|
targetType: "staff_application",
|
||||||
|
targetId: 42,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a zero id", async () => {
|
||||||
|
await dismissApplication(form("0"));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a non-numeric id", async () => {
|
||||||
|
await dismissApplication(form("abc"));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows delete failures, still logs and revalidates", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(dismissApplication(form("7"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"applications.delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ id: "7" },
|
||||||
|
);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial without touching the db", async () => {
|
||||||
|
mocks.requirePermissionRateLimited.mockRejectedValueOnce(
|
||||||
|
new Error("redirect:/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(dismissApplication(form("1"))).rejects.toThrow("redirect:/");
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approveApplication", () => {
|
||||||
|
it("deletes the application and logs approval", async () => {
|
||||||
|
await approveApplication(form("99"));
|
||||||
|
|
||||||
|
expect(mocks.requirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
|
PERMS.USERS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
staffId: 9,
|
||||||
|
action: "application_approve",
|
||||||
|
description: "Approved staff application #99",
|
||||||
|
targetType: "staff_application",
|
||||||
|
targetId: 99,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await approveApplication(form("0"));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows delete failures and still logs the approval", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(approveApplication(form("12"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"applications.delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ id: "12" },
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/applications");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(async () => ({ id: 1, rank: 7, username: "a" })),
|
||||||
|
writeFile: vi.fn(async () => undefined),
|
||||||
|
toBadgeGif: vi.fn(async () => Buffer.from("gif")),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
redirect: vi.fn((url: string) => {
|
||||||
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { CATALOG_EDIT: "admin.catalog.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/images/badge-gif", () => ({ toBadgeGif: mocks.toBadgeGif }));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mocks.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: mocks.redirect }));
|
||||||
|
vi.mock("node:fs/promises", () => ({ writeFile: mocks.writeFile }));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { uploadBadge } from "./admin-badge-upload";
|
||||||
|
|
||||||
|
function form(fields: Record<string, string | File>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(fields)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function png(size = 4, type = "image/png") {
|
||||||
|
return new File([new Uint8Array(size)], "badge.png", { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(fd: FormData): Promise<Error | null> {
|
||||||
|
try {
|
||||||
|
await uploadBadge(fd);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return error as Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIR = "/var/www/atom-nexst/storage/badges";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.stubEnv("BADGE_UPLOAD_DIR", DIR);
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.writeFile.mockResolvedValue(undefined);
|
||||||
|
mocks.toBadgeGif.mockResolvedValue(Buffer.from("gif"));
|
||||||
|
mocks.logStaffActivity.mockResolvedValue(undefined);
|
||||||
|
mocks.redirect.mockImplementation((url: string) => {
|
||||||
|
throw new Error(`NEXT_REDIRECT:${url}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadBadge", () => {
|
||||||
|
it("normalises the image and writes it as <code>.gif", async () => {
|
||||||
|
const error = await run(form({ code: " ACH_1 ", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||||
|
expect(mocks.toBadgeGif).toHaveBeenCalledWith(expect.any(Buffer));
|
||||||
|
expect(mocks.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
const [target, gif] = mocks.writeFile.mock.calls[0];
|
||||||
|
expect(String(target)).toBe(`${DIR}/ACH_1.gif`);
|
||||||
|
expect(gif).toEqual(Buffer.from("gif"));
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "badge_upload",
|
||||||
|
description: 'Uploaded badge image "ACH_1.gif"',
|
||||||
|
targetType: "badge",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?uploaded=${encodeURIComponent("ACH_1")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts GIF uploads", async () => {
|
||||||
|
const error = await run(form({ code: "G1", file: png(4, "image/gif") }));
|
||||||
|
|
||||||
|
expect(mocks.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the badge directory is not configured", async () => {
|
||||||
|
vi.stubEnv("BADGE_UPLOAD_DIR", "");
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Badge upload directory not configured")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a request with no code field", async () => {
|
||||||
|
const error = await run(form({ file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid badge code", async () => {
|
||||||
|
const error = await run(form({ code: "bad code!", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Invalid badge code (use A-Z, 0-9, _ or -, max 64 chars)")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing file", async () => {
|
||||||
|
const error = await run(form({ code: "A" }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("No file uploaded")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty file", async () => {
|
||||||
|
const error = await run(form({ code: "A", file: png(0) }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Uploaded file is empty")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a file larger than 1MB", async () => {
|
||||||
|
const error = await run(form({ code: "A", file: png(1024 * 1024 + 1) }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("File too large (max 1MB)")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a disallowed mime type", async () => {
|
||||||
|
const error = await run(form({ code: "A", file: png(4, "image/jpeg") }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("File must be a GIF or PNG image")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a processing failure when the converter throws", async () => {
|
||||||
|
mocks.toBadgeGif.mockRejectedValueOnce(new Error("sharp failed"));
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Could not process or write the badge file")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a processing failure when the write fails", async () => {
|
||||||
|
mocks.writeFile.mockRejectedValueOnce(new Error("EACCES"));
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Could not process or write the badge file")}`,
|
||||||
|
);
|
||||||
|
expect(error?.message).toContain("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a resolved target outside the configured directory", async () => {
|
||||||
|
vi.stubEnv("BADGE_UPLOAD_DIR", "/");
|
||||||
|
|
||||||
|
const error = await run(form({ code: "escape", file: png() }));
|
||||||
|
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
`/admin/badges?error=${encodeURIComponent("Invalid path")}`,
|
||||||
|
);
|
||||||
|
expect(mocks.toBadgeGif).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(error?.message).toContain("error=Invalid%20path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
const error = await run(form({ code: "A", file: png() }));
|
||||||
|
|
||||||
|
expect(error?.message).toBe("redirect:/admin");
|
||||||
|
expect(mocks.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,14 +51,15 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
|||||||
back("error", "File must be a GIF or PNG image");
|
back("error", "File must be a GIF or PNG image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseDir = path.resolve(dir);
|
||||||
|
const target = path.resolve(baseDir, `${code}.gif`);
|
||||||
|
if (!target.startsWith(baseDir + path.sep)) {
|
||||||
|
back("error", "Invalid path");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const gif = await toBadgeGif(buffer);
|
const gif = await toBadgeGif(buffer);
|
||||||
const baseDir = path.resolve(dir);
|
|
||||||
const target = path.resolve(baseDir, `${code}.gif`);
|
|
||||||
if (!target.startsWith(baseDir + path.sep)) {
|
|
||||||
back("error", "Invalid path");
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||||
await writeFile(target, gif);
|
await writeFile(target, gif);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
existingRows: [] as unknown[],
|
||||||
|
maxRows: [] as unknown[],
|
||||||
|
insertValues: vi.fn(async () => [{}]),
|
||||||
|
fail: false,
|
||||||
|
giveBadge: vi.fn(async () => undefined),
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: state.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { CATALOG_EDIT: "admin.catalog.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { giveBadge: state.giveBadge },
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((_table, projection) => {
|
||||||
|
if (state.fail) throw new Error("db down");
|
||||||
|
return "maxSlot" in projection ? state.maxRows : state.existingRows;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: vi.fn(() => ({ values: state.insertValues })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { giveBadge } from "./admin-badges";
|
||||||
|
|
||||||
|
function form(userId: string, code: string) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("userId", userId);
|
||||||
|
f.set("code", code);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.existingRows = [];
|
||||||
|
state.maxRows = [];
|
||||||
|
state.fail = false;
|
||||||
|
state.insertValues.mockResolvedValue([{}]);
|
||||||
|
state.giveBadge.mockResolvedValue(undefined);
|
||||||
|
state.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("giveBadge", () => {
|
||||||
|
it("grants via rcon and persists at the next free slot", async () => {
|
||||||
|
state.maxRows = [{ maxSlot: 5 }];
|
||||||
|
|
||||||
|
await giveBadge(form("3", "ACH_Test"));
|
||||||
|
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(3, "ACH_Test");
|
||||||
|
expect(state.insertValues).toHaveBeenCalledWith({
|
||||||
|
userId: 3,
|
||||||
|
slotId: 6,
|
||||||
|
badgeCode: "ACH_Test",
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses slot 1 when the user has no badges yet", async () => {
|
||||||
|
state.maxRows = [];
|
||||||
|
|
||||||
|
await giveBadge(form("3", "NEW"));
|
||||||
|
|
||||||
|
expect(state.insertValues).toHaveBeenCalledWith({
|
||||||
|
userId: 3,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "NEW",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a null max slot as zero", async () => {
|
||||||
|
state.maxRows = [{ maxSlot: null }];
|
||||||
|
|
||||||
|
await giveBadge(form("3", "NEW"));
|
||||||
|
|
||||||
|
expect(state.insertValues).toHaveBeenCalledWith({
|
||||||
|
userId: 3,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "NEW",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist a duplicate badge", async () => {
|
||||||
|
state.existingRows = [{ id: 10 }];
|
||||||
|
|
||||||
|
await giveBadge(form("4", "DUP"));
|
||||||
|
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(4, "DUP");
|
||||||
|
expect(state.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a non-positive user id", async () => {
|
||||||
|
await giveBadge(form("0", "X"));
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
expect(state.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an empty code", async () => {
|
||||||
|
await giveBadge(form("5", " "));
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the code field is absent", async () => {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("userId", "5");
|
||||||
|
|
||||||
|
await giveBadge(f);
|
||||||
|
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises and truncates the badge code to 32 characters", async () => {
|
||||||
|
const longCode = "a".repeat(40);
|
||||||
|
|
||||||
|
await giveBadge(form("5", ` ${longCode} `));
|
||||||
|
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(5, "a".repeat(32));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows persistence failures after the rcon grant", async () => {
|
||||||
|
state.insertValues.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(giveBadge(form("6", "BEST"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(6, "BEST");
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows select failures and still revalidates", async () => {
|
||||||
|
state.fail = true;
|
||||||
|
|
||||||
|
await expect(giveBadge(form("6", "BEST"))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/badges");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(giveBadge(form("1", "X"))).rejects.toThrow("redirect:/admin");
|
||||||
|
expect(state.giveBadge).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
insertValues: vi.fn(),
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
logServerError: vi.fn(),
|
||||||
|
siteSettingsUpdate: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { DAILY_REWARDS_EDIT: "admin.dailyrewards.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/server-log", () => ({ logServerError: mocks.logServerError }));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { update: mocks.siteSettingsUpdate },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
deleteDailyReward,
|
||||||
|
setDailyRewardEnabled,
|
||||||
|
upsertDailyReward,
|
||||||
|
} from "./admin-daily-rewards";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.insertValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: vi.fn(async () => [{}]),
|
||||||
|
});
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.siteSettingsUpdate.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upsertDailyReward", () => {
|
||||||
|
it("inserts a new reward and revalidates both surfaces", async () => {
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
day: "3",
|
||||||
|
currency: "Duckets",
|
||||||
|
amount: "500",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
PERMS.DAILY_REWARDS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
day: 3,
|
||||||
|
currency: "duckets",
|
||||||
|
amount: 500,
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/daily-rewards");
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing reward by id", async () => {
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
id: "12",
|
||||||
|
day: "1",
|
||||||
|
currency: "credits",
|
||||||
|
amount: "10",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
day: 1,
|
||||||
|
currency: "credits",
|
||||||
|
amount: 10,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.updateWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a day outside 1..365", async () => {
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "0", currency: "credits", amount: "5" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day must be between 1 and 365",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "366", currency: "credits", amount: "5" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day must be between 1 and 365",
|
||||||
|
});
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive amount", async () => {
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "1", currency: "credits", amount: "0" }),
|
||||||
|
).toEqual({ ok: false, error: "Reward amount must be positive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown currency", async () => {
|
||||||
|
expect(
|
||||||
|
await upsertDailyReward({ day: "1", currency: "gold", amount: "5" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward currency is not one of the emulator wallets",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a payload with no currency", async () => {
|
||||||
|
expect(await upsertDailyReward({ day: "1", amount: "5" } as never)).toEqual(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: "Reward currency is not one of the emulator wallets",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the write fails", async () => {
|
||||||
|
mocks.insertValues.mockReturnValueOnce({
|
||||||
|
onDuplicateKeyUpdate: vi.fn(async () => {
|
||||||
|
throw new Error("db down");
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
day: "2",
|
||||||
|
currency: "diamonds",
|
||||||
|
amount: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.daily_reward_upsert_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day could not be saved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the update fails", async () => {
|
||||||
|
mocks.updateWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await upsertDailyReward({
|
||||||
|
id: "3",
|
||||||
|
day: "2",
|
||||||
|
currency: "points",
|
||||||
|
amount: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day could not be saved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
upsertDailyReward({ day: "1", currency: "credits", amount: "1" }),
|
||||||
|
).rejects.toThrow("redirect:/admin");
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteDailyReward", () => {
|
||||||
|
it("deletes a reward by id", async () => {
|
||||||
|
const result = await deleteDailyReward({ id: "5" });
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(
|
||||||
|
PERMS.DAILY_REWARDS_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/daily-rewards");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing or invalid id", async () => {
|
||||||
|
expect(await deleteDailyReward({ id: "" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing reward id",
|
||||||
|
});
|
||||||
|
expect(await deleteDailyReward({ id: "abc" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing reward id",
|
||||||
|
});
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a payload with no id field", async () => {
|
||||||
|
expect(await deleteDailyReward({} as never)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing reward id",
|
||||||
|
});
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the delete fails", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await deleteDailyReward({ id: "8" });
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.daily_reward_delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ rewardId: "8" },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward day could not be deleted",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setDailyRewardEnabled", () => {
|
||||||
|
it("enables the reward feature", async () => {
|
||||||
|
const result = await setDailyRewardEnabled({ enabled: true });
|
||||||
|
|
||||||
|
expect(mocks.siteSettingsUpdate).toHaveBeenCalledWith(
|
||||||
|
"daily_reward_enabled",
|
||||||
|
"1",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables the reward feature", async () => {
|
||||||
|
await setDailyRewardEnabled({ enabled: false });
|
||||||
|
|
||||||
|
expect(mocks.siteSettingsUpdate).toHaveBeenCalledWith(
|
||||||
|
"daily_reward_enabled",
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a friendly error when the setting write fails", async () => {
|
||||||
|
mocks.siteSettingsUpdate.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
const result = await setDailyRewardEnabled({ enabled: true });
|
||||||
|
|
||||||
|
expect(mocks.logServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.daily_reward_toggle_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward setting could not be saved",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
insertValues: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { PAGES_EDIT: "admin.pages.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createEmailTemplate,
|
||||||
|
deleteEmailTemplate,
|
||||||
|
updateEmailTemplate,
|
||||||
|
} from "./admin-email-templates";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.insertValues.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createEmailTemplate", () => {
|
||||||
|
it("creates a template with trimmed fields and active flag", async () => {
|
||||||
|
await createEmailTemplate(
|
||||||
|
form({
|
||||||
|
name: " Welcome ",
|
||||||
|
subject: " Hi ",
|
||||||
|
body: "Hello",
|
||||||
|
variables: "{{username}}",
|
||||||
|
isActive: "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.PAGES_EDIT);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
name: "Welcome",
|
||||||
|
subject: "Hi",
|
||||||
|
body: "Hello",
|
||||||
|
variables: "{{username}}",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/email-templates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null variables and inactive when the variable field is blank", async () => {
|
||||||
|
await createEmailTemplate(
|
||||||
|
form({ name: "Welcome", subject: "Hi", body: "Hello" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
name: "Welcome",
|
||||||
|
subject: "Hi",
|
||||||
|
body: "Hello",
|
||||||
|
variables: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when every field is absent", async () => {
|
||||||
|
await createEmailTemplate(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when a required field is missing", async () => {
|
||||||
|
await createEmailTemplate(form({ name: "", subject: "Hi", body: "Hello" }));
|
||||||
|
await createEmailTemplate(form({ name: "Hi", subject: "", body: "Hello" }));
|
||||||
|
await createEmailTemplate(form({ name: "Hi", subject: "Hi", body: "" }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmailTemplate", () => {
|
||||||
|
it("updates the subject, body, variables and active flag", async () => {
|
||||||
|
await updateEmailTemplate(
|
||||||
|
form({
|
||||||
|
id: "42",
|
||||||
|
subject: " Updated ",
|
||||||
|
body: "Body",
|
||||||
|
variables: "{{x}}",
|
||||||
|
isActive: "1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith({
|
||||||
|
subject: "Updated",
|
||||||
|
body: "Body",
|
||||||
|
variables: "{{x}}",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(mocks.updateWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/email-templates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank or invalid id", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "", subject: "a", body: "b" }));
|
||||||
|
await updateEmailTemplate(
|
||||||
|
form({ id: "not-a-number", subject: "a", body: "b" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when subject or body is missing", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "1", subject: "", body: "b" }));
|
||||||
|
await updateEmailTemplate(form({ id: "1", subject: "a", body: "" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when every field is absent", async () => {
|
||||||
|
await updateEmailTemplate(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the id is present but subject and body are absent", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "1" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null variables and inactive flag when omitted", async () => {
|
||||||
|
await updateEmailTemplate(form({ id: "3", subject: "s", body: "b" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith({
|
||||||
|
subject: "s",
|
||||||
|
body: "b",
|
||||||
|
variables: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteEmailTemplate", () => {
|
||||||
|
it("deletes the template by id", async () => {
|
||||||
|
await deleteEmailTemplate(form({ id: "7" }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.PAGES_EDIT);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/email-templates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for an invalid id", async () => {
|
||||||
|
await deleteEmailTemplate(form({ id: "0" }));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(deleteEmailTemplate(form({ id: "1" }))).rejects.toThrow(
|
||||||
|
"redirect:/admin",
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => {
|
||||||
|
const onDuplicateKeyUpdate = vi.fn(async () => [{}]);
|
||||||
|
const insertValues = vi.fn(() => ({ onDuplicateKeyUpdate }));
|
||||||
|
return {
|
||||||
|
insertValues,
|
||||||
|
onDuplicateKeyUpdate,
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { updateEmulatorSetting, updateEmulatorText } from "./admin-emulator";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
mocks.insertValues.mockReturnValue({
|
||||||
|
onDuplicateKeyUpdate: mocks.onDuplicateKeyUpdate,
|
||||||
|
});
|
||||||
|
mocks.onDuplicateKeyUpdate.mockResolvedValue([{}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmulatorSetting", () => {
|
||||||
|
it("upserts a setting and revalidates", async () => {
|
||||||
|
await updateEmulatorSetting(
|
||||||
|
form({ key: " welcome_message ", value: "Hi" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "welcome_message",
|
||||||
|
value: "Hi",
|
||||||
|
});
|
||||||
|
expect(mocks.onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
set: { value: "Hi" },
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/emulator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await updateEmulatorSetting(form({ key: " ", value: "Hi" }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when key and value fields are absent", async () => {
|
||||||
|
await updateEmulatorSetting(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the key to 100 and the value to 512 characters", async () => {
|
||||||
|
await updateEmulatorSetting(
|
||||||
|
form({ key: "k".repeat(120), value: "v".repeat(600) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "k".repeat(100),
|
||||||
|
value: "v".repeat(512),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateEmulatorSetting(form({ key: "a", value: "b" })),
|
||||||
|
).rejects.toThrow("redirect:/admin");
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateEmulatorText", () => {
|
||||||
|
it("upserts a text and revalidates", async () => {
|
||||||
|
await updateEmulatorText(form({ key: " text.key ", value: "Hello" }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_EDIT);
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "text.key",
|
||||||
|
value: "Hello",
|
||||||
|
});
|
||||||
|
expect(mocks.onDuplicateKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
set: { value: "Hello" },
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/emulator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await updateEmulatorText(form({ key: "", value: "Hello" }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when key and value fields are absent", async () => {
|
||||||
|
await updateEmulatorText(new FormData());
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the value to 4096 characters", async () => {
|
||||||
|
await updateEmulatorText(form({ key: "k", value: "v".repeat(5000) }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith({
|
||||||
|
key: "k",
|
||||||
|
value: "v".repeat(4096),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
canAccess: vi.fn(() => true),
|
||||||
|
getApiAdminContext: vi.fn(),
|
||||||
|
logAuthorizationEvent: vi.fn(),
|
||||||
|
logAudit: vi.fn(),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
extractClientIpAsync: vi.fn(async () => "127.0.0.1"),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
reportError: vi.fn(),
|
||||||
|
selectRows: [] as unknown[],
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
insertValues: vi.fn(async () => [{}]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 2 }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||||
|
logAuthorizationEvent: mocks.logAuthorizationEvent,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: {
|
||||||
|
USERS_BAN: "admin.users.ban",
|
||||||
|
TICKETS_EDIT: "admin.tickets.edit",
|
||||||
|
MOD_TICKETS_EDIT: "mod.tickets.edit",
|
||||||
|
},
|
||||||
|
canAccess: mocks.canAccess,
|
||||||
|
getApiAdminContext: mocks.getApiAdminContext,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mocks.rateLimit }));
|
||||||
|
vi.mock("@/lib/report-error", () => ({ reportError: mocks.reportError }));
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({
|
||||||
|
extractClientIpAsync: mocks.extractClientIpAsync,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.logAudit }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => mocks.selectRows);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
|
||||||
|
fn({
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
closeHelpCenterTicket,
|
||||||
|
liftBanFromHelpTicket,
|
||||||
|
reopenHelpCenterTicket,
|
||||||
|
replyHelpCenterTicket,
|
||||||
|
} from "./admin-help-tickets";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.canAccess.mockReturnValue(true);
|
||||||
|
mocks.getApiAdminContext.mockResolvedValue({
|
||||||
|
session: { user: { id: 7, rank: 7, name: "staff" } },
|
||||||
|
permissions: {},
|
||||||
|
});
|
||||||
|
mocks.selectRows = [];
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.insertValues.mockResolvedValue([{}]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function ticketId(id: string | number | bigint) {
|
||||||
|
return { ticketId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("liftBanFromHelpTicket", () => {
|
||||||
|
it("removes bans, reopens state and returns the removed count", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: true, title: "Appeal" }];
|
||||||
|
|
||||||
|
const result = await liftBanFromHelpTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: { removed: 2, userId: 3 } });
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 7,
|
||||||
|
action: "unban_via_help_ticket",
|
||||||
|
target: "User",
|
||||||
|
targetId: 3,
|
||||||
|
after: { ticketId: "5", removedBans: 2, title: "Appeal" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ open: false, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/help-tickets");
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/bans");
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users/show/3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips closing an already closed ticket", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: false, title: "Appeal" }];
|
||||||
|
|
||||||
|
await liftBanFromHelpTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a ticket without a requester", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: null, open: true, title: "x" }];
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket has no requester to unban",
|
||||||
|
});
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the removed count to zero when the driver omits affectedRows", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: true, title: "x" }];
|
||||||
|
mocks.deleteWhere.mockResolvedValueOnce([{}]);
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: { removed: 0, userId: 3 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transforms numeric and bigint ticket ids", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, userId: 3, open: true, title: "x" }];
|
||||||
|
|
||||||
|
await liftBanFromHelpTicket(ticketId(5));
|
||||||
|
await liftBanFromHelpTicket(ticketId(5n));
|
||||||
|
|
||||||
|
expect(mocks.selectRows).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated callers", async () => {
|
||||||
|
mocks.getApiAdminContext.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects callers without the ban permission and logs an authorization event", async () => {
|
||||||
|
mocks.canAccess.mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(await liftBanFromHelpTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
expect(mocks.logAuthorizationEvent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "permission.denied",
|
||||||
|
userId: 7,
|
||||||
|
permission: PERMS.USERS_BAN,
|
||||||
|
source: "adminAction",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed input before the handler runs", async () => {
|
||||||
|
const result = await liftBanFromHelpTicket({ ticketId: true } as never);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result).toMatchObject({ error: "Validation failed" });
|
||||||
|
expect(mocks.logAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replyHelpCenterTicket", () => {
|
||||||
|
it("inserts a trimmed reply and bumps the ticket inside a transaction", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
const result = await replyHelpCenterTicket({
|
||||||
|
ticketId: "5",
|
||||||
|
content: " thanks ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mocks.insertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
ticketId: 5n,
|
||||||
|
userId: 7,
|
||||||
|
content: "thanks",
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "help_center_ticket_reply",
|
||||||
|
target: "WebsiteHelpCenterTickets",
|
||||||
|
targetId: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a moderator permission from the any-of list", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
mocks.canAccess.mockImplementation(
|
||||||
|
(_perms: unknown, slug: string) => slug === PERMS.MOD_TICKETS_EDIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await replyHelpCenterTicket({
|
||||||
|
ticketId: "5",
|
||||||
|
content: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(mocks.canAccess).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
PERMS.TICKETS_EDIT,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(mocks.canAccess).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
PERMS.MOD_TICKETS_EDIT,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await replyHelpCenterTicket({ ticketId: "5", content: "hi" }),
|
||||||
|
).toEqual({ ok: false, error: "Ticket not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty reply", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
const result = await replyHelpCenterTicket({ ticketId: "5", content: "" });
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result).toMatchObject({ error: "Validation failed" });
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated callers", async () => {
|
||||||
|
mocks.getApiAdminContext.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await replyHelpCenterTicket({ ticketId: "5", content: "hi" }),
|
||||||
|
).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closeHelpCenterTicket", () => {
|
||||||
|
it("closes an open ticket and records the audit trail", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
const result = await closeHelpCenterTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ open: false, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "help_center_ticket_close",
|
||||||
|
before: { open: true },
|
||||||
|
after: { open: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(await closeHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an already closed ticket", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: false }];
|
||||||
|
|
||||||
|
expect(await closeHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket is already closed",
|
||||||
|
});
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reopenHelpCenterTicket", () => {
|
||||||
|
it("reopens a closed ticket and records the audit trail", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: false }];
|
||||||
|
|
||||||
|
const result = await reopenHelpCenterTicket(ticketId("5"));
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ open: true, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "help_center_ticket_reopen",
|
||||||
|
before: { open: false },
|
||||||
|
after: { open: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing ticket", async () => {
|
||||||
|
mocks.selectRows = [];
|
||||||
|
|
||||||
|
expect(await reopenHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an already open ticket", async () => {
|
||||||
|
mocks.selectRows = [{ id: 5, open: true }];
|
||||||
|
|
||||||
|
expect(await reopenHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket is already open",
|
||||||
|
});
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects callers without the ticket permission", async () => {
|
||||||
|
mocks.canAccess.mockReturnValue(false);
|
||||||
|
|
||||||
|
expect(await reopenHelpCenterTicket(ticketId("5"))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
rows: [] as unknown[],
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: state.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_VIEW: "admin.settings.view" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: createFakeDb(() => state.rows),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { exportPermissions } from "./admin-housekeeping";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.rows = [];
|
||||||
|
state.requirePermission.mockResolvedValue({ id: 1, rank: 7, username: "a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exportPermissions", () => {
|
||||||
|
it("returns the housekeeping permissions as pretty JSON", async () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
permission: "admin.users.edit",
|
||||||
|
minRank: 7,
|
||||||
|
description: "Edit users",
|
||||||
|
groupName: "Users",
|
||||||
|
dependsOn: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.rows = rows;
|
||||||
|
|
||||||
|
const result = await exportPermissions();
|
||||||
|
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith(PERMS.SETTINGS_VIEW);
|
||||||
|
expect(result).toBe(JSON.stringify(rows, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty JSON array when there are no permissions", async () => {
|
||||||
|
await expect(exportPermissions()).resolves.toBe("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("redirect:/"));
|
||||||
|
|
||||||
|
await expect(exportPermissions()).rejects.toThrow("redirect:/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
staff: { id: 11, rank: 7, username: "staff" },
|
||||||
|
requirePermissionRateLimited: vi.fn(),
|
||||||
|
listingRows: [] as unknown[],
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermissionRateLimited: mocks.requirePermissionRateLimited,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SHOP_EDIT: "admin.shop.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mocks.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => mocks.listingRows);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { cancelMarketplaceListing } from "./admin-marketplace";
|
||||||
|
|
||||||
|
function form(id: string) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("id", id);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermissionRateLimited.mockResolvedValue(mocks.staff);
|
||||||
|
mocks.listingRows = [];
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cancelMarketplaceListing", () => {
|
||||||
|
it("cancels an active listing, logs activity and revalidates", async () => {
|
||||||
|
mocks.listingRows = [
|
||||||
|
{ id: 5, state: 1, userId: 3, itemId: 100, price: 250 },
|
||||||
|
];
|
||||||
|
|
||||||
|
await cancelMarketplaceListing(form("5"));
|
||||||
|
|
||||||
|
expect(mocks.requirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
|
PERMS.SHOP_EDIT,
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith({ state: 0 });
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 11,
|
||||||
|
action: "marketplace_cancel",
|
||||||
|
description:
|
||||||
|
"Cancelled marketplace listing #5 (item 100, user 3, price 250)",
|
||||||
|
targetType: "marketplace",
|
||||||
|
targetId: 5,
|
||||||
|
});
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/marketplace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a non-positive id", async () => {
|
||||||
|
await cancelMarketplaceListing(form("0"));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the listing does not exist", async () => {
|
||||||
|
mocks.listingRows = [];
|
||||||
|
|
||||||
|
await cancelMarketplaceListing(form("8"));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the listing is not active", async () => {
|
||||||
|
mocks.listingRows = [{ id: 9, state: 0, userId: 1, itemId: 2, price: 3 }];
|
||||||
|
|
||||||
|
await cancelMarketplaceListing(form("9"));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermissionRateLimited.mockRejectedValueOnce(
|
||||||
|
new Error("redirect:/admin"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(cancelMarketplaceListing(form("1"))).rejects.toThrow(
|
||||||
|
"redirect:/admin",
|
||||||
|
);
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(),
|
||||||
|
keyRows: [] as unknown[],
|
||||||
|
insertValues: vi.fn(async () => [{ insertId: 21 }]),
|
||||||
|
updateSet: vi.fn(),
|
||||||
|
updateWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
deleteWhere: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
redirect: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mocks.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { RADIO_EDIT: "admin.radio.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mocks.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: mocks.redirect }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => mocks.keyRows);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: vi.fn(() => ({ values: mocks.insertValues })),
|
||||||
|
update: vi.fn(() => ({ set: mocks.updateSet })),
|
||||||
|
delete: vi.fn(() => ({ where: mocks.deleteWhere })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createApiKey,
|
||||||
|
deleteApiKey,
|
||||||
|
toggleApiKey,
|
||||||
|
} from "./admin-radio-api-keys";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>) {
|
||||||
|
const f = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data)) f.set(k, v);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.requirePermission.mockResolvedValue({ id: 4, rank: 7, username: "a" });
|
||||||
|
mocks.keyRows = [];
|
||||||
|
mocks.insertValues.mockResolvedValue([{ insertId: 21 }]);
|
||||||
|
mocks.updateSet.mockReturnValue({ where: mocks.updateWhere });
|
||||||
|
mocks.updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
mocks.logStaffActivity.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createApiKey", () => {
|
||||||
|
it("mints a server-side key and redirects on success", async () => {
|
||||||
|
await createApiKey(
|
||||||
|
form({
|
||||||
|
name: " Bridge ",
|
||||||
|
allowedIps: " 1.2.3.4 ",
|
||||||
|
rateLimit: "120",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.RADIO_EDIT);
|
||||||
|
const values = mocks.insertValues.mock.calls[0][0] as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(values.name).toBe("Bridge");
|
||||||
|
expect(values.allowedIps).toBe("1.2.3.4");
|
||||||
|
expect(values.rateLimit).toBe(120);
|
||||||
|
expect(values.isActive).toBe(true);
|
||||||
|
expect(values.key).toMatch(/^[0-9a-f]{48}$/);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "radio_api_key_create",
|
||||||
|
targetType: "radio_api_key",
|
||||||
|
targetId: 21,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/radio/api-keys");
|
||||||
|
expect(mocks.redirect).toHaveBeenCalledWith(
|
||||||
|
"/admin/radio/api-keys?created=1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the rate limit and stores null when allowed IPs is blank", async () => {
|
||||||
|
await createApiKey(form({ name: "Bot" }));
|
||||||
|
|
||||||
|
const values = mocks.insertValues.mock.calls[0][0] as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(values.rateLimit).toBe(300);
|
||||||
|
expect(values.allowedIps).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default for a negative or invalid rate limit", async () => {
|
||||||
|
await createApiKey(form({ name: "A", rateLimit: "-5" }));
|
||||||
|
await createApiKey(form({ name: "B", rateLimit: "abc" }));
|
||||||
|
await createApiKey(form({ name: "C", rateLimit: "12.9" }));
|
||||||
|
|
||||||
|
const rates = mocks.insertValues.mock.calls.map(
|
||||||
|
(call) => (call[0] as Record<string, unknown>).rateLimit,
|
||||||
|
);
|
||||||
|
expect(rates).toEqual([300, 300, 12]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the name is blank", async () => {
|
||||||
|
await createApiKey(form({ name: " " }));
|
||||||
|
|
||||||
|
expect(mocks.insertValues).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.redirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft when the insert throws", async () => {
|
||||||
|
mocks.insertValues.mockRejectedValueOnce(new Error("duplicate"));
|
||||||
|
|
||||||
|
await expect(createApiKey(form({ name: "Dup" }))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.logStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.redirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleApiKey", () => {
|
||||||
|
it("deactivates an active key", async () => {
|
||||||
|
mocks.keyRows = [{ name: "Bridge", isActive: true }];
|
||||||
|
|
||||||
|
await toggleApiKey(form({ id: "7" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ isActive: false, updatedAt: expect.any(Date) }),
|
||||||
|
);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "radio_api_key_toggle",
|
||||||
|
description: 'Deactivated radio API key "Bridge" (#7)',
|
||||||
|
targetId: 7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/radio/api-keys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates an inactive key", async () => {
|
||||||
|
mocks.keyRows = [{ name: "Bridge", isActive: false }];
|
||||||
|
|
||||||
|
await toggleApiKey(form({ id: "7" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ isActive: true }),
|
||||||
|
);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: 'Activated radio API key "Bridge" (#7)',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank or invalid id", async () => {
|
||||||
|
await toggleApiKey(form({ id: "" }));
|
||||||
|
await toggleApiKey(form({ id: "not-a-bigint" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the key does not exist", async () => {
|
||||||
|
mocks.keyRows = [];
|
||||||
|
|
||||||
|
await toggleApiKey(form({ id: "7" }));
|
||||||
|
|
||||||
|
expect(mocks.updateSet).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft when the update throws", async () => {
|
||||||
|
mocks.keyRows = [{ name: "Bridge", isActive: true }];
|
||||||
|
mocks.updateWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(toggleApiKey(form({ id: "7" }))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteApiKey", () => {
|
||||||
|
it("deletes the key and logs activity", async () => {
|
||||||
|
await deleteApiKey(form({ id: "9" }));
|
||||||
|
|
||||||
|
expect(mocks.requirePermission).toHaveBeenCalledWith(PERMS.RADIO_EDIT);
|
||||||
|
expect(mocks.deleteWhere).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "radio_api_key_delete",
|
||||||
|
description: "Deleted radio API key #9",
|
||||||
|
targetId: 9,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/radio/api-keys");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await deleteApiKey(form({ id: "" }));
|
||||||
|
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft when the delete throws", async () => {
|
||||||
|
mocks.deleteWhere.mockRejectedValueOnce(new Error("db down"));
|
||||||
|
|
||||||
|
await expect(deleteApiKey(form({ id: "9" }))).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(mocks.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
mocks.requirePermission.mockRejectedValueOnce(new Error("redirect:/admin"));
|
||||||
|
|
||||||
|
await expect(deleteApiKey(form({ id: "1" }))).rejects.toThrow(
|
||||||
|
"redirect:/admin",
|
||||||
|
);
|
||||||
|
expect(mocks.deleteWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,9 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
|
|||||||
|
|
||||||
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
||||||
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
|
function intOr(raw: FormDataEntryValue | null, fallback: number): number {
|
||||||
const n = Number(str(raw).trim());
|
const trimmed = str(raw).trim();
|
||||||
|
if (!trimmed) return fallback;
|
||||||
|
const n = Number(trimmed);
|
||||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||||
return Math.floor(n);
|
return Math.floor(n);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
insertResult: [{ insertId: 1 }],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { RADIO_EDIT: "admin.radio.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTrack,
|
||||||
|
deleteTrack,
|
||||||
|
toggleTrack,
|
||||||
|
} from "./admin-radio-autodj";
|
||||||
|
import { RadioAutoDjPlaylist } from "@/lib/db";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
fakeDbConfig.insertResult = [{ insertId: 1 }];
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 99,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTrack", () => {
|
||||||
|
it("creates a track and logs staff activity", async () => {
|
||||||
|
await createTrack(
|
||||||
|
form({
|
||||||
|
title: "Night Drive",
|
||||||
|
artist: "Synthwave Kid",
|
||||||
|
album: "Retro",
|
||||||
|
artworkUrl: "https://cdn/x.png",
|
||||||
|
duration: "95",
|
||||||
|
sortOrder: "5.7",
|
||||||
|
isActive: "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.radio.edit");
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(RadioAutoDjPlaylist);
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
title: "Night Drive",
|
||||||
|
artist: "Synthwave Kid",
|
||||||
|
album: "Retro",
|
||||||
|
artworkUrl: "https://cdn/x.png",
|
||||||
|
duration: 95,
|
||||||
|
sortOrder: 5,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(op.values.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(op.values.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 99,
|
||||||
|
action: "radio_autodj_create",
|
||||||
|
description: 'Created AutoDJ track "Night Drive" by Synthwave Kid',
|
||||||
|
targetType: "radio_auto_dj_track",
|
||||||
|
targetId: 1,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/autodj");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller lacks RADIO_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(createTrack(form({ title: "x" }))).rejects.toThrow("denied");
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when title is blank", async () => {
|
||||||
|
await createTrack(form({ title: " " }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores blank optional fields as null and a byte-off isActive as false", async () => {
|
||||||
|
await createTrack(form({ title: "Instrumental" }));
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops[0].values).toMatchObject({
|
||||||
|
artist: null,
|
||||||
|
album: null,
|
||||||
|
artworkUrl: null,
|
||||||
|
duration: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: 'Created AutoDJ track "Instrumental"',
|
||||||
|
targetId: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats invalid/negative numeric fields as 0/null", async () => {
|
||||||
|
await createTrack(
|
||||||
|
form({
|
||||||
|
title: "Edge",
|
||||||
|
duration: "-8",
|
||||||
|
sortOrder: "-2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(fakeDbConfig.ops[0].values).toMatchObject({
|
||||||
|
title: "Edge",
|
||||||
|
duration: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a NaN sortOrder/duration as 0/null", async () => {
|
||||||
|
await createTrack(
|
||||||
|
form({ title: "Edge2", duration: "abc", sortOrder: "abc" }),
|
||||||
|
);
|
||||||
|
expect(fakeDbConfig.ops[0].values).toMatchObject({
|
||||||
|
duration: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows insert failures but still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await expect(createTrack(form({ title: "Track" }))).resolves.toBeUndefined();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/autodj");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleTrack", () => {
|
||||||
|
it("activates a track", async () => {
|
||||||
|
await toggleTrack(form({ id: "7", isActive: "true" }));
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(RadioAutoDjPlaylist);
|
||||||
|
expect(op.set).toMatchObject({ isActive: true });
|
||||||
|
expect(op.set.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 99,
|
||||||
|
action: "radio_autodj_toggle",
|
||||||
|
description: "Activated AutoDJ track #7",
|
||||||
|
targetType: "radio_auto_dj_track",
|
||||||
|
targetId: 7,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/autodj");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deactivates a track when isActive is 0", async () => {
|
||||||
|
await toggleTrack(form({ id: "7", isActive: "0" }));
|
||||||
|
expect(fakeDbConfig.ops[0].set).toMatchObject({ isActive: false });
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "Deactivated AutoDJ track #7",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a non-numeric id", async () => {
|
||||||
|
await toggleTrack(form({ id: "nope" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a zero id", async () => {
|
||||||
|
await toggleTrack(form({ id: "0" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a missing id", async () => {
|
||||||
|
await toggleTrack(new FormData());
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows update failures but still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
await toggleTrack(form({ id: "2", isActive: "on" }));
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/autodj");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteTrack", () => {
|
||||||
|
it("deletes a track and logs staff activity", async () => {
|
||||||
|
await deleteTrack(form({ id: "9" }));
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("delete");
|
||||||
|
expect(op.table).toBe(RadioAutoDjPlaylist);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 99,
|
||||||
|
action: "radio_autodj_delete",
|
||||||
|
description: "Deleted AutoDJ track #9",
|
||||||
|
targetType: "radio_auto_dj_track",
|
||||||
|
targetId: 9,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/autodj");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await deleteTrack(form({ id: "-3" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows delete failures but still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteTrack(form({ id: "4" }));
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/autodj");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
insertResult: [{ insertId: 1 }],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockReload = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLoggerError = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { RADIO_EDIT: "admin.radio.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: mockReload },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: mockLoggerError, warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRadioBanner,
|
||||||
|
createRadioRank,
|
||||||
|
deleteRadioBanner,
|
||||||
|
deleteRadioRank,
|
||||||
|
saveRadioSetting,
|
||||||
|
saveRadioSettings,
|
||||||
|
updateRadioBanner,
|
||||||
|
updateRadioRank,
|
||||||
|
} from "./admin-radio-extra";
|
||||||
|
import {
|
||||||
|
RadioBanners,
|
||||||
|
RadioRanks,
|
||||||
|
WebsiteSetting,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const staff = { id: 99, rank: 7, username: "admin" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
fakeDbConfig.insertResult = [{ insertId: 1 }];
|
||||||
|
mockRequirePermission.mockResolvedValue(staff);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveRadioSetting", () => {
|
||||||
|
it("upserts a radio_* setting and reloads the settings cache", async () => {
|
||||||
|
await saveRadioSetting(
|
||||||
|
form({
|
||||||
|
key: " radio_stream_url ",
|
||||||
|
value: " https://stream.example/radio ",
|
||||||
|
comment: "Main stream",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.radio.edit");
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(WebsiteSetting);
|
||||||
|
expect(op.values).toEqual({
|
||||||
|
key: "radio_stream_url",
|
||||||
|
value: " https://stream.example/radio ",
|
||||||
|
comment: "Main stream",
|
||||||
|
});
|
||||||
|
expect(op.onDuplicate).toEqual({ value: " https://stream.example/radio " });
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a null comment when blank", async () => {
|
||||||
|
await saveRadioSetting(form({ key: "radio_name", value: "Atom FM" }));
|
||||||
|
expect(fakeDbConfig.ops[0].values).toEqual({
|
||||||
|
key: "radio_name",
|
||||||
|
value: "Atom FM",
|
||||||
|
comment: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the key is blank", async () => {
|
||||||
|
await saveRadioSetting(form({ key: " ", value: "x" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues on DB failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? "boom" : false);
|
||||||
|
await saveRadioSetting(form({ key: "radio_name", value: "Atom FM" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to save radio setting",
|
||||||
|
{ err: expect.any(Error), key: "radio_name" },
|
||||||
|
);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveRadioSettings", () => {
|
||||||
|
it("bulk upserts only radio_/auto_dj_ keys and reloads", async () => {
|
||||||
|
await saveRadioSettings(
|
||||||
|
form({
|
||||||
|
__keys: "radio_foo, auto_dj_bar, unrelated, radio_baz ",
|
||||||
|
radio_foo: "1",
|
||||||
|
auto_dj_bar: "green",
|
||||||
|
unrelated: "nope",
|
||||||
|
radio_baz: "0",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts).toHaveLength(3);
|
||||||
|
expect(inserts.map((o: any) => o.values.key).sort()).toEqual([
|
||||||
|
"auto_dj_bar",
|
||||||
|
"radio_baz",
|
||||||
|
"radio_foo",
|
||||||
|
]);
|
||||||
|
for (const op of inserts) {
|
||||||
|
expect(op.values.comment).toBeNull();
|
||||||
|
expect(op.onDuplicate).toBeDefined();
|
||||||
|
}
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without touching the DB when no keys match", async () => {
|
||||||
|
await saveRadioSettings(form({ __keys: "other_x, extra" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues when the bulk upsert fails", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await saveRadioSettings(form({ __keys: "radio_foo", radio_foo: "1" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to bulk-save radio settings",
|
||||||
|
expect.objectContaining({ err: expect.any(Error), keys: ["radio_foo"] }),
|
||||||
|
);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRadioBanner", () => {
|
||||||
|
it("creates a banner owned by the staff user", async () => {
|
||||||
|
await createRadioBanner(
|
||||||
|
form({
|
||||||
|
imagePath: "/banners/a.png",
|
||||||
|
title: "Summer",
|
||||||
|
description: "Beach party",
|
||||||
|
sortOrder: "5.9",
|
||||||
|
isActive: "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(RadioBanners);
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
userId: 99n,
|
||||||
|
imagePath: "/banners/a.png",
|
||||||
|
title: "Summer",
|
||||||
|
description: "Beach party",
|
||||||
|
sortOrder: 5,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(op.values.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/banners");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without an image path", async () => {
|
||||||
|
await createRadioBanner(form({ title: "No image" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips blank optional fields and coerces invalid sortOrder to 0", async () => {
|
||||||
|
await createRadioBanner(
|
||||||
|
form({ imagePath: "/banners/b.png", sortOrder: "xyz", isActive: "0" }),
|
||||||
|
);
|
||||||
|
expect(fakeDbConfig.ops[0].values).toMatchObject({
|
||||||
|
title: null,
|
||||||
|
description: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues when the insert fails", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await createRadioBanner(form({ imagePath: "/banners/c.png" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to create radio banner",
|
||||||
|
{ err: expect.any(Error), imagePath: "/banners/c.png" },
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/banners");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateRadioBanner", () => {
|
||||||
|
it("updates an existing banner", async () => {
|
||||||
|
await updateRadioBanner(
|
||||||
|
form({
|
||||||
|
id: "3",
|
||||||
|
imagePath: "/banners/next.png",
|
||||||
|
title: "Winter",
|
||||||
|
description: "Snow",
|
||||||
|
sortOrder: "1",
|
||||||
|
isActive: "true",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(RadioBanners);
|
||||||
|
expect(op.set).toMatchObject({
|
||||||
|
imagePath: "/banners/next.png",
|
||||||
|
title: "Winter",
|
||||||
|
description: "Snow",
|
||||||
|
sortOrder: 1,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(op.set.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/banners");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await updateRadioBanner(form({ id: "xyz", imagePath: "/banners/x.png" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the image path is blank", async () => {
|
||||||
|
await updateRadioBanner(form({ id: "3", imagePath: " " }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues on update failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
await updateRadioBanner(form({ id: "3", imagePath: "/banners/x.png" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to update radio banner",
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/banners");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteRadioBanner", () => {
|
||||||
|
it("deletes a banner by id", async () => {
|
||||||
|
await deleteRadioBanner(form({ id: "5" }));
|
||||||
|
const del = fakeDbConfig.ops[0];
|
||||||
|
expect(del.kind).toBe("delete");
|
||||||
|
expect(del.table).toBe(RadioBanners);
|
||||||
|
expect(del.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/banners");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await deleteRadioBanner(form({ id: "0" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues on delete failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteRadioBanner(form({ id: "6" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to delete radio banner",
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/banners");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRadioRank", () => {
|
||||||
|
it("creates a rank", async () => {
|
||||||
|
await createRadioRank(
|
||||||
|
form({
|
||||||
|
name: "DJ Legend",
|
||||||
|
description: "Top DJ",
|
||||||
|
badgeCode: "DJ01",
|
||||||
|
isActive: "1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(RadioRanks);
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
name: "DJ Legend",
|
||||||
|
description: "Top DJ",
|
||||||
|
badgeCode: "DJ01",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/ranks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without a name", async () => {
|
||||||
|
await createRadioRank(form({ badgeCode: "X" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores nulls for blank optional fields", async () => {
|
||||||
|
await createRadioRank(form({ name: "Listener" }));
|
||||||
|
expect(fakeDbConfig.ops[0].values).toMatchObject({
|
||||||
|
description: null,
|
||||||
|
badgeCode: null,
|
||||||
|
isActive: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues on insert failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await createRadioRank(form({ name: "Broken" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to create radio rank",
|
||||||
|
{ err: expect.any(Error), name: "Broken" },
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/ranks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateRadioRank", () => {
|
||||||
|
it("updates an existing rank", async () => {
|
||||||
|
await updateRadioRank(
|
||||||
|
form({
|
||||||
|
id: "2",
|
||||||
|
name: "VIP",
|
||||||
|
description: "Premium",
|
||||||
|
badgeCode: "VIP02",
|
||||||
|
isActive: "on",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(RadioRanks);
|
||||||
|
expect(op.set).toMatchObject({
|
||||||
|
name: "VIP",
|
||||||
|
description: "Premium",
|
||||||
|
badgeCode: "VIP02",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/ranks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await updateRadioRank(form({ id: "abc", name: "VIP" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the name is blank", async () => {
|
||||||
|
await updateRadioRank(form({ id: "2", name: " " }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues on update failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
await updateRadioRank(form({ id: "2", name: "VIP" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to update radio rank",
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/ranks");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteRadioRank", () => {
|
||||||
|
it("deletes a rank by id", async () => {
|
||||||
|
await deleteRadioRank(form({ id: "4" }));
|
||||||
|
const del = fakeDbConfig.ops[0];
|
||||||
|
expect(del.kind).toBe("delete");
|
||||||
|
expect(del.table).toBe(RadioRanks);
|
||||||
|
expect(del.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/ranks");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await deleteRadioRank(form({ id: "-1" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and continues on delete failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteRadioRank(form({ id: "7" }));
|
||||||
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
|
"Failed to delete radio rank",
|
||||||
|
expect.objectContaining({ err: expect.any(Error) }),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/ranks");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { RADIO_EDIT: "admin.radio.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { deleteShout } from "./admin-radio-moderation";
|
||||||
|
import { RadioShouts } from "@/lib/db";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 55,
|
||||||
|
rank: 7,
|
||||||
|
username: "mod",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteShout", () => {
|
||||||
|
it("deletes a shout and logs staff activity", async () => {
|
||||||
|
await deleteShout(form({ id: "12" }));
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.radio.edit");
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(1);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("delete");
|
||||||
|
expect(op.table).toBe(RadioShouts);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 55,
|
||||||
|
action: "radio.shout.delete",
|
||||||
|
description: "Deleted radio shout #12",
|
||||||
|
targetType: "radio_shout",
|
||||||
|
targetId: 12,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/moderation");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller lacks RADIO_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(deleteShout(form({ id: "1" }))).rejects.toThrow("denied");
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await deleteShout(form({ id: "abc" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a zero id", async () => {
|
||||||
|
await deleteShout(form({ id: "0" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows DB failures but still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteShout(form({ id: "3" }));
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/moderation");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockReload = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRedirect = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { RADIO_EDIT: "admin.radio.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: mockRedirect,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: mockReload },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { savePoints } from "./admin-radio-points";
|
||||||
|
import { WebsiteSetting } from "@/lib/db";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 42,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const POINTS_KEYS = [
|
||||||
|
"radio_points_enabled",
|
||||||
|
"radio_points_per_minute",
|
||||||
|
"radio_points_currency",
|
||||||
|
"radio_points_max_per_day",
|
||||||
|
"radio_points_min_listeners",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("savePoints", () => {
|
||||||
|
it("saves all five point settings with normalized values", async () => {
|
||||||
|
await savePoints(
|
||||||
|
form({
|
||||||
|
radio_points_enabled: "on",
|
||||||
|
radio_points_per_minute: "5.9",
|
||||||
|
radio_points_currency: "duckets",
|
||||||
|
radio_points_max_per_day: "100",
|
||||||
|
radio_points_min_listeners: "2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.radio.edit");
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts).toHaveLength(5);
|
||||||
|
for (const op of inserts) {
|
||||||
|
expect(op.table).toBe(WebsiteSetting);
|
||||||
|
expect(op.values.comment).toBe("Radio points");
|
||||||
|
expect(op.onDuplicate).toBeDefined();
|
||||||
|
}
|
||||||
|
const byKey = Object.fromEntries(
|
||||||
|
inserts.map((op: any) => [op.values.key, op.values.value]),
|
||||||
|
);
|
||||||
|
expect(byKey).toEqual({
|
||||||
|
radio_points_enabled: "1",
|
||||||
|
radio_points_per_minute: "5",
|
||||||
|
radio_points_currency: "duckets",
|
||||||
|
radio_points_max_per_day: "100",
|
||||||
|
radio_points_min_listeners: "2",
|
||||||
|
});
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 42,
|
||||||
|
action: "radio_points_update",
|
||||||
|
description:
|
||||||
|
"Updated radio listener-points settings (enabled=1, 5/min duckets)",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/points");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/radio/points?saved=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to credits and clamps invalid integers to 0", async () => {
|
||||||
|
await savePoints(
|
||||||
|
form({
|
||||||
|
radio_points_enabled: "0",
|
||||||
|
radio_points_per_minute: "-3",
|
||||||
|
radio_points_currency: "brainz",
|
||||||
|
radio_points_max_per_day: "abc",
|
||||||
|
radio_points_min_listeners: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
const byKey = Object.fromEntries(
|
||||||
|
inserts.map((op: any) => [op.values.key, op.values.value]),
|
||||||
|
);
|
||||||
|
expect(byKey).toEqual({
|
||||||
|
radio_points_enabled: "0",
|
||||||
|
radio_points_per_minute: "0",
|
||||||
|
radio_points_currency: "credits",
|
||||||
|
radio_points_max_per_day: "0",
|
||||||
|
radio_points_min_listeners: "0",
|
||||||
|
});
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description:
|
||||||
|
"Updated radio listener-points settings (enabled=0, 0/min credits)",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty form with defaults", async () => {
|
||||||
|
await savePoints(new FormData());
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts).toHaveLength(5);
|
||||||
|
const byKey = Object.fromEntries(
|
||||||
|
inserts.map((op: any) => [op.values.key, op.values.value]),
|
||||||
|
);
|
||||||
|
expect(byKey).toEqual({
|
||||||
|
radio_points_enabled: "0",
|
||||||
|
radio_points_per_minute: "0",
|
||||||
|
radio_points_currency: "credits",
|
||||||
|
radio_points_max_per_day: "0",
|
||||||
|
radio_points_min_listeners: "0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller lacks RADIO_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(savePoints(new FormData())).rejects.toThrow("denied");
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft on DB errors but still revalidates and redirects", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? "db down" : false);
|
||||||
|
await savePoints(
|
||||||
|
form({ radio_points_enabled: "on", radio_points_currency: "diamonds" }),
|
||||||
|
);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/radio/points");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/radio/points?saved=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SHOP_EDIT: "admin.shop.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCategory,
|
||||||
|
createValue,
|
||||||
|
deleteCategory,
|
||||||
|
deleteValue,
|
||||||
|
updateCategory,
|
||||||
|
updateValue,
|
||||||
|
} from "./admin-rare-values";
|
||||||
|
import {
|
||||||
|
WebsiteRareValueCategories,
|
||||||
|
WebsiteRareValues,
|
||||||
|
} from "@/lib/db";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 10,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCategory", () => {
|
||||||
|
it("creates a category with a floored positive priority", async () => {
|
||||||
|
await createCategory(
|
||||||
|
form({ name: " Rares ", badge: " RAR ", priority: "3.9" }),
|
||||||
|
);
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.shop.edit");
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(WebsiteRareValueCategories);
|
||||||
|
expect(op.values).toEqual({ name: "Rares", badge: "RAR", priority: 3 });
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without a name or badge", async () => {
|
||||||
|
await createCategory(form({ badge: "X" }));
|
||||||
|
await createCategory(form({ name: "Y" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults invalid priorities to 1", async () => {
|
||||||
|
await createCategory(form({ name: "A", badge: "B", priority: "-5" }));
|
||||||
|
await createCategory(form({ name: "C", badge: "D", priority: "abc" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(2);
|
||||||
|
expect(fakeDbConfig.ops[0].values.priority).toBe(1);
|
||||||
|
expect(fakeDbConfig.ops[1].values.priority).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows insert errors and still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await createCategory(form({ name: "A", badge: "B" }));
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteCategory", () => {
|
||||||
|
it("deletes the category after removing its values", async () => {
|
||||||
|
await deleteCategory(form({ id: "7" }));
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(2);
|
||||||
|
const [valuesDelete, categoryDelete] = fakeDbConfig.ops;
|
||||||
|
expect(valuesDelete.kind).toBe("delete");
|
||||||
|
expect(valuesDelete.table).toBe(WebsiteRareValues);
|
||||||
|
expect(valuesDelete.where).toBeDefined();
|
||||||
|
expect(categoryDelete.kind).toBe("delete");
|
||||||
|
expect(categoryDelete.table).toBe(WebsiteRareValueCategories);
|
||||||
|
expect(categoryDelete.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for invalid ids", async () => {
|
||||||
|
await deleteCategory(form({ id: "0" }));
|
||||||
|
await deleteCategory(form({ id: "abc" }));
|
||||||
|
await deleteCategory(new FormData());
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows DB errors and still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteCategory(form({ id: "3" }));
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createValue", () => {
|
||||||
|
it("creates a value with parsed itemId and wallet fields", async () => {
|
||||||
|
await createValue(
|
||||||
|
form({
|
||||||
|
categoryId: "2",
|
||||||
|
name: "Throne",
|
||||||
|
furnitureIcon: "throne.png",
|
||||||
|
itemId: "101.9",
|
||||||
|
creditValue: "500",
|
||||||
|
currencyValue: "10",
|
||||||
|
currencyType: "diamonds",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(WebsiteRareValues);
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
categoryId: 2n,
|
||||||
|
name: "Throne",
|
||||||
|
furnitureIcon: "throne.png",
|
||||||
|
itemId: 101,
|
||||||
|
creditValue: "500",
|
||||||
|
currencyValue: "10",
|
||||||
|
currencyType: "diamonds",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when categoryId, name or furnitureIcon is missing", async () => {
|
||||||
|
await createValue(form({ name: "X", furnitureIcon: "x.png" }));
|
||||||
|
await createValue(
|
||||||
|
form({ categoryId: "1", furnitureIcon: "x.png" }),
|
||||||
|
);
|
||||||
|
await createValue(form({ categoryId: "1", name: "X" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults itemId/values to null and currencyType to diamonds", async () => {
|
||||||
|
await createValue(
|
||||||
|
form({
|
||||||
|
categoryId: "1",
|
||||||
|
name: "Empty",
|
||||||
|
furnitureIcon: "e.png",
|
||||||
|
itemId: "abc",
|
||||||
|
currencyType: " ",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(fakeDbConfig.ops[0].values).toMatchObject({
|
||||||
|
itemId: null,
|
||||||
|
creditValue: null,
|
||||||
|
currencyValue: null,
|
||||||
|
currencyType: "diamonds",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows insert errors and still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await createValue(
|
||||||
|
form({ categoryId: "1", name: "X", furnitureIcon: "x.png" }),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteValue", () => {
|
||||||
|
it("deletes a value by id", async () => {
|
||||||
|
await deleteValue(form({ id: "9" }));
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("delete");
|
||||||
|
expect(op.table).toBe(WebsiteRareValues);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for an invalid id", async () => {
|
||||||
|
await deleteValue(form({ id: "-1" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows delete errors and still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteValue(form({ id: "4" }));
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateCategory", () => {
|
||||||
|
it("updates an existing category", async () => {
|
||||||
|
await updateCategory(
|
||||||
|
form({ id: "3", name: "Updated", badge: "B2", priority: "2" }),
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(WebsiteRareValueCategories);
|
||||||
|
expect(op.set).toEqual({ name: "Updated", badge: "B2", priority: 2 });
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without an id or without name/badge", async () => {
|
||||||
|
await updateCategory(form({ name: "X", badge: "B" }));
|
||||||
|
await updateCategory(form({ id: "1", badge: "B" }));
|
||||||
|
await updateCategory(form({ id: "1", name: "X" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows update errors and still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
await updateCategory(form({ id: "1", name: "X", badge: "B" }));
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateValue", () => {
|
||||||
|
it("updates a value including the categoryId when provided", async () => {
|
||||||
|
await updateValue(
|
||||||
|
form({
|
||||||
|
id: "5",
|
||||||
|
categoryId: "2",
|
||||||
|
name: "Sofa",
|
||||||
|
furnitureIcon: "sofa.png",
|
||||||
|
itemId: "7",
|
||||||
|
creditValue: "1",
|
||||||
|
currencyValue: "2",
|
||||||
|
currencyType: "points",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(WebsiteRareValues);
|
||||||
|
expect(op.set).toMatchObject({
|
||||||
|
name: "Sofa",
|
||||||
|
itemId: 7,
|
||||||
|
creditValue: "1",
|
||||||
|
currencyValue: "2",
|
||||||
|
currencyType: "points",
|
||||||
|
furnitureIcon: "sofa.png",
|
||||||
|
categoryId: 2n,
|
||||||
|
});
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits categoryId and nulls invalid fields when they are absent", async () => {
|
||||||
|
await updateValue(
|
||||||
|
form({ id: "5", name: "Sofa", furnitureIcon: "sofa.png", itemId: "x" }),
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.set).not.toHaveProperty("categoryId");
|
||||||
|
expect(op.set).toMatchObject({
|
||||||
|
name: "Sofa",
|
||||||
|
itemId: null,
|
||||||
|
creditValue: null,
|
||||||
|
currencyValue: null,
|
||||||
|
currencyType: "diamonds",
|
||||||
|
furnitureIcon: "sofa.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without an id or without name/furnitureIcon", async () => {
|
||||||
|
await updateValue(form({ name: "X", furnitureIcon: "x.png" }));
|
||||||
|
await updateValue(form({ id: "1", furnitureIcon: "x.png" }));
|
||||||
|
await updateValue(form({ id: "1", name: "X" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows update errors and still revalidates", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
await updateValue(form({ id: "1", name: "X", furnitureIcon: "x.png" }));
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/rare-values");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogServerError = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSiteSettingUpdate = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { REFERRALS_EDIT: "admin.referrals.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server-log", () => ({
|
||||||
|
logServerError: mockLogServerError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
actionError: (message: string) => ({ ok: false, error: message }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { update: mockSiteSettingUpdate },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { updateReferralSettings } from "./admin-referrals";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
mockSiteSettingUpdate.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateReferralSettings", () => {
|
||||||
|
it("saves valid settings and returns ok", async () => {
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "5",
|
||||||
|
rewardAmount: "100",
|
||||||
|
rewardCurrency: "duckets",
|
||||||
|
blockSameIp: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.referrals.edit");
|
||||||
|
expect(mockSiteSettingUpdate).toHaveBeenNthCalledWith(1, "referrals_needed", "5");
|
||||||
|
expect(mockSiteSettingUpdate).toHaveBeenNthCalledWith(2, "referral_reward_amount", "100");
|
||||||
|
expect(mockSiteSettingUpdate).toHaveBeenNthCalledWith(3, "referral_reward_currency_type", "duckets");
|
||||||
|
expect(mockSiteSettingUpdate).toHaveBeenNthCalledWith(4, "referrals_block_same_ip", "1");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/referrals");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes currency case/spacing and stores blockSameIp 0", async () => {
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "10",
|
||||||
|
rewardAmount: "50",
|
||||||
|
rewardCurrency: " DUCKETS ",
|
||||||
|
blockSameIp: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSiteSettingUpdate).toHaveBeenCalledWith(
|
||||||
|
"referral_reward_currency_type",
|
||||||
|
"duckets",
|
||||||
|
);
|
||||||
|
expect(mockSiteSettingUpdate).toHaveBeenCalledWith(
|
||||||
|
"referrals_block_same_ip",
|
||||||
|
"0",
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when referrals needed is below 1", async () => {
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "0",
|
||||||
|
rewardAmount: "100",
|
||||||
|
rewardCurrency: "credits",
|
||||||
|
blockSameIp: "0",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Referrals needed must be at least 1",
|
||||||
|
});
|
||||||
|
expect(mockSiteSettingUpdate).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric referrals needed", async () => {
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "abc",
|
||||||
|
rewardAmount: "100",
|
||||||
|
rewardCurrency: "credits",
|
||||||
|
blockSameIp: "0",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive reward amount", async () => {
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "3",
|
||||||
|
rewardAmount: "-5",
|
||||||
|
rewardCurrency: "credits",
|
||||||
|
blockSameIp: "0",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: false, error: "Reward amount must be positive" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsupported reward currency", async () => {
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "3",
|
||||||
|
rewardAmount: "10",
|
||||||
|
rewardCurrency: "gemstones",
|
||||||
|
blockSameIp: "0",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Reward currency is not one of the emulator wallets",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller lacks REFERRALS_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(
|
||||||
|
updateReferralSettings({
|
||||||
|
referralsNeeded: "3",
|
||||||
|
rewardAmount: "10",
|
||||||
|
rewardCurrency: "credits",
|
||||||
|
blockSameIp: "0",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error message when saving fails", async () => {
|
||||||
|
mockSiteSettingUpdate.mockRejectedValueOnce(new Error("db gone"));
|
||||||
|
const result = await updateReferralSettings({
|
||||||
|
referralsNeeded: "3",
|
||||||
|
rewardAmount: "10",
|
||||||
|
rewardCurrency: "points",
|
||||||
|
blockSameIp: "1",
|
||||||
|
});
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.referral_settings_update_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Referral settings could not be saved",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
insertResult: [{ insertId: 1 }],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermissionRateLimited = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockReload = vi.hoisted(() => vi.fn());
|
||||||
|
const mockClearFurnidata = vi.hoisted(() => vi.fn());
|
||||||
|
const mockClearBadge = vi.hoisted(() => vi.fn());
|
||||||
|
const mockAdminAction = vi.hoisted(() => vi.fn());
|
||||||
|
const capturedAdminAction = vi.hoisted(() => ({ calls: [] as any[] }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermissionRateLimited,
|
||||||
|
requirePermissionRateLimited: mockRequirePermissionRateLimited,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/foundation/action", () => ({
|
||||||
|
adminAction: (opts: unknown, handler: (ctx: any) => unknown) => {
|
||||||
|
capturedAdminAction.calls.push([opts, handler]);
|
||||||
|
mockAdminAction(opts, handler);
|
||||||
|
return async (input?: unknown) => {
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 1, username: "admin", rank: 10 } },
|
||||||
|
permissions: {},
|
||||||
|
requestId: "req-1",
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
...(input !== undefined ? { data: input } : {}),
|
||||||
|
};
|
||||||
|
return await handler(ctx);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: mockReload },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/habbo-furnidata-cache", () => ({
|
||||||
|
clearOfficialHabboFurnidataCache: mockClearFurnidata,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/habboassets", () => ({
|
||||||
|
clearBadgeCache: mockClearBadge,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSetting,
|
||||||
|
deleteSetting,
|
||||||
|
saveManagedSettings,
|
||||||
|
updateSetting,
|
||||||
|
} from "./admin-settings";
|
||||||
|
import { WebsiteSetting } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
mockRequirePermissionRateLimited.mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveManagedSettings", () => {
|
||||||
|
it("is wrapped with the SETTINGS_EDIT permission and rate limiting config", () => {
|
||||||
|
expect(capturedAdminAction.calls).toHaveLength(1);
|
||||||
|
expect(capturedAdminAction.calls[0][0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
permission: PERMS.SETTINGS_EDIT,
|
||||||
|
rateLimitKey: "admin-settings-save",
|
||||||
|
rateLimitMax: 30,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves only managed keys and revalidates admin routes", async () => {
|
||||||
|
const result = await saveManagedSettings({
|
||||||
|
settings: {
|
||||||
|
cms_logo: "/logo.png",
|
||||||
|
cms_favicon: "/favicon.svg",
|
||||||
|
not_a_managed_key: "ignored",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(2);
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts.map((o: any) => o.values.key).sort()).toEqual([
|
||||||
|
"cms_favicon",
|
||||||
|
"cms_logo",
|
||||||
|
]);
|
||||||
|
expect((inserts as any[])[0].onDuplicate).toBeDefined();
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearFurnidata).not.toHaveBeenCalled();
|
||||||
|
expect(mockClearBadge).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/settings");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
|
||||||
|
expect(result).toEqual({ ok: true, data: { saved: 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes the gamedata hotel value and busts gamedata caches", async () => {
|
||||||
|
const result = await saveManagedSettings({
|
||||||
|
settings: { habbo_gamedata_hotel: " es " },
|
||||||
|
});
|
||||||
|
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
key: "habbo_gamedata_hotel",
|
||||||
|
value: "es",
|
||||||
|
});
|
||||||
|
expect(mockClearFurnidata).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearBadge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toEqual({ ok: true, data: { saved: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports saved 0 for an empty settings object", async () => {
|
||||||
|
const result = await saveManagedSettings({ settings: {} });
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearFurnidata).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual({ ok: true, data: { saved: 0 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateSetting", () => {
|
||||||
|
it("rejects when the caller lacks SETTINGS_EDIT", async () => {
|
||||||
|
mockRequirePermissionRateLimited.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(updateSetting(form({ key: "a", value: "b" }))).rejects.toThrow(
|
||||||
|
"denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await updateSetting(form({ key: " ", value: "x" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a plain setting and reloads without busting gamedata caches", async () => {
|
||||||
|
await updateSetting(form({ key: "cms_logo", value: "/new-logo.png" }));
|
||||||
|
|
||||||
|
expect(mockRequirePermissionRateLimited).toHaveBeenCalledWith(
|
||||||
|
"admin.settings.edit",
|
||||||
|
);
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(WebsiteSetting);
|
||||||
|
expect(op.values).toEqual({ key: "cms_logo", value: "/new-logo.png" });
|
||||||
|
expect(op.onDuplicate).toEqual({ value: "/new-logo.png" });
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearFurnidata).not.toHaveBeenCalled();
|
||||||
|
expect(mockClearBadge).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and busts gamedata caches for the hotel setting", async () => {
|
||||||
|
await updateSetting(form({ key: "habbo_gamedata_hotel", value: " FR " }));
|
||||||
|
expect(fakeDbConfig.ops[0].values).toEqual({
|
||||||
|
key: "habbo_gamedata_hotel",
|
||||||
|
value: "fr",
|
||||||
|
});
|
||||||
|
expect(mockClearFurnidata).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearBadge).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createSetting", () => {
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await createSetting(form({ key: "", value: "x" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a setting with a trimmed comment", async () => {
|
||||||
|
await createSetting(
|
||||||
|
form({ key: "custom_key", value: "v1", comment: " My comment " }),
|
||||||
|
);
|
||||||
|
expect(fakeDbConfig.ops[0].values).toEqual({
|
||||||
|
key: "custom_key",
|
||||||
|
value: "v1",
|
||||||
|
comment: "My comment",
|
||||||
|
});
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a null comment when blank", async () => {
|
||||||
|
await createSetting(form({ key: "custom_key", value: "v1" }));
|
||||||
|
expect(fakeDbConfig.ops[0].values.comment).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("busts gamedata caches for the hotel setting", async () => {
|
||||||
|
await createSetting(form({ key: "habbo_gamedata_hotel", value: "de" }));
|
||||||
|
expect(fakeDbConfig.ops[0].values.value).toBe("de");
|
||||||
|
expect(mockClearFurnidata).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearBadge).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteSetting", () => {
|
||||||
|
it("returns early for a blank key", async () => {
|
||||||
|
await deleteSetting(form({ key: "" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a plain setting and reloads", async () => {
|
||||||
|
await deleteSetting(form({ key: "cms_logo" }));
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("delete");
|
||||||
|
expect(op.table).toBe(WebsiteSetting);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearFurnidata).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("busts gamedata caches when deleting the hotel setting", async () => {
|
||||||
|
await deleteSetting(form({ key: "habbo_gamedata_hotel" }));
|
||||||
|
expect(mockClearFurnidata).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockClearBadge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
insertResult: [{ insertId: 1 }],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogServerError = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRedirect = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SHOP_EDIT: "admin.shop.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: mockRedirect,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server-log", () => ({
|
||||||
|
logServerError: mockLogServerError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createShopArticle,
|
||||||
|
deleteShopArticle,
|
||||||
|
updateShopArticle,
|
||||||
|
} from "./admin-shop";
|
||||||
|
import { WebsiteShopArticles } from "@/lib/db";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
fakeDbConfig.insertResult = [{ insertId: 1 }];
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 77,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createShopArticle", () => {
|
||||||
|
it("creates a package and redirects to the shop", async () => {
|
||||||
|
await createShopArticle(
|
||||||
|
form({
|
||||||
|
name: "Weekend Bundle",
|
||||||
|
info: "Stuff",
|
||||||
|
icon: "/icons/box.png",
|
||||||
|
color: "blue",
|
||||||
|
costs: "150.9",
|
||||||
|
giveRank: "5",
|
||||||
|
credits: "10",
|
||||||
|
duckets: "20",
|
||||||
|
diamonds: "30",
|
||||||
|
badges: "BUNDLE1",
|
||||||
|
position: "4",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.shop.edit");
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(WebsiteShopArticles);
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
name: "Weekend Bundle",
|
||||||
|
info: "Stuff",
|
||||||
|
iconUrl: "/icons/box.png",
|
||||||
|
color: "blue",
|
||||||
|
costs: 150,
|
||||||
|
giveRank: 5,
|
||||||
|
credits: 10,
|
||||||
|
duckets: 20,
|
||||||
|
diamonds: 30,
|
||||||
|
badges: "BUNDLE1",
|
||||||
|
position: 4,
|
||||||
|
});
|
||||||
|
expect(op.values.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 77,
|
||||||
|
action: "shop_create",
|
||||||
|
description: 'Created shop package "Weekend Bundle" (150 costs)',
|
||||||
|
targetType: "shop_article",
|
||||||
|
targetId: 1,
|
||||||
|
});
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/shop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without a name", async () => {
|
||||||
|
await createShopArticle(form({ costs: "10" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults missing numeric fields and stores null optionals", async () => {
|
||||||
|
await createShopArticle(form({ name: "Basic" }));
|
||||||
|
const values = fakeDbConfig.ops[0].values;
|
||||||
|
expect(values).toMatchObject({
|
||||||
|
info: "",
|
||||||
|
iconUrl: "",
|
||||||
|
color: "",
|
||||||
|
costs: 0,
|
||||||
|
giveRank: null,
|
||||||
|
credits: null,
|
||||||
|
duckets: null,
|
||||||
|
diamonds: null,
|
||||||
|
badges: null,
|
||||||
|
position: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces invalid numbers to 0/null", async () => {
|
||||||
|
await createShopArticle(
|
||||||
|
form({
|
||||||
|
name: "Edge",
|
||||||
|
costs: "-9",
|
||||||
|
giveRank: "abc",
|
||||||
|
credits: "-1",
|
||||||
|
position: "xyz",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const values = fakeDbConfig.ops[0].values;
|
||||||
|
expect(values).toMatchObject({
|
||||||
|
costs: 0,
|
||||||
|
giveRank: null,
|
||||||
|
credits: null,
|
||||||
|
position: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs the error and returns without redirecting on insert failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await createShopArticle(form({ name: "Broken" }));
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.shop_create_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
expect.objectContaining({ staffId: 77, name: "Broken" }),
|
||||||
|
);
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller lacks SHOP_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(createShopArticle(form({ name: "X" }))).rejects.toThrow(
|
||||||
|
"denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateShopArticle", () => {
|
||||||
|
it("updates an existing article and redirects", async () => {
|
||||||
|
await updateShopArticle(
|
||||||
|
form({
|
||||||
|
id: "9",
|
||||||
|
name: "Renamed",
|
||||||
|
info: "New info",
|
||||||
|
icon: "/icons/new.png",
|
||||||
|
color: "red",
|
||||||
|
costs: "50",
|
||||||
|
giveRank: "3",
|
||||||
|
credits: "5",
|
||||||
|
duckets: "",
|
||||||
|
diamonds: "",
|
||||||
|
badges: "",
|
||||||
|
position: "2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(WebsiteShopArticles);
|
||||||
|
expect(op.set).toMatchObject({
|
||||||
|
name: "Renamed",
|
||||||
|
info: "New info",
|
||||||
|
iconUrl: "/icons/new.png",
|
||||||
|
color: "red",
|
||||||
|
costs: 50,
|
||||||
|
giveRank: 3,
|
||||||
|
credits: 5,
|
||||||
|
duckets: null,
|
||||||
|
diamonds: null,
|
||||||
|
badges: null,
|
||||||
|
position: 2,
|
||||||
|
});
|
||||||
|
expect(op.set.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "shop_update",
|
||||||
|
description: 'Updated shop package #9 ("Renamed")',
|
||||||
|
targetId: 9,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/shop/9");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/shop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without a valid id", async () => {
|
||||||
|
await updateShopArticle(form({ id: "abc", name: "X" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without a name", async () => {
|
||||||
|
await updateShopArticle(form({ id: "1", name: " " }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs the error and returns without rendering guards on failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
await updateShopArticle(form({ id: "1", name: "Broken" }));
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.shop_update_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
expect.objectContaining({ staffId: 77, articleId: "1" }),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteShopArticle", () => {
|
||||||
|
it("deletes an article and redirects", async () => {
|
||||||
|
await deleteShopArticle(form({ id: "12" }));
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("delete");
|
||||||
|
expect(op.table).toBe(WebsiteShopArticles);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 77,
|
||||||
|
action: "shop_delete",
|
||||||
|
description: "Deleted shop package #12",
|
||||||
|
targetType: "shop_article",
|
||||||
|
targetId: 12,
|
||||||
|
});
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/shop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early without a valid id", async () => {
|
||||||
|
await deleteShopArticle(form({ id: "0" }));
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs the error and returns without redirecting on failure", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
await deleteShopArticle(form({ id: "3" }));
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.shop_delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
expect.objectContaining({ staffId: 77, articleId: "3" }),
|
||||||
|
);
|
||||||
|
expect(mockRedirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockReload = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRedirect = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSnapshotCurrentTheme = vi.hoisted(() => vi.fn());
|
||||||
|
const mockUpsertCustomTheme = vi.hoisted(() => vi.fn());
|
||||||
|
const mockGetCustomTheme = vi.hoisted(() => vi.fn());
|
||||||
|
const mockDeleteCustomThemeStore = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: mockRedirect,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: mockReload },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/theme-custom-store", () => ({
|
||||||
|
snapshotCurrentTheme: mockSnapshotCurrentTheme,
|
||||||
|
upsertCustomTheme: mockUpsertCustomTheme,
|
||||||
|
getCustomTheme: mockGetCustomTheme,
|
||||||
|
deleteCustomThemeStore: mockDeleteCustomThemeStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyCustomTheme,
|
||||||
|
applyPreset,
|
||||||
|
deleteCustomTheme,
|
||||||
|
renameCustomTheme,
|
||||||
|
saveCustomTheme,
|
||||||
|
saveTheme,
|
||||||
|
} from "./admin-theme";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(data)) fd.set(key, value);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 3,
|
||||||
|
rank: 10,
|
||||||
|
username: "owner",
|
||||||
|
});
|
||||||
|
mockSnapshotCurrentTheme.mockResolvedValue({ color_primary: "#ffffff" });
|
||||||
|
mockUpsertCustomTheme.mockImplementation(
|
||||||
|
(name: string, settings: object, id?: string) =>
|
||||||
|
Promise.resolve({ id: id ?? "abc-123", name, settings }),
|
||||||
|
);
|
||||||
|
mockGetCustomTheme.mockResolvedValue(null);
|
||||||
|
mockDeleteCustomThemeStore.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveTheme", () => {
|
||||||
|
it("writes valid theme settings for both modes and admin keys", async () => {
|
||||||
|
await saveTheme(
|
||||||
|
form({
|
||||||
|
color_primary: "#123456",
|
||||||
|
color_background: "purple!!!",
|
||||||
|
color_primary_dark: "#654321",
|
||||||
|
admin_canvas: "#111111",
|
||||||
|
admin_text: "color!!!",
|
||||||
|
border_radius: "16",
|
||||||
|
font_family: "nunito",
|
||||||
|
size_heading_h1: "30",
|
||||||
|
size_heading_h2: "30px",
|
||||||
|
custom_css: "body{color:red}",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.settings.edit");
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts).toHaveLength(7);
|
||||||
|
const byKey = Object.fromEntries(
|
||||||
|
inserts.map((op: any) => [op.values.key, op.values.value]),
|
||||||
|
);
|
||||||
|
expect(byKey).toEqual({
|
||||||
|
color_primary: "#123456",
|
||||||
|
color_primary_dark: "#654321",
|
||||||
|
admin_canvas: "#111111",
|
||||||
|
border_radius: "16",
|
||||||
|
font_family: "nunito",
|
||||||
|
size_heading_h1: "30",
|
||||||
|
custom_css: "body{color:red}",
|
||||||
|
});
|
||||||
|
for (const op of inserts) {
|
||||||
|
expect(op.table).toBeDefined();
|
||||||
|
expect(op.onDuplicate).toBeDefined();
|
||||||
|
}
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 3,
|
||||||
|
action: "theme_update",
|
||||||
|
description: "Updated theme settings",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?saved=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when the form is empty but still reloads", async () => {
|
||||||
|
await saveTheme(new FormData());
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?saved=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft when a write fails and still redirects", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await saveTheme(form({ color_primary: "#123456" }));
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?saved=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyPreset", () => {
|
||||||
|
it("writes every preset setting plus the preset name", async () => {
|
||||||
|
await applyPreset(form({ preset: "Midnight" }));
|
||||||
|
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts.length).toBeGreaterThan(0);
|
||||||
|
const themePreset = inserts.find(
|
||||||
|
(op: any) => op.values.key === "theme_preset",
|
||||||
|
);
|
||||||
|
expect(themePreset.values).toEqual({
|
||||||
|
key: "theme_preset",
|
||||||
|
value: "Midnight",
|
||||||
|
comment: "Theme (housekeeping)",
|
||||||
|
});
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "theme_preset",
|
||||||
|
description: 'Applied theme preset "Midnight"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?preset=Midnight");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects back when the preset is unknown", async () => {
|
||||||
|
await applyPreset(form({ preset: "NotARealPreset" }));
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme");
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft on write errors", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await applyPreset(form({ preset: "Ocean" }));
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?preset=Ocean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveCustomTheme", () => {
|
||||||
|
it("snapshots and persists the custom theme", async () => {
|
||||||
|
await saveCustomTheme(form({ name: "My Theme" }));
|
||||||
|
|
||||||
|
expect(mockSnapshotCurrentTheme).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpsertCustomTheme).toHaveBeenCalledWith(
|
||||||
|
"My Theme",
|
||||||
|
{ color_primary: "#ffffff" },
|
||||||
|
);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 3,
|
||||||
|
action: "theme_preset",
|
||||||
|
description: 'Saved custom theme "My Theme"',
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/theme");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?savedTheme=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when the name is blank", async () => {
|
||||||
|
mockRedirect.mockImplementationOnce(() => {
|
||||||
|
throw new Error("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
await expect(saveCustomTheme(form({ name: " " }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockSnapshotCurrentTheme).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft on persist errors", async () => {
|
||||||
|
mockUpsertCustomTheme.mockRejectedValueOnce(new Error("store down"));
|
||||||
|
await saveCustomTheme(form({ name: "My Theme" }));
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?savedTheme=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyCustomTheme", () => {
|
||||||
|
it("applies the saved theme settings", async () => {
|
||||||
|
mockGetCustomTheme.mockResolvedValue({
|
||||||
|
id: "t1",
|
||||||
|
name: "Studio",
|
||||||
|
createdAt: 0,
|
||||||
|
settings: { color_primary: "#abcdef", color_error: "" },
|
||||||
|
});
|
||||||
|
await applyCustomTheme(form({ id: "t1" }));
|
||||||
|
|
||||||
|
const inserts = fakeDbConfig.ops.filter((o: any) => o.kind === "insert");
|
||||||
|
expect(inserts[0].values).toEqual({
|
||||||
|
key: "color_primary",
|
||||||
|
value: "#abcdef",
|
||||||
|
comment: "Theme (housekeeping)",
|
||||||
|
});
|
||||||
|
const themePreset = inserts.find(
|
||||||
|
(op: any) => op.values.key === "theme_preset",
|
||||||
|
);
|
||||||
|
expect(themePreset.values.value).toBe("Studio");
|
||||||
|
expect(mockReload).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: 'Applied custom theme "Studio"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith(
|
||||||
|
"/admin/theme?theme=Studio",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when the id is blank", async () => {
|
||||||
|
mockRedirect.mockImplementationOnce(() => {
|
||||||
|
throw new Error("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
await expect(applyCustomTheme(form({ id: " " }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockGetCustomTheme).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when the theme is not found", async () => {
|
||||||
|
mockRedirect.mockImplementationOnce(() => {
|
||||||
|
throw new Error("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
await expect(applyCustomTheme(form({ id: "nope" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockGetCustomTheme).toHaveBeenCalledWith("nope");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a getCustomTheme failure", async () => {
|
||||||
|
mockGetCustomTheme.mockRejectedValueOnce(new Error("read failed"));
|
||||||
|
await expect(applyCustomTheme(form({ id: "t1" }))).rejects.toThrow(
|
||||||
|
"read failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft on write errors", async () => {
|
||||||
|
mockGetCustomTheme.mockResolvedValue({
|
||||||
|
id: "t1",
|
||||||
|
name: "Studio",
|
||||||
|
createdAt: 0,
|
||||||
|
settings: { color_primary: "#abcdef" },
|
||||||
|
});
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
await applyCustomTheme(form({ id: "t1" }));
|
||||||
|
expect(mockReload).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?theme=Studio");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renameCustomTheme", () => {
|
||||||
|
it("renames a stored theme", async () => {
|
||||||
|
await renameCustomTheme(form({ id: "t1", name: "Renamed" }));
|
||||||
|
|
||||||
|
expect(mockSnapshotCurrentTheme).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpsertCustomTheme).toHaveBeenCalledWith(
|
||||||
|
"Renamed",
|
||||||
|
{ color_primary: "#ffffff" },
|
||||||
|
"t1",
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/theme");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?renamed=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when id or name is missing", async () => {
|
||||||
|
mockRedirect.mockImplementationOnce(() => {
|
||||||
|
throw new Error("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
await expect(renameCustomTheme(form({ id: "t1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockSnapshotCurrentTheme).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft on upsert errors", async () => {
|
||||||
|
mockUpsertCustomTheme.mockRejectedValueOnce(new Error("store down"));
|
||||||
|
await renameCustomTheme(form({ id: "t1", name: "Renamed" }));
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?renamed=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteCustomTheme", () => {
|
||||||
|
it("deletes the custom theme store entry", async () => {
|
||||||
|
await deleteCustomTheme(form({ id: "t1" }));
|
||||||
|
expect(mockDeleteCustomThemeStore).toHaveBeenCalledWith("t1");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/theme");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?deletedTheme=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when the id is blank", async () => {
|
||||||
|
mockRedirect.mockImplementationOnce(() => {
|
||||||
|
throw new Error("NEXT_REDIRECT");
|
||||||
|
});
|
||||||
|
await expect(deleteCustomTheme(form({ id: "" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockDeleteCustomThemeStore).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails soft on store errors", async () => {
|
||||||
|
mockDeleteCustomThemeStore.mockRejectedValueOnce(new Error("store down"));
|
||||||
|
await deleteCustomTheme(form({ id: "t1" }));
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/admin/theme?deletedTheme=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const fakeDbConfig = vi.hoisted(() => ({
|
||||||
|
ops: [] as any[],
|
||||||
|
insertResult: [{ insertId: 1 }],
|
||||||
|
fail: undefined as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
const mockLogServerError = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeActionDb } = await import("@/test/fake-db-actions");
|
||||||
|
return { ...schema, db: createFakeActionDb(fakeDbConfig) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SHOP_EDIT: "admin.shop.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: any) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/server-log", () => ({
|
||||||
|
logServerError: mockLogServerError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
actionError: (message: string) => ({ ok: false, error: message }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createVoucher,
|
||||||
|
deleteVoucher,
|
||||||
|
updateVoucher,
|
||||||
|
} from "./admin-vouchers";
|
||||||
|
import { WebsiteShopVouchers } from "@/lib/db";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fakeDbConfig.ops.length = 0;
|
||||||
|
fakeDbConfig.fail = undefined;
|
||||||
|
fakeDbConfig.insertResult = [{ insertId: 1 }];
|
||||||
|
mockRequirePermission.mockResolvedValue({
|
||||||
|
id: 8,
|
||||||
|
rank: 7,
|
||||||
|
username: "admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createVoucher", () => {
|
||||||
|
it("creates a voucher with parsed amount/uses/expiry", async () => {
|
||||||
|
const result = await createVoucher({
|
||||||
|
code: " SUMMER ",
|
||||||
|
amount: 99.7,
|
||||||
|
maxUses: 5.9,
|
||||||
|
expiresAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith("admin.shop.edit");
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("insert");
|
||||||
|
expect(op.table).toBe(WebsiteShopVouchers);
|
||||||
|
expect(op.values).toMatchObject({
|
||||||
|
code: "SUMMER",
|
||||||
|
amount: 99,
|
||||||
|
maxUses: 5,
|
||||||
|
useCount: 0,
|
||||||
|
});
|
||||||
|
expect(op.values.expiresAt).toBeInstanceOf(Date);
|
||||||
|
expect(op.values.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/vouchers");
|
||||||
|
expect(result).toEqual({ ok: true, data: { id: "1" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults maxUses to 1 and expiry to null", async () => {
|
||||||
|
await createVoucher({ code: "BASIC", amount: 10, maxUses: 0 });
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.values.maxUses).toBe(1);
|
||||||
|
expect(op.values.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unparseable expiry as null", async () => {
|
||||||
|
await createVoucher({
|
||||||
|
code: "BAD_DATE",
|
||||||
|
amount: 10,
|
||||||
|
maxUses: -3,
|
||||||
|
expiresAt: "not-a-date",
|
||||||
|
});
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.values.maxUses).toBe(1);
|
||||||
|
expect(op.values.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a blank code or non-positive amount", async () => {
|
||||||
|
const blank = await createVoucher({ code: " ", amount: 10, maxUses: 1 });
|
||||||
|
expect(blank).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Code and a positive amount are required",
|
||||||
|
});
|
||||||
|
const zero = await createVoucher({ code: "X", amount: 0, maxUses: 1 });
|
||||||
|
expect(zero.ok).toBe(false);
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when the caller lacks SHOP_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(
|
||||||
|
createVoucher({ code: "X", amount: 1, maxUses: 1 }),
|
||||||
|
).rejects.toThrow("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error when the insert fails", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "insert" ? true : false);
|
||||||
|
const result = await createVoucher({ code: "X", amount: 1, maxUses: 1 });
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.voucher_create_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Could not create voucher (code may already exist)",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteVoucher", () => {
|
||||||
|
it("deletes a voucher by id", async () => {
|
||||||
|
const result = await deleteVoucher({ id: "5" });
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("delete");
|
||||||
|
expect(op.table).toBe(WebsiteShopVouchers);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/vouchers");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid ids", async () => {
|
||||||
|
for (const id of ["", "0", "-1", "abc"]) {
|
||||||
|
const result = await deleteVoucher({ id });
|
||||||
|
expect(result).toEqual({ ok: false, error: "Missing voucher id" });
|
||||||
|
}
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error when the delete fails", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "delete" ? true : false);
|
||||||
|
const result = await deleteVoucher({ id: "5" });
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.voucher_delete_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ voucherId: "5" },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ ok: false, error: "Could not delete voucher" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateVoucher", () => {
|
||||||
|
it("updates a voucher", async () => {
|
||||||
|
const result = await updateVoucher({
|
||||||
|
id: "9",
|
||||||
|
code: "UPDATED",
|
||||||
|
amount: 12.8,
|
||||||
|
maxUses: 2.4,
|
||||||
|
expiresAt: "2026-06-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.kind).toBe("update");
|
||||||
|
expect(op.table).toBe(WebsiteShopVouchers);
|
||||||
|
expect(op.set).toMatchObject({
|
||||||
|
code: "UPDATED",
|
||||||
|
amount: 12,
|
||||||
|
maxUses: 2,
|
||||||
|
});
|
||||||
|
expect(op.set.expiresAt).toBeInstanceOf(Date);
|
||||||
|
expect(op.set.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(op.where).toBeDefined();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/vouchers");
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults maxUses/expiry when invalid", async () => {
|
||||||
|
await updateVoucher({
|
||||||
|
id: "9",
|
||||||
|
code: "X",
|
||||||
|
amount: 1,
|
||||||
|
maxUses: Number.NaN,
|
||||||
|
expiresAt: "garbage",
|
||||||
|
});
|
||||||
|
const op = fakeDbConfig.ops[0];
|
||||||
|
expect(op.set.maxUses).toBe(1);
|
||||||
|
expect(op.set.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid id", async () => {
|
||||||
|
const result = await updateVoucher({
|
||||||
|
id: "0",
|
||||||
|
code: "X",
|
||||||
|
amount: 1,
|
||||||
|
maxUses: 1,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: false, error: "Missing voucher id" });
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a blank code or non-positive amount", async () => {
|
||||||
|
const blank = await updateVoucher({
|
||||||
|
id: "1",
|
||||||
|
code: "",
|
||||||
|
amount: 1,
|
||||||
|
maxUses: 1,
|
||||||
|
});
|
||||||
|
expect(blank.ok).toBe(false);
|
||||||
|
const zero = await updateVoucher({
|
||||||
|
id: "1",
|
||||||
|
code: "X",
|
||||||
|
amount: -4,
|
||||||
|
maxUses: 1,
|
||||||
|
});
|
||||||
|
expect(zero.ok).toBe(false);
|
||||||
|
expect(fakeDbConfig.ops).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error when the update fails", async () => {
|
||||||
|
fakeDbConfig.fail = (kind: string) => (kind === "update" ? true : false);
|
||||||
|
const result = await updateVoucher({
|
||||||
|
id: "9",
|
||||||
|
code: "X",
|
||||||
|
amount: 1,
|
||||||
|
maxUses: 1,
|
||||||
|
});
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"admin.voucher_update_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
{ voucherId: "9" },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Could not update voucher (code may already exist)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
insertError: null as Error | null,
|
||||||
|
insertCalls: [] as { table: unknown; values: unknown }[],
|
||||||
|
deleteCalls: [] as { table: unknown; where: unknown }[],
|
||||||
|
nextId: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn(), handlers: {} }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", async () => {
|
||||||
|
const { PERMS } = await import("@/lib/permission-slugs");
|
||||||
|
return { PERMS };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockReloadWordFilter = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/moderation", () => ({
|
||||||
|
reloadWordFilter: mockReloadWordFilter,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUpdateWordFilter = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { updateWordFilter: mockUpdateWordFilter },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
if (state.insertError) {
|
||||||
|
const e = state.insertError;
|
||||||
|
state.insertError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.insertCalls.push({ table, values });
|
||||||
|
return [{ insertId: state.nextId++ }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
state.deleteCalls.push({ table, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WebsiteWordfilter } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { addWord, deleteWord } from "./admin-wordfilter";
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequirePermission.mockResolvedValue(staff as never);
|
||||||
|
state.insertError = null;
|
||||||
|
state.insertCalls = [];
|
||||||
|
state.deleteCalls = [];
|
||||||
|
state.nextId = 1;
|
||||||
|
mockRevalidatePath.mockClear();
|
||||||
|
mockReloadWordFilter.mockClear();
|
||||||
|
mockUpdateWordFilter.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addWord", () => {
|
||||||
|
it("rejects when the caller lacks WORDFILTER_EDIT permission", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(addWord({ word: "bad" })).rejects.toThrow("denied");
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith(PERMS.WORDFILTER_EDIT);
|
||||||
|
expect(state.insertCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when the word is empty (or missing)", async () => {
|
||||||
|
await expect(addWord({ word: "" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Word is required",
|
||||||
|
});
|
||||||
|
await expect(addWord({} as { word: string })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Word is required",
|
||||||
|
});
|
||||||
|
expect(state.insertCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes, trims and truncates the word to 255 chars and inserts it", async () => {
|
||||||
|
const longWord = `${"a".repeat(300)} `;
|
||||||
|
const result = await addWord({ word: ` ${longWord} ` });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: { id: "1" },
|
||||||
|
});
|
||||||
|
expect(state.insertCalls).toHaveLength(1);
|
||||||
|
expect(state.insertCalls[0].table).toBe(WebsiteWordfilter);
|
||||||
|
expect(state.insertCalls[0].values).toEqual({
|
||||||
|
word: "a".repeat(255),
|
||||||
|
});
|
||||||
|
expect(mockReloadWordFilter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateWordFilter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a duplicate-key insert error to a friendly message", async () => {
|
||||||
|
state.insertError = new Error("ER_DUP_ENTRY");
|
||||||
|
const result = await addWord({ word: "dupe" });
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Could not add word (it may already exist)",
|
||||||
|
});
|
||||||
|
expect(mockReloadWordFilter).not.toHaveBeenCalled();
|
||||||
|
expect(mockUpdateWordFilter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteWord", () => {
|
||||||
|
it("errors when the id is missing", async () => {
|
||||||
|
await expect(deleteWord({ id: "" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Missing word id",
|
||||||
|
});
|
||||||
|
expect(state.deleteCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes by id, reloads the filter and revalidates", async () => {
|
||||||
|
const result = await deleteWord({ id: "42" });
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.deleteCalls).toHaveLength(1);
|
||||||
|
expect(state.deleteCalls[0].table).toBe(WebsiteWordfilter);
|
||||||
|
expect(state.deleteCalls[0].where).toEqual(
|
||||||
|
eq(WebsiteWordfilter.id, BigInt(42)),
|
||||||
|
);
|
||||||
|
expect(mockReloadWordFilter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockUpdateWordFilter).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/wordfilter");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a non-numeric id or a failed delete to the generic error", async () => {
|
||||||
|
await expect(deleteWord({ id: "not-a-number" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Could not remove word",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
insertError: null as Error | null,
|
||||||
|
updateError: null as Error | null,
|
||||||
|
deleteError: null as Error | null,
|
||||||
|
insertCalls: [] as { table: unknown; values: unknown }[],
|
||||||
|
updateCalls: [] as { table: unknown; values: unknown; where: unknown }[],
|
||||||
|
deleteCalls: [] as { table: unknown; where: unknown }[],
|
||||||
|
nextId: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", async () => {
|
||||||
|
const { PERMS } = await import("@/lib/permission-slugs");
|
||||||
|
return { PERMS };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn(), handlers: {} }));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
if (state.insertError) {
|
||||||
|
const e = state.insertError;
|
||||||
|
state.insertError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.insertCalls.push({ table, values });
|
||||||
|
return [{ insertId: state.nextId++ }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
if (state.updateError) {
|
||||||
|
const e = state.updateError;
|
||||||
|
state.updateError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.updateCalls.push({ table, values, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
if (state.deleteError) {
|
||||||
|
const e = state.deleteError;
|
||||||
|
state.deleteError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.deleteCalls.push({ table, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WebsiteWriteableBoxes } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createBox,
|
||||||
|
deleteBox,
|
||||||
|
toggleBox,
|
||||||
|
updateBox,
|
||||||
|
} from "./admin-writeable-boxes";
|
||||||
|
|
||||||
|
const staff = { id: 5, rank: 4, username: "editor" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequirePermission.mockResolvedValue(staff as never);
|
||||||
|
state.insertError = null;
|
||||||
|
state.updateError = null;
|
||||||
|
state.deleteError = null;
|
||||||
|
state.insertCalls = [];
|
||||||
|
state.updateCalls = [];
|
||||||
|
state.deleteCalls = [];
|
||||||
|
state.nextId = 1;
|
||||||
|
vi.mocked(mockLogStaffActivity).mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBox", () => {
|
||||||
|
it("rejects callers without PAGES_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(createBox(fakeForm({ title: "Hi" }) as FormData)).rejects.toThrow(
|
||||||
|
"denied",
|
||||||
|
);
|
||||||
|
expect(state.insertCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the title is empty", async () => {
|
||||||
|
await expect(createBox(fakeForm({}) as FormData)).resolves.toBeUndefined();
|
||||||
|
expect(state.insertCalls).toHaveLength(0);
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a box, logs activity and revalidates", async () => {
|
||||||
|
await createBox(
|
||||||
|
fakeForm({
|
||||||
|
title: " Welcome ",
|
||||||
|
icon: " star ",
|
||||||
|
content: "Body",
|
||||||
|
position: "3.9",
|
||||||
|
isActive: "1",
|
||||||
|
}) as FormData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.insertCalls).toHaveLength(1);
|
||||||
|
expect(state.insertCalls[0].table).toBe(WebsiteWriteableBoxes);
|
||||||
|
const values = state.insertCalls[0].values as Record<string, unknown>;
|
||||||
|
expect(values.title).toBe("Welcome");
|
||||||
|
expect(values.icon).toBe("star");
|
||||||
|
expect(values.content).toBe("Body");
|
||||||
|
expect(values.position).toBe(3);
|
||||||
|
expect(values.isActive).toBe(true);
|
||||||
|
expect(values.createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(values.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 5,
|
||||||
|
action: "writeable_box_create",
|
||||||
|
description: 'Created writeable box "Welcome" (#1)',
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: 1,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/writeable-boxes");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses position defensively and treats missing icon/isActive sensibly", async () => {
|
||||||
|
for (const [pos, expected] of [
|
||||||
|
["", 0],
|
||||||
|
["abc", 0],
|
||||||
|
["-5", 0],
|
||||||
|
["2", 2],
|
||||||
|
] as const) {
|
||||||
|
state.nextId = 1;
|
||||||
|
await createBox(
|
||||||
|
fakeForm({
|
||||||
|
title: "T",
|
||||||
|
position: pos,
|
||||||
|
isActive: "0",
|
||||||
|
}) as FormData,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(state.insertCalls.at(-1)?.values as Record<string, unknown>).position,
|
||||||
|
).toBe(expected);
|
||||||
|
expect(
|
||||||
|
(state.insertCalls.at(-1)?.values as Record<string, unknown>).isActive,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(state.insertCalls.at(-1)?.values as Record<string, unknown>).icon,
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a failed insert without revalidating", async () => {
|
||||||
|
state.insertError = new Error("db down");
|
||||||
|
await expect(
|
||||||
|
createBox(fakeForm({ title: "New" }) as FormData),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateBox", () => {
|
||||||
|
it("returns early for a blank/invalid id", async () => {
|
||||||
|
await updateBox(fakeForm({ id: "", title: "T" }) as FormData);
|
||||||
|
await updateBox(fakeForm({ id: "abc", title: "T" }) as FormData);
|
||||||
|
expect(state.updateCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns early when the title is blank", async () => {
|
||||||
|
await updateBox(fakeForm({ id: "3", title: " " }) as FormData);
|
||||||
|
expect(state.updateCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the box and logs activity", async () => {
|
||||||
|
await updateBox(
|
||||||
|
fakeForm({
|
||||||
|
id: "7",
|
||||||
|
title: "Patched",
|
||||||
|
icon: "",
|
||||||
|
content: "C",
|
||||||
|
position: "1",
|
||||||
|
isActive: "1",
|
||||||
|
}) as FormData,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.updateCalls).toHaveLength(1);
|
||||||
|
expect(state.updateCalls[0].table).toBe(WebsiteWriteableBoxes);
|
||||||
|
expect(state.updateCalls[0].where).toEqual(
|
||||||
|
eq(WebsiteWriteableBoxes.id, BigInt(7)),
|
||||||
|
);
|
||||||
|
const values = state.updateCalls[0].values as Record<string, unknown>;
|
||||||
|
expect(values.title).toBe("Patched");
|
||||||
|
expect(values.icon).toBeNull();
|
||||||
|
expect(values.isActive).toBe(true);
|
||||||
|
expect(values.updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 5,
|
||||||
|
action: "writeable_box_update",
|
||||||
|
description: 'Updated writeable box #7 ("Patched")',
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: 7,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a failed update", async () => {
|
||||||
|
state.updateError = new Error("db down");
|
||||||
|
await expect(
|
||||||
|
updateBox(fakeForm({ id: "1", title: "T" }) as FormData),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBox", () => {
|
||||||
|
it("returns early for a blank id", async () => {
|
||||||
|
await deleteBox(fakeForm({ id: "" }) as FormData);
|
||||||
|
expect(state.deleteCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes the box and logs activity", async () => {
|
||||||
|
await deleteBox(fakeForm({ id: "9" }) as FormData);
|
||||||
|
expect(state.deleteCalls).toHaveLength(1);
|
||||||
|
expect(state.deleteCalls[0].table).toBe(WebsiteWriteableBoxes);
|
||||||
|
expect(state.deleteCalls[0].where).toEqual(
|
||||||
|
eq(WebsiteWriteableBoxes.id, BigInt(9)),
|
||||||
|
);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith({
|
||||||
|
staffId: 5,
|
||||||
|
action: "writeable_box_delete",
|
||||||
|
description: "Deleted writeable box #9",
|
||||||
|
targetType: "writeable_box",
|
||||||
|
targetId: 9,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/writeable-boxes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a failed delete", async () => {
|
||||||
|
state.deleteError = new Error("db down");
|
||||||
|
await expect(deleteBox(fakeForm({ id: "1" }) as FormData)).resolves.toBeUndefined();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleBox", () => {
|
||||||
|
it("returns early for a missing id", async () => {
|
||||||
|
await toggleBox(fakeForm({}) as FormData);
|
||||||
|
expect(state.updateCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates when next is 1", async () => {
|
||||||
|
await toggleBox(fakeForm({ id: "2", next: "1" }) as FormData);
|
||||||
|
expect(state.updateCalls).toHaveLength(1);
|
||||||
|
expect((state.updateCalls[0].values as Record<string, unknown>).isActive).toBe(true);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "writeable_box_toggle",
|
||||||
|
description: "Activated writeable box #2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides when next is anything but 1", async () => {
|
||||||
|
await toggleBox(fakeForm({ id: "2", next: "0" }) as FormData);
|
||||||
|
expect((state.updateCalls[0].values as Record<string, unknown>).isActive).toBe(false);
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "Hid writeable box #2",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a failed toggle", async () => {
|
||||||
|
state.updateError = new Error("db down");
|
||||||
|
await expect(toggleBox(fakeForm({ id: "2", next: "1" }) as FormData)).resolves.toBeUndefined();
|
||||||
|
expect(mockLogStaffActivity).not.toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
articles: [] as { slug: string }[],
|
||||||
|
reactions: [] as { id: bigint; active: boolean }[],
|
||||||
|
selectError: null as Error | null,
|
||||||
|
updates: [] as { table: unknown; values: unknown }[],
|
||||||
|
inserts: [] as { table: unknown; values: unknown }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRedirect = vi.hoisted(() =>
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
const err = new Error(`NEXT_REDIRECT: ${url}`);
|
||||||
|
(err as never as { digest: string }).digest =
|
||||||
|
`NEXT_REDIRECT;replace;${url};307;;`;
|
||||||
|
throw err;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: mockRedirect }));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuth = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
|
||||||
|
|
||||||
|
const mockRateLimit = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: mockRateLimit,
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...createFakeDb((table) => {
|
||||||
|
if (state.selectError) {
|
||||||
|
const e = state.selectError;
|
||||||
|
state.selectError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (table === schema.WebsiteArticles) return state.articles;
|
||||||
|
if (table === schema.WebsiteArticleReactions)
|
||||||
|
return state.reactions;
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.updates.push({ table, values });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
state.inserts.push({ table, values });
|
||||||
|
return [{ insertId: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WebsiteArticleReactions, WebsiteArticles } from "@/lib/db";
|
||||||
|
import { toggleReaction } from "./article-reactions";
|
||||||
|
|
||||||
|
function form(data: Record<string, string>): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const [k, v] of Object.entries(data)) fd.append(k, v);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockAuth.mockReset();
|
||||||
|
mockAuth.mockResolvedValue({ user: { id: "7" } });
|
||||||
|
mockRateLimit.mockReset();
|
||||||
|
mockRateLimit.mockResolvedValue({ ok: true });
|
||||||
|
mockRedirect.mockClear();
|
||||||
|
mockRevalidatePath.mockClear();
|
||||||
|
state.articles = [];
|
||||||
|
state.reactions = [];
|
||||||
|
state.selectError = null;
|
||||||
|
state.updates = [];
|
||||||
|
state.inserts = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleReaction", () => {
|
||||||
|
it("redirects to /login when not signed in", async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce(null);
|
||||||
|
await expect(toggleReaction(form({ slug: "a" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login for a non-numeric user id", async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce({ user: { id: "abc" } });
|
||||||
|
await expect(toggleReaction(form({ slug: "a" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with a ratelimit outcome when throttled", async () => {
|
||||||
|
mockRateLimit.mockResolvedValueOnce({ ok: false });
|
||||||
|
await expect(toggleReaction(form({ slug: "a" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /news/a?error=ratelimit",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reactions outside the allowed set", async () => {
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "a", reaction: "meh" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT: /news/a?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric article id", async () => {
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "a", reaction: "like", articleId: "x" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT: /news/a?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with not_found when the article is missing", async () => {
|
||||||
|
state.articles = [];
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "a", reaction: "like", articleId: "1" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT: /news/a?error=not_found");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/news/a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deactivates an already-active reaction and uses the canonical slug", async () => {
|
||||||
|
state.articles = [{ slug: "canonical" }];
|
||||||
|
state.reactions = [{ id: 11n, active: true }];
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "stale", reaction: "LIKE", articleId: "1" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT: /news/canonical?reaction=1");
|
||||||
|
|
||||||
|
expect(state.updates).toEqual([
|
||||||
|
{ table: WebsiteArticleReactions, values: { active: false } },
|
||||||
|
]);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/news/canonical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-activates an existing inactive reaction after clearing others", async () => {
|
||||||
|
state.articles = [{ slug: "canonical" }];
|
||||||
|
state.reactions = [{ id: 11n, active: false }];
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "a", reaction: "wow", articleId: "2" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
|
||||||
|
expect(state.updates).toHaveLength(2);
|
||||||
|
expect(state.updates[0].values).toEqual({ active: false });
|
||||||
|
expect(state.updates[1]).toEqual({
|
||||||
|
table: WebsiteArticleReactions,
|
||||||
|
values: { active: true },
|
||||||
|
});
|
||||||
|
expect(state.inserts).toHaveLength(0);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/news/canonical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a fresh reaction when none exists yet", async () => {
|
||||||
|
state.articles = [{ slug: "canonical" }];
|
||||||
|
state.reactions = [];
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "a", reaction: "like", articleId: "3" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
|
||||||
|
expect(state.updates).toHaveLength(1);
|
||||||
|
expect(state.updates[0].values).toEqual({ active: false });
|
||||||
|
expect(state.inserts).toHaveLength(1);
|
||||||
|
expect(state.inserts[0].table).toBe(WebsiteArticleReactions);
|
||||||
|
expect(state.inserts[0].values).toEqual({
|
||||||
|
userId: 7,
|
||||||
|
articleId: 3n,
|
||||||
|
reaction: "like",
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/news/canonical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("echoes errors as a redirect instead of throwing", async () => {
|
||||||
|
state.selectError = new Error("db down");
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "a", reaction: "like", articleId: "1" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT: /news/a?error=error");
|
||||||
|
expect(mockRedirect).toHaveBeenCalledWith("/news/a?error=error");
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to the news index when no slug hint was provided", async () => {
|
||||||
|
state.articles = [{ slug: "canonical" }];
|
||||||
|
state.reactions = [];
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ reaction: "like", articleId: "1" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT: /news/canonical?reaction=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queries the article by the parsed bigint id", async () => {
|
||||||
|
state.articles = [{ slug: "canonical" }];
|
||||||
|
state.reactions = [];
|
||||||
|
await expect(
|
||||||
|
toggleReaction(form({ slug: "s", reaction: "love", articleId: "999" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(WebsiteArticles).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
badges: [] as { badgeName: string; badgeDescription: string }[],
|
||||||
|
upserts: [] as { table: unknown; values: unknown; conflict: unknown }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", async () => {
|
||||||
|
const { PERMS } = await import("@/lib/permission-slugs");
|
||||||
|
return { PERMS };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn(), handlers: {} }));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...createFakeDb((table) =>
|
||||||
|
table === schema.WebsiteBadges ? state.badges : [],
|
||||||
|
),
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => ({
|
||||||
|
onDuplicateKeyUpdate: (conflict: unknown) => {
|
||||||
|
state.upserts.push({ table, values, conflict });
|
||||||
|
return [{ insertId: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WebsiteBadges } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { getBadgeData, updateBadge } from "./badges";
|
||||||
|
|
||||||
|
const staff = { id: 1, rank: 7, username: "admin" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequirePermission.mockResolvedValue(staff as never);
|
||||||
|
state.badges = [];
|
||||||
|
state.upserts = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getBadgeData", () => {
|
||||||
|
it("rejects callers without CATALOG_EDIT", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(getBadgeData({ code: "B1" })).rejects.toThrow("denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a null payload when the badge does not exist", async () => {
|
||||||
|
state.badges = [];
|
||||||
|
await expect(getBadgeData({ code: "MISSING" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns name and description for an existing badge", async () => {
|
||||||
|
state.badges = [{ badgeName: "B1", badgeDescription: "desc" }];
|
||||||
|
await expect(getBadgeData({ code: "B1" })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: { name: "B1", desc: "desc" },
|
||||||
|
});
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateBadge", () => {
|
||||||
|
it("inserts or updates the badge and revalidates", async () => {
|
||||||
|
const before = Date.now();
|
||||||
|
await updateBadge({ code: "B2", name: "N", desc: "D" });
|
||||||
|
|
||||||
|
expect(state.upserts).toHaveLength(1);
|
||||||
|
expect(state.upserts[0].table).toBe(WebsiteBadges);
|
||||||
|
const values = state.upserts[0].values as {
|
||||||
|
badgeKey: string;
|
||||||
|
badgeName: string;
|
||||||
|
badgeDescription: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
expect(values.badgeKey).toBe("B2");
|
||||||
|
expect(values.badgeName).toBe("N");
|
||||||
|
expect(values.badgeDescription).toBe("D");
|
||||||
|
expect(values.createdAt.getTime()).toBeGreaterThanOrEqual(before);
|
||||||
|
expect((state.upserts[0].conflict as { set: unknown }).set).toMatchObject({
|
||||||
|
badgeName: "N",
|
||||||
|
badgeDescription: "D",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/import/badges");
|
||||||
|
expect(mockRequirePermission).toHaveBeenCalledWith(PERMS.CATALOG_EDIT);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: true,
|
||||||
|
authenticated: true,
|
||||||
|
existing: [] as { id: number }[],
|
||||||
|
inserts: [] as { table: unknown; values: unknown }[],
|
||||||
|
updates: [] as { table: unknown; values: unknown }[],
|
||||||
|
deletes: [] as { table: unknown; where: unknown }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetApiAdminContext = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCanAccess = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/permissions", async () => {
|
||||||
|
const { PERMS } = await import("@/lib/permission-slugs");
|
||||||
|
return {
|
||||||
|
PERMS,
|
||||||
|
getApiAdminContext: mockGetApiAdminContext,
|
||||||
|
canAccess: mockCanAccess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||||
|
logAuthorizationEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||||
|
vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
|
||||||
|
const mockExtractClientIpAsync = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({
|
||||||
|
extractClientIpAsync: mockExtractClientIpAsync,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLogAudit = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: mockLogAudit }));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...createFakeDb((table) =>
|
||||||
|
table === schema.WebsiteBanner ? state.existing : [],
|
||||||
|
),
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
state.inserts.push({ table, values });
|
||||||
|
return [{ insertId: 5 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
state.updates.push({ table, values, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
state.deletes.push({ table, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WebsiteBanner } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createBanner,
|
||||||
|
deleteBanner,
|
||||||
|
updateBanner,
|
||||||
|
} from "./banners";
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
return {
|
||||||
|
session: { user: { id: 42, rank: 7, name: "admin" } },
|
||||||
|
permissions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.allowed = true;
|
||||||
|
state.authenticated = true;
|
||||||
|
state.existing = [];
|
||||||
|
state.inserts = [];
|
||||||
|
state.updates = [];
|
||||||
|
state.deletes = [];
|
||||||
|
mockGetApiAdminContext.mockReset();
|
||||||
|
mockGetApiAdminContext.mockImplementation(async () =>
|
||||||
|
state.authenticated ? session() : null,
|
||||||
|
);
|
||||||
|
mockCanAccess.mockImplementation(() => state.allowed);
|
||||||
|
mockExtractClientIpAsync.mockResolvedValue("127.0.0.1");
|
||||||
|
mockLogAudit.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBanner", () => {
|
||||||
|
it("creates a banner with schema defaults applied", async () => {
|
||||||
|
const result = await createBanner({ title: "T", image: "img.png" });
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: { id: 5 } });
|
||||||
|
expect(state.inserts).toHaveLength(1);
|
||||||
|
expect(state.inserts[0].table).toBe(WebsiteBanner);
|
||||||
|
expect(state.inserts[0].values).toMatchObject({
|
||||||
|
title: "T",
|
||||||
|
subtitle: "",
|
||||||
|
image: "img.png",
|
||||||
|
link: "",
|
||||||
|
color: "",
|
||||||
|
isActive: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
expect(mockLogAudit).toHaveBeenCalledWith({
|
||||||
|
userId: 42,
|
||||||
|
action: "banner_create",
|
||||||
|
target: "WebsiteBanner",
|
||||||
|
targetId: 5,
|
||||||
|
after: { title: "T" },
|
||||||
|
});
|
||||||
|
expect(mockCanAccess).toHaveBeenCalledWith({}, PERMS.BANNERS_EDIT, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows explicit optional fields", async () => {
|
||||||
|
await createBanner({
|
||||||
|
title: "Sale",
|
||||||
|
image: "sale.jpg",
|
||||||
|
link: "https://example.com",
|
||||||
|
color: "#fff",
|
||||||
|
isActive: 0,
|
||||||
|
sortOrder: 3,
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-02-01",
|
||||||
|
subtitle: "Big",
|
||||||
|
});
|
||||||
|
expect(state.inserts[0].values).toMatchObject({
|
||||||
|
link: "https://example.com",
|
||||||
|
color: "#fff",
|
||||||
|
isActive: 0,
|
||||||
|
sortOrder: 3,
|
||||||
|
startDate: "2026-01-01",
|
||||||
|
endDate: "2026-02-01",
|
||||||
|
subtitle: "Big",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies when the caller lacks permission", async () => {
|
||||||
|
state.allowed = false;
|
||||||
|
const result = await createBanner({ title: "T", image: "i" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Unauthorized");
|
||||||
|
expect(state.inserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies unauthenticated callers", async () => {
|
||||||
|
state.authenticated = false;
|
||||||
|
const result = await createBanner({ title: "T", image: "i" });
|
||||||
|
expect(result).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing title via validation", async () => {
|
||||||
|
const result = await createBanner({ image: "i" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Validation failed");
|
||||||
|
expect((result as { fieldErrors: Record<string, string[]> }).fieldErrors.title).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an out-of-range isActive value", async () => {
|
||||||
|
const result = await createBanner({ title: "T", image: "i", isActive: 2 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an over-long image url", async () => {
|
||||||
|
const result = await createBanner({
|
||||||
|
title: "T",
|
||||||
|
image: "i".repeat(501),
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
(result as { fieldErrors: Record<string, string[]> }).fieldErrors.image,
|
||||||
|
).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateBanner", () => {
|
||||||
|
it("fails when the banner does not exist", async () => {
|
||||||
|
state.existing = [];
|
||||||
|
const result = await updateBanner({ id: 9, title: "X" });
|
||||||
|
expect(result).toEqual({ ok: false, error: "Banner not found" });
|
||||||
|
expect(state.updates).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates only the whitelisted fields and audits", async () => {
|
||||||
|
state.existing = [{ id: 9 }];
|
||||||
|
const result = await updateBanner({
|
||||||
|
id: 9,
|
||||||
|
title: "X",
|
||||||
|
subtitle: "s",
|
||||||
|
image: "x.png",
|
||||||
|
link: "l",
|
||||||
|
color: "c",
|
||||||
|
isActive: 1,
|
||||||
|
sortOrder: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, data: { id: 9 } });
|
||||||
|
expect(state.updates).toHaveLength(1);
|
||||||
|
expect(state.updates[0].table).toBe(WebsiteBanner);
|
||||||
|
expect(state.updates[0].where).toEqual(eq(WebsiteBanner.id, 9));
|
||||||
|
const values = state.updates[0].values as Record<string, unknown>;
|
||||||
|
expect(values).not.toHaveProperty("id");
|
||||||
|
expect(values.title).toBe("X");
|
||||||
|
expect(mockLogAudit).toHaveBeenCalledWith({
|
||||||
|
userId: 42,
|
||||||
|
action: "banner_update",
|
||||||
|
target: "WebsiteBanner",
|
||||||
|
targetId: 9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive id", async () => {
|
||||||
|
state.existing = [{ id: 1 }];
|
||||||
|
const result = await updateBanner({ id: 0, title: "X" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBanner", () => {
|
||||||
|
it("deletes the banner and audits", async () => {
|
||||||
|
const result = await deleteBanner({ id: 3 });
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.deletes).toHaveLength(1);
|
||||||
|
expect(state.deletes[0].table).toBe(WebsiteBanner);
|
||||||
|
expect(state.deletes[0].where).toEqual(eq(WebsiteBanner.id, 3));
|
||||||
|
expect(mockLogAudit).toHaveBeenCalledWith({
|
||||||
|
userId: 42,
|
||||||
|
action: "banner_delete",
|
||||||
|
target: "WebsiteBanner",
|
||||||
|
targetId: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid id", async () => {
|
||||||
|
const result = await deleteBanner({ id: -1 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
deletes: [] as { table: unknown; where: unknown }[],
|
||||||
|
deleteError: null as Error | null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRequirePermission = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: mockRequirePermission,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", async () => {
|
||||||
|
const { PERMS } = await import("@/lib/permission-slugs");
|
||||||
|
return { PERMS };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn(), handlers: {} }));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCatalogFailure = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/features/catalog/server/errors", () => ({
|
||||||
|
catalogFailure: mockCatalogFailure,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUpdateBcOfferCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCreateBcOfferCommand = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/features/catalog/server/offer-commands", () => ({
|
||||||
|
updateBcOfferCommand: mockUpdateBcOfferCommand,
|
||||||
|
createBcOfferCommand: mockCreateBcOfferCommand,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUpdatePageCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCreatePageCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const mockDeletePageCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const mockReorderPagesCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const mockTogglePageCommand = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/features/catalog/server/page-commands", () => ({
|
||||||
|
updatePageCommand: mockUpdatePageCommand,
|
||||||
|
createPageCommand: mockCreatePageCommand,
|
||||||
|
deletePageCommand: mockDeletePageCommand,
|
||||||
|
reorderPagesCommand: mockReorderPagesCommand,
|
||||||
|
togglePageCommand: mockTogglePageCommand,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSendCatalogUpdate = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||||
|
sendCatalogUpdate: mockSendCatalogUpdate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockWithCatalogExport = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||||
|
withCatalogExport: mockWithCatalogExport,
|
||||||
|
catalogExportEnabled: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLogStaffActivity = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: mockLogStaffActivity,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
if (state.deleteError) {
|
||||||
|
const e = state.deleteError;
|
||||||
|
state.deleteError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.deletes.push({ table, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { CatalogItemsBc } from "@/lib/db";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
createBcItem,
|
||||||
|
createBcPage,
|
||||||
|
deleteBcItem,
|
||||||
|
deleteBcTreePage,
|
||||||
|
reorderBcCatalogPages,
|
||||||
|
reorderBcTreePage,
|
||||||
|
toggleBcPage,
|
||||||
|
updateBcItem,
|
||||||
|
updateBcPage,
|
||||||
|
} from "./catalog-bc";
|
||||||
|
|
||||||
|
const staff = { id: 3, rank: 6, username: "cat" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequirePermission.mockResolvedValue(staff as never);
|
||||||
|
state.deletes = [];
|
||||||
|
state.deleteError = null;
|
||||||
|
mockWithCatalogExport.mockImplementation(
|
||||||
|
async (fn: () => unknown) => await fn(),
|
||||||
|
);
|
||||||
|
mockCatalogFailure.mockReturnValue({ message: "Catalog op failed", status: 400 });
|
||||||
|
mockCreateBcOfferCommand.mockResolvedValue(501);
|
||||||
|
mockCreatePageCommand.mockResolvedValue(201);
|
||||||
|
mockSendCatalogUpdate.mockResolvedValue({});
|
||||||
|
mockLogStaffActivity.mockResolvedValue(undefined);
|
||||||
|
mockUpdateBcOfferCommand.mockResolvedValue({});
|
||||||
|
mockUpdatePageCommand.mockResolvedValue({});
|
||||||
|
mockDeletePageCommand.mockResolvedValue({});
|
||||||
|
mockReorderPagesCommand.mockResolvedValue({});
|
||||||
|
mockTogglePageCommand.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateBcPage", () => {
|
||||||
|
it("updates only allowed page fields and logs activity", async () => {
|
||||||
|
const result = await updateBcPage({
|
||||||
|
id: 1,
|
||||||
|
caption: "Renamed",
|
||||||
|
pageHeadline: "H",
|
||||||
|
foo: "filtered",
|
||||||
|
expected: { caption: "Old" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(mockUpdatePageCommand).toHaveBeenCalledWith(
|
||||||
|
"bc",
|
||||||
|
1,
|
||||||
|
{ caption: "Renamed", pageHeadline: "H" },
|
||||||
|
{ caption: "Old" },
|
||||||
|
staff.id,
|
||||||
|
);
|
||||||
|
expect(mockSendCatalogUpdate).toHaveBeenCalled();
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "bc_page_update",
|
||||||
|
description: "Updated BC catalog page #1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/admin/catalog/builder-club",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when no allowed field is present", async () => {
|
||||||
|
const result = await updateBcPage({ id: 1 });
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "No valid fields to update",
|
||||||
|
});
|
||||||
|
expect(mockUpdatePageCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps command failures through catalogFailure", async () => {
|
||||||
|
mockUpdatePageCommand.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
const result = await updateBcPage({ id: 1, caption: "X" });
|
||||||
|
expect(result).toEqual({ ok: false, error: "Catalog op failed" });
|
||||||
|
expect(mockCatalogFailure).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bubbles up permission errors", async () => {
|
||||||
|
mockRequirePermission.mockRejectedValueOnce(new Error("denied"));
|
||||||
|
await expect(updateBcPage({ id: 1, caption: "X" })).rejects.toThrow(
|
||||||
|
"denied",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBcItem", () => {
|
||||||
|
it("deletes the bc item and logs activity", async () => {
|
||||||
|
const result = await deleteBcItem({ id: 7 });
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(state.deletes).toHaveLength(1);
|
||||||
|
expect(state.deletes[0].table).toBe(CatalogItemsBc);
|
||||||
|
expect(state.deletes[0].where).toEqual(eq(CatalogItemsBc.id, 7));
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "bc_item_delete",
|
||||||
|
description: "Deleted BC catalog item #7",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockSendCatalogUpdate).toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/admin/catalog/builder-club",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates delete failures", async () => {
|
||||||
|
state.deleteError = new Error("db down");
|
||||||
|
await expect(deleteBcItem({ id: 7 })).rejects.toThrow("db down");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateBcItem", () => {
|
||||||
|
it("updates the offer with only allowed keys", async () => {
|
||||||
|
const result = await updateBcItem({
|
||||||
|
id: 12,
|
||||||
|
catalogName: "New name",
|
||||||
|
orderNumber: 4,
|
||||||
|
extradata: "ed",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(mockUpdateBcOfferCommand).toHaveBeenCalledWith(12, {
|
||||||
|
catalogName: "New name",
|
||||||
|
orderNumber: 4,
|
||||||
|
extradata: "ed",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/admin/catalog/builder-club",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when no allowed key is present", async () => {
|
||||||
|
const result = await updateBcItem({ id: 12 });
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "No valid fields to update",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps command failures through catalogFailure", async () => {
|
||||||
|
mockUpdateBcOfferCommand.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
const result = await updateBcItem({ id: 12, catalogName: "X" });
|
||||||
|
expect(result).toEqual({ ok: false, error: "Catalog op failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBcItem", () => {
|
||||||
|
it("creates the offer and returns its id", async () => {
|
||||||
|
const result = await createBcItem({
|
||||||
|
pageId: 8,
|
||||||
|
itemIds: "1;2",
|
||||||
|
catalogName: "Bundle",
|
||||||
|
orderNumber: 1,
|
||||||
|
extradata: "",
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: true, data: { id: 501 } });
|
||||||
|
expect(mockCreateBcOfferCommand).toHaveBeenCalledWith({
|
||||||
|
pageId: 8,
|
||||||
|
itemIds: "1;2",
|
||||||
|
catalogName: "Bundle",
|
||||||
|
orderNumber: 1,
|
||||||
|
extradata: "",
|
||||||
|
});
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "bc_item_create",
|
||||||
|
description: "Created BC catalog item #501",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleBcPage", () => {
|
||||||
|
it("toggles the requested field", async () => {
|
||||||
|
const result = await toggleBcPage({ id: 2, field: "visible" });
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(mockTogglePageCommand).toHaveBeenCalledWith("bc", 2, "visible", staff.id);
|
||||||
|
expect(mockSendCatalogUpdate).toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/admin/catalog/builder-club",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createBcPage", () => {
|
||||||
|
it("creates a page with default layout fields", async () => {
|
||||||
|
const result = await createBcPage({ caption: "Page", parentId: -1 });
|
||||||
|
expect(result).toEqual({ ok: true, data: { id: 201 } });
|
||||||
|
expect(mockCreatePageCommand).toHaveBeenCalledWith("bc", {
|
||||||
|
caption: "Page",
|
||||||
|
parentId: -1,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
iconColor: 0,
|
||||||
|
iconImage: 0,
|
||||||
|
orderNum: 0,
|
||||||
|
visible: "1",
|
||||||
|
enabled: "1",
|
||||||
|
pageHeadline: "",
|
||||||
|
pageTeaser: "",
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
|
||||||
|
expect(mockLogStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "bc_page_create",
|
||||||
|
description: 'Created BC catalog page "Page"',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes explicit values through", async () => {
|
||||||
|
await createBcPage({
|
||||||
|
caption: "Page",
|
||||||
|
parentId: 1,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
iconColor: 3,
|
||||||
|
iconImage: 2,
|
||||||
|
orderNum: 9,
|
||||||
|
enabled: "0",
|
||||||
|
visible: "0",
|
||||||
|
});
|
||||||
|
expect(mockCreatePageCommand).toHaveBeenCalledWith("bc", {
|
||||||
|
caption: "Page",
|
||||||
|
parentId: 1,
|
||||||
|
pageLayout: "default_3x3",
|
||||||
|
iconColor: 3,
|
||||||
|
iconImage: 2,
|
||||||
|
orderNum: 9,
|
||||||
|
visible: "0",
|
||||||
|
enabled: "0",
|
||||||
|
pageHeadline: "",
|
||||||
|
pageTeaser: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reorderBcTreePage", () => {
|
||||||
|
it("reparents with new order number", async () => {
|
||||||
|
const result = await reorderBcTreePage({
|
||||||
|
pageId: 5,
|
||||||
|
newParentId: 3,
|
||||||
|
newOrderNum: 2,
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockUpdatePageCommand).toHaveBeenCalledWith("bc", 5, {
|
||||||
|
parentId: 3,
|
||||||
|
orderNum: 2,
|
||||||
|
});
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/admin/catalog/builder-club",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows dropping to the root (undefined parent)", async () => {
|
||||||
|
await reorderBcTreePage({ pageId: 5, newOrderNum: 1 });
|
||||||
|
expect(mockUpdatePageCommand).toHaveBeenCalledWith("bc", 5, {
|
||||||
|
parentId: undefined,
|
||||||
|
orderNum: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteBcTreePage", () => {
|
||||||
|
it("deletes with cascade mode", async () => {
|
||||||
|
const result = await deleteBcTreePage({ pageId: 4, mode: "cascade" });
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockDeletePageCommand).toHaveBeenCalledWith("bc", 4, "cascade");
|
||||||
|
expect(mockSendCatalogUpdate).toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes with reparent mode", async () => {
|
||||||
|
await deleteBcTreePage({ pageId: 4, mode: "reparent" });
|
||||||
|
expect(mockDeletePageCommand).toHaveBeenCalledWith("bc", 4, "reparent");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reorderBcCatalogPages", () => {
|
||||||
|
it("reorders pages via the command", async () => {
|
||||||
|
const input = { parentId: 1, ids: [2, 3], expectedIds: [3, 2] };
|
||||||
|
const result = await reorderBcCatalogPages(input);
|
||||||
|
expect(result).toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockReorderPagesCommand).toHaveBeenCalledWith("bc", input);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/catalog");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/admin/catalog/builder-club",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: true,
|
||||||
|
authenticated: true,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
target: [] as { rank: number }[],
|
||||||
|
rankRows: [] as { id: number }[],
|
||||||
|
rankRowsError: null as Error | null,
|
||||||
|
userUpdates: [] as { values: unknown }[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetApiAdminContext = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCanAccess = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/permissions", async () => {
|
||||||
|
const { PERMS } = await import("@/lib/permission-slugs");
|
||||||
|
return {
|
||||||
|
PERMS,
|
||||||
|
getApiAdminContext: mockGetApiAdminContext,
|
||||||
|
canAccess: mockCanAccess,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
||||||
|
logAuthorizationEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||||
|
vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
|
||||||
|
vi.mock("@/lib/foundation/security", () => ({
|
||||||
|
extractClientIpAsync: async () => "127.0.0.1",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSend = vi.hoisted(() => vi.fn());
|
||||||
|
const mockUpdateCatalog = vi.hoisted(() => vi.fn());
|
||||||
|
const mockUpdateWordFilter = vi.hoisted(() => vi.fn());
|
||||||
|
const mockDisconnectUser = vi.hoisted(() => vi.fn());
|
||||||
|
const mockAlertUser = vi.hoisted(() => vi.fn());
|
||||||
|
const mockForwardUser = vi.hoisted(() => vi.fn());
|
||||||
|
const mockGiveCredits = vi.hoisted(() => vi.fn());
|
||||||
|
const mockGiveDuckets = vi.hoisted(() => vi.fn());
|
||||||
|
const mockGiveDiamonds = vi.hoisted(() => vi.fn());
|
||||||
|
const mockGiveBadge = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSetMotto = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSetRank = vi.hoisted(() => vi.fn());
|
||||||
|
const mockExecuteCommand = vi.hoisted(() => vi.fn());
|
||||||
|
const mockSendGift = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: {
|
||||||
|
send: mockSend,
|
||||||
|
updateCatalog: mockUpdateCatalog,
|
||||||
|
updateWordFilter: mockUpdateWordFilter,
|
||||||
|
disconnectUser: mockDisconnectUser,
|
||||||
|
alertUser: mockAlertUser,
|
||||||
|
forwardUser: mockForwardUser,
|
||||||
|
giveCredits: mockGiveCredits,
|
||||||
|
giveDuckets: mockGiveDuckets,
|
||||||
|
giveDiamonds: mockGiveDiamonds,
|
||||||
|
giveBadge: mockGiveBadge,
|
||||||
|
setMotto: mockSetMotto,
|
||||||
|
setRank: mockSetRank,
|
||||||
|
executeCommand: mockExecuteCommand,
|
||||||
|
sendGift: mockSendGift,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
queryRows: async () => {
|
||||||
|
if (state.rankRowsError) {
|
||||||
|
const e = state.rankRowsError;
|
||||||
|
state.rankRowsError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return state.rankRows;
|
||||||
|
},
|
||||||
|
db: {
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({
|
||||||
|
limit: () => state.target,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.userUpdates.push({ table, values });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import {
|
||||||
|
alertUser,
|
||||||
|
disconnectUser,
|
||||||
|
executeCommand,
|
||||||
|
forwardUser,
|
||||||
|
giveBadge,
|
||||||
|
giveCredits,
|
||||||
|
giveDiamonds,
|
||||||
|
giveDuckets,
|
||||||
|
hotelAlert,
|
||||||
|
setMotto,
|
||||||
|
setRank,
|
||||||
|
sendGift,
|
||||||
|
updateCatalog,
|
||||||
|
updateNavigator,
|
||||||
|
updateWordFilter,
|
||||||
|
} from "./commandocentrum";
|
||||||
|
|
||||||
|
const RCON_FAIL = "RCON command failed. Is the emulator running?";
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
return {
|
||||||
|
session: { user: { id: 42, rank: 7, name: "admin" } },
|
||||||
|
permissions: { isSuperAdmin: state.isSuperAdmin },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.allowed = true;
|
||||||
|
state.authenticated = true;
|
||||||
|
state.isSuperAdmin = false;
|
||||||
|
state.target = [];
|
||||||
|
state.rankRows = [{ id: 1 }];
|
||||||
|
state.rankRowsError = null;
|
||||||
|
state.userUpdates = [];
|
||||||
|
mockGetApiAdminContext.mockReset();
|
||||||
|
mockGetApiAdminContext.mockImplementation(async () =>
|
||||||
|
state.authenticated ? session() : null,
|
||||||
|
);
|
||||||
|
mockCanAccess.mockImplementation(() => state.allowed);
|
||||||
|
mockUpdateCatalog.mockReset();
|
||||||
|
mockUpdateCatalog.mockResolvedValue(true);
|
||||||
|
mockUpdateWordFilter.mockResolvedValue(true);
|
||||||
|
mockSend.mockResolvedValue(true);
|
||||||
|
mockDisconnectUser.mockResolvedValue(true);
|
||||||
|
mockAlertUser.mockResolvedValue(true);
|
||||||
|
mockForwardUser.mockResolvedValue(true);
|
||||||
|
mockGiveCredits.mockResolvedValue(true);
|
||||||
|
mockGiveDuckets.mockResolvedValue(true);
|
||||||
|
mockGiveDiamonds.mockResolvedValue(true);
|
||||||
|
mockGiveBadge.mockResolvedValue(true);
|
||||||
|
mockSetMotto.mockResolvedValue(true);
|
||||||
|
mockSetRank.mockResolvedValue(true);
|
||||||
|
mockExecuteCommand.mockResolvedValue(true);
|
||||||
|
mockSendGift.mockResolvedValue(true);
|
||||||
|
mockRevalidatePath.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("simple rcon commands", () => {
|
||||||
|
it.each([
|
||||||
|
["updateCatalog", updateCatalog, () => mockUpdateCatalog],
|
||||||
|
["updateWordFilter", updateWordFilter, () => mockUpdateWordFilter],
|
||||||
|
] as const)("%s succeeds and revalidates", async (_name, action, rconFn) => {
|
||||||
|
await expect(action()).resolves.toEqual({ ok: true, data: {} });
|
||||||
|
expect(rconFn()).toHaveBeenCalled();
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/commandocentrum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateCatalog fails when rcon reports failure", async () => {
|
||||||
|
mockUpdateCatalog.mockResolvedValueOnce(false);
|
||||||
|
await expect(updateCatalog()).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: RCON_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateNavigator", () => {
|
||||||
|
it("sends a null payload and revalidates", async () => {
|
||||||
|
await expect(updateNavigator()).resolves.toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockSend).toHaveBeenCalledWith("updatenavigator", null);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/commandocentrum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when rcon reports failure", async () => {
|
||||||
|
mockSend.mockResolvedValueOnce(false);
|
||||||
|
await expect(updateNavigator()).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: RCON_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hotelAlert", () => {
|
||||||
|
it("broadcasts a normalized message", async () => {
|
||||||
|
await expect(hotelAlert({ message: " hello " })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockSend).toHaveBeenCalledWith("hotelalert", {
|
||||||
|
message: "hello",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a blank message", async () => {
|
||||||
|
const result = await hotelAlert({ message: " " });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an over-long message", async () => {
|
||||||
|
const result = await hotelAlert({ message: "x".repeat(513) });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when delivery fails", async () => {
|
||||||
|
mockSend.mockResolvedValueOnce(false);
|
||||||
|
await expect(hotelAlert({ message: "hi" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: RCON_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disconnectUser", () => {
|
||||||
|
it("disconnects the target user", async () => {
|
||||||
|
await expect(
|
||||||
|
disconnectUser({ userId: 5, username: "bob" }),
|
||||||
|
).resolves.toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockDisconnectUser).toHaveBeenCalledWith(5, "bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a blank username", async () => {
|
||||||
|
const result = await disconnectUser({ userId: 5, username: " " });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.error).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("alertUser", () => {
|
||||||
|
it("alerts a specific user", async () => {
|
||||||
|
await expect(alertUser({ userId: 3, message: "m" })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockAlertUser).toHaveBeenCalledWith(3, "m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when delivery fails", async () => {
|
||||||
|
mockAlertUser.mockResolvedValueOnce(false);
|
||||||
|
await expect(alertUser({ userId: 3, message: "m" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: RCON_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("forwardUser", () => {
|
||||||
|
it("forwards a user to a room", async () => {
|
||||||
|
await expect(forwardUser({ userId: 4, roomId: 9 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockForwardUser).toHaveBeenCalledWith(4, 9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("giveCredits / giveDuckets / giveDiamonds", () => {
|
||||||
|
it("gives credits", async () => {
|
||||||
|
await expect(giveCredits({ userId: 1, credits: 100 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockGiveCredits).toHaveBeenCalledWith(1, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives duckets", async () => {
|
||||||
|
await expect(giveDuckets({ userId: 1, amount: 50 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockGiveDuckets).toHaveBeenCalledWith(1, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives diamonds", async () => {
|
||||||
|
await expect(giveDiamonds({ userId: 1, amount: 25 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockGiveDiamonds).toHaveBeenCalledWith(1, 25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative amounts", async () => {
|
||||||
|
await expect(giveCredits({ userId: 1, credits: 0 })).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("giveBadge / setMotto / executeCommand", () => {
|
||||||
|
it("gives a badge", async () => {
|
||||||
|
await expect(giveBadge({ userId: 2, badge: " B1 " })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockGiveBadge).toHaveBeenCalledWith(2, "B1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets a motto", async () => {
|
||||||
|
await expect(setMotto({ userId: 2, motto: "hey" })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockSetMotto).toHaveBeenCalledWith(2, "hey");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes a command as a user", async () => {
|
||||||
|
await expect(
|
||||||
|
executeCommand({ userId: 2, command: ":flag" }),
|
||||||
|
).resolves.toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockExecuteCommand).toHaveBeenCalledWith(2, ":flag");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when delivery fails", async () => {
|
||||||
|
mockGiveBadge.mockResolvedValueOnce(false);
|
||||||
|
await expect(giveBadge({ userId: 2, badge: "B1" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: RCON_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendGift", () => {
|
||||||
|
it("uses the default message when none is supplied", async () => {
|
||||||
|
await expect(sendGift({ userId: 2, itemId: 10 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockSendGift).toHaveBeenCalledWith(2, 10, "Here is a gift.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the supplied message", async () => {
|
||||||
|
await expect(
|
||||||
|
sendGift({ userId: 2, itemId: 10, message: "Happy gift!" }),
|
||||||
|
).resolves.toEqual({ ok: true, data: {} });
|
||||||
|
expect(mockSendGift).toHaveBeenCalledWith(2, 10, "Happy gift!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setRank", () => {
|
||||||
|
it("errors when the target user does not exist", async () => {
|
||||||
|
state.target = [];
|
||||||
|
await expect(setRank({ userId: 99, rank: 2 })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "User not found",
|
||||||
|
});
|
||||||
|
expect(mockSetRank).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when the requested rank does not exist", async () => {
|
||||||
|
state.target = [{ rank: 1 }];
|
||||||
|
state.rankRows = [];
|
||||||
|
await expect(setRank({ userId: 1, rank: 99 })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Rank does not exist",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when the rank lookup query throws", async () => {
|
||||||
|
state.target = [{ rank: 1 }];
|
||||||
|
state.rankRowsError = new Error("db down");
|
||||||
|
await expect(setRank({ userId: 1, rank: 2 })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Rank does not exist",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks non-super staff from changing a peer or higher rank", async () => {
|
||||||
|
state.target = [{ rank: 7 }];
|
||||||
|
state.rankRows = [{ id: 7 }];
|
||||||
|
state.isSuperAdmin = false;
|
||||||
|
await expect(setRank({ userId: 1, rank: 2 })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Cannot change rank of a user at or above your rank",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks non-super staff from granting their own rank or higher", async () => {
|
||||||
|
state.target = [{ rank: 1 }];
|
||||||
|
state.rankRows = [{ id: 7 }];
|
||||||
|
state.isSuperAdmin = false;
|
||||||
|
await expect(setRank({ userId: 1, rank: 8 })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Cannot set a rank equal to or above your own",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the rank via rcon and the database for a normal staff member", async () => {
|
||||||
|
state.target = [{ rank: 1 }];
|
||||||
|
state.rankRows = [{ id: 2 }];
|
||||||
|
state.isSuperAdmin = false;
|
||||||
|
await expect(setRank({ userId: 1, rank: 2 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockSetRank).toHaveBeenCalledWith(1, 2);
|
||||||
|
expect(state.userUpdates).toHaveLength(1);
|
||||||
|
expect(state.userUpdates[0].values).toEqual({ rank: 2 });
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/commandocentrum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a super admin promote freely", async () => {
|
||||||
|
state.target = [{ rank: 7 }];
|
||||||
|
state.rankRows = [{ id: 10 }];
|
||||||
|
state.isSuperAdmin = true;
|
||||||
|
await expect(setRank({ userId: 1, rank: 10 })).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(mockSetRank).toHaveBeenCalledWith(1, 10);
|
||||||
|
expect(state.userUpdates).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("access control", () => {
|
||||||
|
it("denies callers without RCON_EXECUTE", async () => {
|
||||||
|
state.allowed = false;
|
||||||
|
await expect(updateCatalog()).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
expect(mockUpdateCatalog).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies unauthenticated callers", async () => {
|
||||||
|
state.authenticated = false;
|
||||||
|
const result = await updateCatalog();
|
||||||
|
expect(result).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an rcon failure on setMotto", async () => {
|
||||||
|
mockSetMotto.mockResolvedValueOnce(false);
|
||||||
|
await expect(setMotto({ userId: 2, motto: "x" })).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: RCON_FAIL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
rewardState: {} as Record<string, unknown> | null,
|
||||||
|
loadError: null as Error | null,
|
||||||
|
insertError: null as Error | null,
|
||||||
|
deleteError: null as Error | null,
|
||||||
|
inserts: [] as { table: unknown; values: unknown }[],
|
||||||
|
deletes: [] as { table: unknown; where: unknown }[],
|
||||||
|
nextId: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRedirect = vi.hoisted(() =>
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
const err = new Error(`NEXT_REDIRECT: ${url}`);
|
||||||
|
(err as never as { digest: string }).digest =
|
||||||
|
`NEXT_REDIRECT;replace;${url};307;;`;
|
||||||
|
throw err;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: mockRedirect }));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuth = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
|
||||||
|
|
||||||
|
const mockRateLimit = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: mockRateLimit }));
|
||||||
|
|
||||||
|
const mockLogger = vi.hoisted(() => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: mockLogger }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/daily-rewards", () => ({
|
||||||
|
loadDailyRewardState: async () => {
|
||||||
|
if (state.loadError) {
|
||||||
|
const e = state.loadError;
|
||||||
|
state.loadError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return state.rewardState ?? {};
|
||||||
|
},
|
||||||
|
isRewardCurrency: (value: string) =>
|
||||||
|
["credits", "duckets", "diamonds", "points"].includes(value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSendCurrency = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/send-currency", () => ({
|
||||||
|
currencyDb: { user: {}, usersCurrency: {} },
|
||||||
|
sendCurrency: mockSendCurrency,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: {} }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
if (state.insertError) {
|
||||||
|
const e = state.insertError;
|
||||||
|
state.insertError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.inserts.push({ table, values });
|
||||||
|
return [{ insertId: state.nextId }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => {
|
||||||
|
if (state.deleteError) {
|
||||||
|
const e = state.deleteError;
|
||||||
|
state.deleteError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.deletes.push({ table, where });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { WebsiteDailyRewardClaims } from "@/lib/db";
|
||||||
|
import { claimDailyReward } from "./daily-reward";
|
||||||
|
|
||||||
|
function claimState(overrides: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
today: "2026-09-21",
|
||||||
|
alreadyClaimedToday: false,
|
||||||
|
nextStreak: 3,
|
||||||
|
nextReward: { day: 3, currency: "credits", amount: 100 },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockAuth.mockReset();
|
||||||
|
mockAuth.mockResolvedValue({ user: { id: "7" } });
|
||||||
|
mockRateLimit.mockReset();
|
||||||
|
mockRateLimit.mockResolvedValue({ ok: true });
|
||||||
|
mockSendCurrency.mockReset();
|
||||||
|
mockSendCurrency.mockResolvedValue(true);
|
||||||
|
state.rewardState = null;
|
||||||
|
state.loadError = null;
|
||||||
|
state.insertError = null;
|
||||||
|
state.deleteError = null;
|
||||||
|
state.inserts = [];
|
||||||
|
state.deletes = [];
|
||||||
|
state.nextId = 100;
|
||||||
|
mockRedirect.mockClear();
|
||||||
|
mockRevalidatePath.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("claimDailyReward", () => {
|
||||||
|
it("redirects to login when not signed in", async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce(null);
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /login",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to login for a non-numeric or non-positive user id", async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce({ user: { id: "x" } });
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /login",
|
||||||
|
);
|
||||||
|
mockAuth.mockResolvedValueOnce({ user: { id: "0" } });
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /login",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with a ratelimit outcome when throttled", async () => {
|
||||||
|
mockRateLimit.mockResolvedValueOnce({ ok: false });
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=ratelimit",
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports daily=disabled when the feature is off", async () => {
|
||||||
|
state.rewardState = claimState({ enabled: false });
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=disabled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports already_claimed when the user claimed today", async () => {
|
||||||
|
state.rewardState = claimState({ alreadyClaimedToday: true });
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=already_claimed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports bad_config when no reward is configured", async () => {
|
||||||
|
state.rewardState = claimState({ nextReward: null });
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=bad_config",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports bad_config for an unsupported currency", async () => {
|
||||||
|
state.rewardState = claimState({
|
||||||
|
nextReward: { day: 3, currency: "gems", amount: 100 },
|
||||||
|
});
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=bad_config",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports bad_config for a non-positive amount", async () => {
|
||||||
|
state.rewardState = claimState({
|
||||||
|
nextReward: { day: 3, currency: "credits", amount: 0 },
|
||||||
|
});
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=bad_config",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a claim, pays the reward and redirects to daily=claimed", async () => {
|
||||||
|
state.rewardState = claimState({});
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=claimed",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(state.inserts).toHaveLength(1);
|
||||||
|
expect(state.inserts[0].table).toBe(WebsiteDailyRewardClaims);
|
||||||
|
expect(state.inserts[0].values).toEqual({
|
||||||
|
userId: 7,
|
||||||
|
claimDate: new Date("2026-09-21T00:00:00Z"),
|
||||||
|
streak: 3,
|
||||||
|
rewardDay: 3,
|
||||||
|
currency: "credits",
|
||||||
|
amount: 100,
|
||||||
|
});
|
||||||
|
expect(mockSendCurrency).toHaveBeenCalledWith(
|
||||||
|
{ rcon: expect.anything(), db: expect.anything() },
|
||||||
|
7,
|
||||||
|
"credits",
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a duplicate-key insert as already_claimed", async () => {
|
||||||
|
state.rewardState = claimState({});
|
||||||
|
state.insertError = Object.assign(new Error("dup"), {
|
||||||
|
cause: { code: "ER_DUP_ENTRY" },
|
||||||
|
});
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=already_claimed",
|
||||||
|
);
|
||||||
|
expect(mockSendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs and surfaces a generic insert failure as daily=error", async () => {
|
||||||
|
state.rewardState = claimState({});
|
||||||
|
state.insertError = new Error("db down");
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=error",
|
||||||
|
);
|
||||||
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
|
"Daily reward claim insert failed",
|
||||||
|
expect.objectContaining({ userId: 7 }),
|
||||||
|
);
|
||||||
|
expect(mockSendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls the claim back when the reward cannot be delivered", async () => {
|
||||||
|
state.rewardState = claimState({});
|
||||||
|
mockSendCurrency.mockRejectedValueOnce(new Error("rcon down"));
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=error",
|
||||||
|
);
|
||||||
|
expect(state.deletes).toHaveLength(1);
|
||||||
|
expect(state.deletes[0].table).toBe(WebsiteDailyRewardClaims);
|
||||||
|
expect(state.deletes[0].where).toEqual(
|
||||||
|
eq(WebsiteDailyRewardClaims.id, BigInt(100)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports error when the rollback delete also fails", async () => {
|
||||||
|
state.rewardState = claimState({});
|
||||||
|
mockSendCurrency.mockRejectedValueOnce(new Error("rcon down"));
|
||||||
|
state.deleteError = new Error("rollback failed");
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a non-redirect error from state loading into daily=error", async () => {
|
||||||
|
state.loadError = new Error("state load failed");
|
||||||
|
await expect(claimDailyReward(new FormData())).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /me?daily=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
badges: [] as (
|
||||||
|
| { id: number; badgePath: string; published: boolean }
|
||||||
|
| undefined
|
||||||
|
)[],
|
||||||
|
buyers: [] as { credits: number }[],
|
||||||
|
price: "50",
|
||||||
|
txExisting: [] as { id: number }[],
|
||||||
|
txMax: null as number | null,
|
||||||
|
txError: null as Error | null,
|
||||||
|
txUpdates: [] as { table: unknown; values: unknown }[],
|
||||||
|
txInserted: [] as { table: unknown; values: unknown }[],
|
||||||
|
giveBadgeError: null as Error | null,
|
||||||
|
caughtErrors: [] as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRedirect = vi.hoisted(() =>
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
const err = new Error(`NEXT_REDIRECT: ${url}`);
|
||||||
|
(err as never as { digest: string }).digest =
|
||||||
|
`NEXT_REDIRECT;replace;${url};307;;`;
|
||||||
|
throw err;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: mockRedirect }));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: mockRevalidatePath,
|
||||||
|
unstable_cache: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuth = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: mockAuth }));
|
||||||
|
|
||||||
|
const mockClientIp = vi.hoisted(() => vi.fn());
|
||||||
|
const mockRateLimit = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
clientIp: mockClientIp,
|
||||||
|
rateLimit: mockRateLimit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLogServerError = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/server-log", () => ({ logServerError: mockLogServerError }));
|
||||||
|
|
||||||
|
const mockGiveBadge = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: mockGiveBadge } }));
|
||||||
|
|
||||||
|
const mockSiteSettingsGet = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { get: mockSiteSettingsGet },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
|
||||||
|
function txSelect(fields: unknown) {
|
||||||
|
const isExisting = (fields as { id?: unknown }).id !== undefined;
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () =>
|
||||||
|
isExisting
|
||||||
|
? { limit: () => state.txExisting }
|
||||||
|
: { limit: () => [{ maxSlot: state.txMax }] },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
select: (fields: unknown) => {
|
||||||
|
if ((fields as { credits?: unknown }).credits !== undefined) {
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({ limit: () => state.buyers }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
from: () => ({
|
||||||
|
where: () => ({ limit: () => state.badges }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
transaction: (fn: (t: unknown) => unknown) =>
|
||||||
|
fn({
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
try {
|
||||||
|
if (state.txError) {
|
||||||
|
const e = state.txError;
|
||||||
|
state.txError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.txUpdates.push({ table, values });
|
||||||
|
return [{ affectedRows: 1 }];
|
||||||
|
} catch (e) {
|
||||||
|
state.caughtErrors.push(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
select: txSelect,
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
try {
|
||||||
|
if (state.txError) {
|
||||||
|
const e = state.txError;
|
||||||
|
state.txError = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
state.txInserted.push({ table, values });
|
||||||
|
return [{ insertId: 1 }];
|
||||||
|
} catch (e) {
|
||||||
|
state.caughtErrors.push(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { User, UsersBadges } from "@/lib/db";
|
||||||
|
import { buyBadge } from "./draw-badge";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockAuth.mockReset();
|
||||||
|
mockAuth.mockResolvedValue({ user: { id: "7" } });
|
||||||
|
mockClientIp.mockReset();
|
||||||
|
mockClientIp.mockResolvedValue("127.0.0.1");
|
||||||
|
mockRateLimit.mockReset();
|
||||||
|
mockRateLimit.mockResolvedValue({ ok: true });
|
||||||
|
mockSiteSettingsGet.mockReset();
|
||||||
|
mockSiteSettingsGet.mockResolvedValue(state.price);
|
||||||
|
mockGiveBadge.mockReset();
|
||||||
|
mockGiveBadge.mockResolvedValue(true);
|
||||||
|
state.badges = [];
|
||||||
|
state.buyers = [];
|
||||||
|
state.txExisting = [];
|
||||||
|
state.txMax = null;
|
||||||
|
state.txError = null;
|
||||||
|
state.txUpdates = [];
|
||||||
|
state.txInserted = [];
|
||||||
|
state.giveBadgeError = null;
|
||||||
|
mockRedirect.mockClear();
|
||||||
|
mockRevalidatePath.mockClear();
|
||||||
|
mockLogServerError.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function form(id: string) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("id", id);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buyBadge", () => {
|
||||||
|
it("redirects to login when not signed in", async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce(null);
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow("NEXT_REDIRECT: /login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to login for a non-numeric user id", async () => {
|
||||||
|
mockAuth.mockResolvedValueOnce({ user: { id: "abc" } });
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow("NEXT_REDIRECT: /login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing or malformed badge id before any work", async () => {
|
||||||
|
await expect(buyBadge(form(""))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(buyBadge(form("1abc"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(buyBadge(form("1.5"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=invalid",
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(mockRateLimit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with a ratelimit outcome when throttled", async () => {
|
||||||
|
mockRateLimit.mockResolvedValueOnce({ ok: false });
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(mockRateLimit).toHaveBeenCalledWith("draw-badge-buy:7", 5, 60_000);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/draw-badge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports invalid when the badge row does not exist", async () => {
|
||||||
|
state.badges = [];
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=invalid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports invalid for an unpublished badge", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: false }];
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=invalid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports invalid when the derived badge code is empty", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "!!!", published: true }];
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=invalid",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports credits when the buyer has too few credits", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 10 }];
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=credits",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports credits when the buyer row does not exist", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [];
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=credits",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("buys a new badge, deducts credits and redirects with the code", async () => {
|
||||||
|
state.badges = [
|
||||||
|
{ id: 1, badgePath: "album1584/MYBADGE.gif", published: true },
|
||||||
|
];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txMax = 4;
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?bought=MYBADGE",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockSiteSettingsGet).toHaveBeenCalledWith("drawbadge.price", "50");
|
||||||
|
expect(state.txUpdates).toHaveLength(1);
|
||||||
|
expect(state.txUpdates[0].table).toBe(User);
|
||||||
|
expect(state.txUpdates[0].values).toEqual({
|
||||||
|
credits: expect.objectContaining({ queryChunks: expect.any(Array) }),
|
||||||
|
});
|
||||||
|
expect(state.txInserted).toHaveLength(1);
|
||||||
|
expect(state.txInserted[0].table).toBe(UsersBadges);
|
||||||
|
expect(state.txInserted[0].values).toEqual({
|
||||||
|
userId: 7,
|
||||||
|
slotId: 5,
|
||||||
|
badgeCode: "MYBADGE",
|
||||||
|
});
|
||||||
|
expect(mockGiveBadge).toHaveBeenCalledWith(7, "MYBADGE");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/draw-badge");
|
||||||
|
console.log("DEBUG caught:", state.caughtErrors.map((e) => (e as Error).message ?? e));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing badge slot instead of creating a new one", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txExisting = [{ id: 9 }];
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?bought=MYBADGE",
|
||||||
|
);
|
||||||
|
expect(state.txInserted).toHaveLength(0);
|
||||||
|
expect(mockGiveBadge).toHaveBeenCalledWith(7, "MYBADGE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an empty slot when no existing badge slots exist", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txMax = null;
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?bought=MYBADGE",
|
||||||
|
);
|
||||||
|
expect(state.txInserted[0].values).toEqual({
|
||||||
|
userId: 7,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "MYBADGE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the ledger transaction when the price is zero", async () => {
|
||||||
|
state.price = "0";
|
||||||
|
state.badges = [{ id: 1, badgePath: "FREE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 0 }];
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?bought=FREE",
|
||||||
|
);
|
||||||
|
expect(state.txUpdates).toHaveLength(0);
|
||||||
|
expect(state.txInserted).toHaveLength(0);
|
||||||
|
expect(mockGiveBadge).toHaveBeenCalledWith(7, "FREE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the default price for an invalid setting", async () => {
|
||||||
|
state.price = "abc";
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txMax = 0;
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?bought=MYBADGE",
|
||||||
|
);
|
||||||
|
expect(state.txUpdates[0].values).toEqual({
|
||||||
|
credits: expect.objectContaining({ queryChunks: expect.any(Array) }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still counts the purchase when the live rcon grant fails", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txMax = 0;
|
||||||
|
mockGiveBadge.mockRejectedValueOnce(new Error("emulator down"));
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?bought=MYBADGE",
|
||||||
|
);
|
||||||
|
expect(mockLogServerError).toHaveBeenCalledWith(
|
||||||
|
"drawbadge.give_failed",
|
||||||
|
expect.objectContaining({ message: "emulator down" }),
|
||||||
|
{ userId: 7, code: "MYBADGE" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips unsafe characters and caps the badge code at 32 characters", async () => {
|
||||||
|
const long = `${"a".repeat(40)}!bad.gif`;
|
||||||
|
state.badges = [{ id: 1, badgePath: long, published: true }];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txMax = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await buyBadge(form("1"));
|
||||||
|
} catch (e) {
|
||||||
|
console.log("CAUGHT ERROR:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports fail when an unexpected error is thrown inside the transaction", async () => {
|
||||||
|
state.badges = [{ id: 1, badgePath: "MYBADGE.gif", published: true }];
|
||||||
|
state.buyers = [{ credits: 100 }];
|
||||||
|
state.txError = new Error("tx exploded");
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=fail",
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/draw-badge");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports fail when the ip lookup throws", async () => {
|
||||||
|
mockClientIp.mockRejectedValueOnce(new Error("ip failed"));
|
||||||
|
|
||||||
|
await expect(buyBadge(form("1"))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT: /draw-badge?error=fail",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({ inserted: [] as any[] }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: () => ({
|
||||||
|
values: (v: any) => {
|
||||||
|
state.inserted.push(v);
|
||||||
|
return Promise.resolve([{ insertId: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const authMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: authMock }));
|
||||||
|
|
||||||
|
const clientIpMock = vi.hoisted(() => vi.fn());
|
||||||
|
const rateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
clientIp: clientIpMock,
|
||||||
|
rateLimit: rateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const isAllowedMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/moderation", () => ({ isAllowed: isAllowedMock }));
|
||||||
|
|
||||||
|
const revalidatePathMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
|
||||||
|
|
||||||
|
const redirectMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (url: string) => {
|
||||||
|
redirectMock(url);
|
||||||
|
throw Object.assign(new Error("NEXT_REDIRECT"), {
|
||||||
|
digest: `NEXT_REDIRECT;replace;${url};307;`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { postGuestbook } from "./guestbook";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.inserted = [];
|
||||||
|
authMock.mockResolvedValue({ user: { id: 5, name: "bob" } });
|
||||||
|
clientIpMock.mockResolvedValue("1.2.3.4");
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: true, retryAfter: 0 });
|
||||||
|
isAllowedMock.mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("postGuestbook", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
postGuestbook(fakeForm({ username: "bob", profileId: "9" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
expect(state.inserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects when the session id is not a positive integer", async () => {
|
||||||
|
authMock.mockResolvedValue({ user: { id: 0, name: "bob" } });
|
||||||
|
await expect(
|
||||||
|
postGuestbook(fakeForm({ username: "bob", profileId: "9" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit without touching the database", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 12 });
|
||||||
|
await expect(
|
||||||
|
postGuestbook(fakeForm({ username: "bob", profileId: "9" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?error=ratelimit");
|
||||||
|
expect(rateLimitMock).toHaveBeenCalledWith("guestbook:5", 5, 30_000);
|
||||||
|
expect(clientIpMock).toHaveBeenCalled();
|
||||||
|
expect(state.inserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", "abc", "0", "-1", "3.5"])(
|
||||||
|
"rejects an invalid profile id (%s)",
|
||||||
|
async (profileId) => {
|
||||||
|
await expect(
|
||||||
|
postGuestbook(fakeForm({ username: "bob", profileId })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?error=invalid");
|
||||||
|
expect(state.inserted).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects an empty message", async () => {
|
||||||
|
await expect(
|
||||||
|
postGuestbook(
|
||||||
|
fakeForm({ username: "bob", profileId: "9", message: " " }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?error=empty");
|
||||||
|
expect(state.inserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing message field as empty", async () => {
|
||||||
|
await expect(
|
||||||
|
postGuestbook(fakeForm({ username: "bob", profileId: "9" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?error=empty");
|
||||||
|
expect(state.inserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a moderated message", async () => {
|
||||||
|
isAllowedMock.mockResolvedValue({ ok: false, reason: "bad" });
|
||||||
|
await expect(
|
||||||
|
postGuestbook(
|
||||||
|
fakeForm({ username: "bob", profileId: "9", message: "bad word" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?error=moderated");
|
||||||
|
expect(state.inserted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a trimmed, 255-char-capped entry and revalidates the profile", async () => {
|
||||||
|
const long = ` ${"x".repeat(300)} `;
|
||||||
|
await expect(
|
||||||
|
postGuestbook(
|
||||||
|
fakeForm({ username: " bob ", profileId: "42", message: long }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
|
||||||
|
expect(state.inserted).toHaveLength(1);
|
||||||
|
expect(state.inserted[0]).toMatchObject({
|
||||||
|
profileId: 42,
|
||||||
|
userId: 5,
|
||||||
|
message: "x".repeat(255),
|
||||||
|
});
|
||||||
|
expect(state.inserted[0].message).toHaveLength(255);
|
||||||
|
expect(state.inserted[0].createdAt).toBeInstanceOf(Date);
|
||||||
|
expect(state.inserted[0].updatedAt).toBeInstanceOf(Date);
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/u/bob");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?guestbook=posted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the root path when no username is supplied", async () => {
|
||||||
|
await expect(postGuestbook(fakeForm({ profileId: "0" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/?error=invalid");
|
||||||
|
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows unexpected database errors and reports ?error=error", async () => {
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
|
vi.spyOn(db, "insert").mockReturnValueOnce({
|
||||||
|
values: () => Promise.reject(new Error("db down")),
|
||||||
|
} as never);
|
||||||
|
await expect(
|
||||||
|
postGuestbook(
|
||||||
|
fakeForm({ username: "bob", profileId: "9", message: "hello" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/u/bob?error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
|
|
||||||
|
vi.mock("next/server", () => ({ NextResponse: { json: vi.fn() } }));
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
categories: [] as any[],
|
||||||
|
tickets: [] as any[],
|
||||||
|
inserts: [] as Array<{ table: unknown; value: any }>,
|
||||||
|
updates: [] as any[],
|
||||||
|
nextInsertId: 77,
|
||||||
|
categoryInsertError: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table) => {
|
||||||
|
if (table === schema.WebsiteHelpCenterCategories) return state.categories;
|
||||||
|
if (table === schema.WebsiteHelpCenterTickets) return state.tickets;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const makeInsert = (table: unknown) => ({
|
||||||
|
values: (value: any) => {
|
||||||
|
state.inserts.push({ table, value });
|
||||||
|
if (
|
||||||
|
table === schema.WebsiteHelpCenterCategories &&
|
||||||
|
state.categoryInsertError
|
||||||
|
)
|
||||||
|
return Promise.reject(new Error("duplicate"));
|
||||||
|
return Promise.resolve([{ insertId: state.nextInsertId }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => makeInsert(table),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (value: any) => {
|
||||||
|
state.updates.push({ table, value });
|
||||||
|
return { where: () => Promise.resolve([{ affectedRows: 1 }]) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transaction: async (cb: (tx: any) => Promise<unknown>) =>
|
||||||
|
cb({
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => makeInsert(table),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (value: any) => {
|
||||||
|
state.updates.push({ table, value });
|
||||||
|
return { where: () => Promise.resolve([{ affectedRows: 1 }]) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const authMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: authMock }));
|
||||||
|
|
||||||
|
const clientIpMock = vi.hoisted(() => vi.fn());
|
||||||
|
const rateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
clientIp: clientIpMock,
|
||||||
|
rateLimit: rateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const moderateOrThrowMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/moderation", () => ({
|
||||||
|
moderateOrThrow: moderateOrThrowMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createOwnedTicketReplyMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/ticket-replies", () => ({
|
||||||
|
createOwnedTicketReply: createOwnedTicketReplyMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const notifyMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: notifyMock }));
|
||||||
|
|
||||||
|
const revalidatePathMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
|
||||||
|
|
||||||
|
const redirectMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (url: string) => {
|
||||||
|
redirectMock(url);
|
||||||
|
throw Object.assign(new Error("NEXT_REDIRECT"), {
|
||||||
|
digest: `NEXT_REDIRECT;replace;${url};307;`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { closeHelpTicket, createTicket, replyHelpTicket } from "./help-tickets";
|
||||||
|
|
||||||
|
const redirected = () => redirectMock.mock.calls.at(-1)?.[0];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.categories = [];
|
||||||
|
state.tickets = [];
|
||||||
|
state.inserts = [];
|
||||||
|
state.updates = [];
|
||||||
|
state.nextInsertId = 77;
|
||||||
|
state.categoryInsertError = false;
|
||||||
|
authMock.mockResolvedValue({ user: { id: 7, name: "bob" } });
|
||||||
|
clientIpMock.mockResolvedValue("1.2.3.4");
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: true, retryAfter: 0 });
|
||||||
|
moderateOrThrowMock.mockResolvedValue(undefined);
|
||||||
|
createOwnedTicketReplyMock.mockImplementation(async (ops: any, data: any) => {
|
||||||
|
await ops.findTicket(data.ticketId);
|
||||||
|
const reply = await ops.createReply({
|
||||||
|
ticketId: data.ticketId,
|
||||||
|
userId: data.userId,
|
||||||
|
content: data.content,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
await ops.touchTicket(data.ticketId, new Date());
|
||||||
|
return reply;
|
||||||
|
});
|
||||||
|
notifyMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createTicket", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
createTicket(fakeForm({ title: "Help", content: "please" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 3 });
|
||||||
|
await expect(
|
||||||
|
createTicket(fakeForm({ title: "Help", content: "please" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(rateLimitMock).toHaveBeenCalledWith("ticket:7", 3, 60_000);
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=ratelimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty title or content", async () => {
|
||||||
|
await expect(
|
||||||
|
createTicket(fakeForm({ title: " ", content: "please" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=invalid");
|
||||||
|
expect(state.inserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a form with missing fields", async () => {
|
||||||
|
await expect(createTicket(fakeForm({}))).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks moderated content", async () => {
|
||||||
|
moderateOrThrowMock.mockRejectedValue(new Error("bad"));
|
||||||
|
await expect(
|
||||||
|
createTicket(fakeForm({ title: "Help", content: "bad" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=moderated");
|
||||||
|
expect(state.inserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a ticket with a numeric category and notifies", async () => {
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({
|
||||||
|
title: " My problem ",
|
||||||
|
content: " details ",
|
||||||
|
categoryId: "5",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
|
||||||
|
const ticket = state.inserts.find(
|
||||||
|
(i) => i.table && (i.value as any).title === "My problem",
|
||||||
|
)?.value;
|
||||||
|
expect(ticket).toMatchObject({
|
||||||
|
userId: 7,
|
||||||
|
content: "details",
|
||||||
|
categoryId: 5n,
|
||||||
|
open: true,
|
||||||
|
});
|
||||||
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "ticket_create", actor: "bob" }),
|
||||||
|
);
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/help/tickets");
|
||||||
|
expect(redirected()).toBe("/help/tickets?created=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a ticket without a valid category and skips notify when unnamed", async () => {
|
||||||
|
authMock.mockResolvedValue({ user: { id: 7 } });
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({ title: "Help", content: "please", categoryId: "abc" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
const ticket = state.inserts.at(-1)?.value;
|
||||||
|
expect(ticket.categoryId).toBeNull();
|
||||||
|
expect(notifyMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses an existing Ban appeal category and prefixes the title", async () => {
|
||||||
|
state.categories = [{ id: 3n }];
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({ title: "unban me", content: "please", banAppeal: "1" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
const ticket = state.inserts.at(-1)?.value;
|
||||||
|
expect(ticket.title).toBe("[Ban appeal] unban me");
|
||||||
|
expect(ticket.categoryId).toBe(3n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-prefix a title that already mentions ban appeal", async () => {
|
||||||
|
state.categories = [{ id: 3n }];
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({
|
||||||
|
title: "ban appeal for bob",
|
||||||
|
content: "please",
|
||||||
|
banAppeal: "on",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(state.inserts.at(-1)?.value.title).toBe("ban appeal for bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the Ban appeal category when missing", async () => {
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({ title: "help", content: "please", banAppeal: "1" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(state.inserts).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
value: expect.objectContaining({ name: "Ban appeal" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const ticket = state.inserts.at(-1)?.value;
|
||||||
|
expect(ticket.categoryId).toBe(77n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a re-read when creating the category races", async () => {
|
||||||
|
state.categoryInsertError = true;
|
||||||
|
state.categories = [{ id: 9n }];
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({ title: "help", content: "please", banAppeal: "1" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(state.inserts.at(-1)?.value.categoryId).toBe(9n);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a null category when the raced re-read also finds nothing", async () => {
|
||||||
|
state.categoryInsertError = true;
|
||||||
|
state.categories = [];
|
||||||
|
await expect(
|
||||||
|
createTicket(
|
||||||
|
fakeForm({ title: "help", content: "please", banAppeal: "1" }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(state.inserts.at(-1)?.value.categoryId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected insert failure to ?error=error", async () => {
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
|
const original = db.insert;
|
||||||
|
(db as any).insert = () => ({
|
||||||
|
values: () => Promise.reject(new Error("db down")),
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
createTicket(fakeForm({ title: "Help", content: "please" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=error");
|
||||||
|
(db as any).insert = original;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replyHelpTicket", () => {
|
||||||
|
it("rejects an invalid ticket id before auth work", async () => {
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "0", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=invalid");
|
||||||
|
expect(rateLimitMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reply form with no ticket id field", async () => {
|
||||||
|
await expect(replyHelpTicket(fakeForm({}))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a reply rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 5 });
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(rateLimitMock).toHaveBeenCalledWith("ticket-reply:7", 5, 60_000);
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=ratelimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty content", async () => {
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: " " })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reply form with no content field", async () => {
|
||||||
|
await expect(replyHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing ticket at reply time as not_found", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: true }];
|
||||||
|
createOwnedTicketReplyMock.mockImplementation(async (ops: any) => {
|
||||||
|
state.tickets = [];
|
||||||
|
expect(await ops.findTicket(42n)).toBeNull();
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing or foreign ticket", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=not_found");
|
||||||
|
|
||||||
|
state.tickets = [{ id: 1n, userId: 999, open: true }];
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a closed ticket", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: false }];
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=closed_ticket");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks moderated replies", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: true }];
|
||||||
|
moderateOrThrowMock.mockRejectedValue(new Error("bad"));
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "bad" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=moderated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a reply and revalidates the ticket", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: true }];
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: " thanks " })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(createOwnedTicketReplyMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ ticketId: 1n, userId: 7, content: "thanks" }),
|
||||||
|
);
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/help/tickets/1");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?replied=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not_found when the reply helper refuses ownership", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: true }];
|
||||||
|
createOwnedTicketReplyMock.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected failure to ?error=error", async () => {
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
|
vi.spyOn(db, "select").mockImplementationOnce(() => {
|
||||||
|
throw new Error("db down");
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
replyHelpTicket(fakeForm({ ticketId: "1", content: "hi" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("closeHelpTicket", () => {
|
||||||
|
it("rejects an invalid ticket id", async () => {
|
||||||
|
await expect(
|
||||||
|
closeHelpTicket(fakeForm({ ticketId: "abc" })),
|
||||||
|
).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a form with no ticket id field", async () => {
|
||||||
|
await expect(closeHelpTicket(fakeForm({}))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/help/tickets?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await expect(closeHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a close rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 9 });
|
||||||
|
await expect(closeHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(rateLimitMock).toHaveBeenCalledWith("ticket-close:7", 10, 60_000);
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=ratelimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing or foreign ticket", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
await expect(closeHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an already closed ticket", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: false }];
|
||||||
|
await expect(closeHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=closed_ticket");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes an owned open ticket", async () => {
|
||||||
|
state.tickets = [{ id: 1n, userId: 7, open: true }];
|
||||||
|
await expect(closeHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(state.updates.at(-1)?.value).toMatchObject({
|
||||||
|
open: false,
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?closed=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected failure to ?error=error", async () => {
|
||||||
|
const { db } = await import("@/lib/db");
|
||||||
|
vi.spyOn(db, "select").mockImplementationOnce(() => {
|
||||||
|
throw new Error("db down");
|
||||||
|
});
|
||||||
|
await expect(closeHelpTicket(fakeForm({ ticketId: "1" }))).rejects.toThrow(
|
||||||
|
"NEXT_REDIRECT",
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/help/tickets/1?error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
inserted: [] as any[],
|
||||||
|
upsert: null as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: () => ({
|
||||||
|
values: (v: any) => {
|
||||||
|
state.inserted.push(v);
|
||||||
|
return {
|
||||||
|
onDuplicateKeyUpdate: (u: any) => {
|
||||||
|
state.upsert = u;
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = vi.hoisted(() => ({ getCtx: vi.fn(), canAccess: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
getApiAdminContext: perm.getCtx,
|
||||||
|
canAccess: perm.canAccess,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const withCatalogExportMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||||
|
withCatalogExport: withCatalogExportMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const deleteImportedItemMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/furni-import", () => ({
|
||||||
|
deleteImportedItem: deleteImportedItemMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const reloadMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: { reload: reloadMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteImportedFurni,
|
||||||
|
setFurnidataTranslateEnabled,
|
||||||
|
} from "./import-furni";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 7, username: "admin", rank: 7, name: "admin" } },
|
||||||
|
permissions: { has: () => true },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.inserted = [];
|
||||||
|
state.upsert = null;
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(true);
|
||||||
|
withCatalogExportMock.mockImplementation((fn: () => unknown) => fn());
|
||||||
|
deleteImportedItemMock.mockResolvedValue({
|
||||||
|
spriteId: 5,
|
||||||
|
deletedFiles: ["chair.nitro"],
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
reloadMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteImportedFurni", () => {
|
||||||
|
it.each([
|
||||||
|
["no context", null],
|
||||||
|
["permission denied", "denied"],
|
||||||
|
])("returns Unauthorized when %s", async (_label, mode) => {
|
||||||
|
if (mode === null) perm.getCtx.mockResolvedValue(null);
|
||||||
|
else perm.canAccess.mockReturnValue(false);
|
||||||
|
const res = await deleteImportedFurni({ classname: "chair" });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
expect(withCatalogExportMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the classname before touching the catalog export", async () => {
|
||||||
|
const res = await deleteImportedFurni({ classname: " " });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toBe("Validation failed");
|
||||||
|
expect(withCatalogExportMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs the deletion inside the catalog export wrapper", async () => {
|
||||||
|
const res = await deleteImportedFurni({ classname: "chair" });
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: { spriteId: 5, deletedFiles: ["chair.nitro"], errors: [] },
|
||||||
|
});
|
||||||
|
expect(withCatalogExportMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteImportedItemMock).toHaveBeenCalledWith("chair");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setFurnidataTranslateEnabled", () => {
|
||||||
|
it("persists an enabled value, reloads settings and reports success", async () => {
|
||||||
|
const res = await setFurnidataTranslateEnabled({ enabled: true });
|
||||||
|
expect(res).toEqual({ ok: true, data: { enabled: true } });
|
||||||
|
expect(state.inserted).toContainEqual({
|
||||||
|
key: "furnidata_translate_enabled",
|
||||||
|
value: "1",
|
||||||
|
});
|
||||||
|
expect(state.upsert).toEqual({ set: { value: "1" } });
|
||||||
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists a disabled value", async () => {
|
||||||
|
const res = await setFurnidataTranslateEnabled({ enabled: false });
|
||||||
|
expect(res).toEqual({ ok: true, data: { enabled: false } });
|
||||||
|
expect(state.inserted).toContainEqual({
|
||||||
|
key: "furnidata_translate_enabled",
|
||||||
|
value: "0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid input types", async () => {
|
||||||
|
const res = await setFurnidataTranslateEnabled({
|
||||||
|
enabled: "yes",
|
||||||
|
} as never);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toBe("Validation failed");
|
||||||
|
expect(reloadMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the assets import permission", async () => {
|
||||||
|
perm.canAccess.mockReturnValue(false);
|
||||||
|
const res = await setFurnidataTranslateEnabled({ enabled: true });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
expect(reloadMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
existing: [{ id: 1 }] as any[],
|
||||||
|
set: null as any,
|
||||||
|
updateError: null as Error | null,
|
||||||
|
inserted: [] as any[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => state.existing);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: () => ({
|
||||||
|
set: (v: any) => {
|
||||||
|
state.set = v;
|
||||||
|
return {
|
||||||
|
where: () =>
|
||||||
|
state.updateError
|
||||||
|
? Promise.reject(state.updateError)
|
||||||
|
: Promise.resolve([{ affectedRows: 1 }]),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = vi.hoisted(() => ({ getCtx: vi.fn(), canAccess: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
getApiAdminContext: perm.getCtx,
|
||||||
|
canAccess: perm.canAccess,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rconMock = vi.hoisted(() => ({ updateCatalog: vi.fn() }));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: rconMock }));
|
||||||
|
|
||||||
|
const logStaffActivityMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: logStaffActivityMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const revalidatePathMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
|
||||||
|
|
||||||
|
import { updateItemsBase } from "./items-base";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 7, username: "admin", rank: 7, name: "admin" } },
|
||||||
|
permissions: { has: () => true },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.existing = [{ id: 1 }];
|
||||||
|
state.set = null;
|
||||||
|
state.updateError = null;
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(true);
|
||||||
|
rconMock.updateCatalog.mockResolvedValue(true);
|
||||||
|
logStaffActivityMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateItemsBase", () => {
|
||||||
|
it("returns Unauthorized when no admin context exists", async () => {
|
||||||
|
perm.getCtx.mockResolvedValue(null);
|
||||||
|
const res = await updateItemsBase({ id: 1, fields: { publicName: "x" } });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
expect(state.set).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Unauthorized when the permission check denies access", async () => {
|
||||||
|
perm.canAccess.mockReturnValue(false);
|
||||||
|
const res = await updateItemsBase({ id: 1, fields: { publicName: "x" } });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
expect(state.set).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests with no whitelisted fields", async () => {
|
||||||
|
const res = await updateItemsBase({
|
||||||
|
id: 1,
|
||||||
|
fields: { notAllowed: 1, other: "x" },
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toBe("No valid fields to update");
|
||||||
|
expect(state.set).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing item", async () => {
|
||||||
|
state.existing = [];
|
||||||
|
const res = await updateItemsBase({ id: 3, fields: { publicName: "x" } });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toBe("Item not found");
|
||||||
|
expect(state.set).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the input schema before running the handler", async () => {
|
||||||
|
const res = await updateItemsBase({ id: 0, fields: { publicName: "x" } });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toBe("Validation failed");
|
||||||
|
expect(state.set).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists only allowed fields and coerces numeric values", async () => {
|
||||||
|
const res = await updateItemsBase({
|
||||||
|
id: 1,
|
||||||
|
fields: {
|
||||||
|
publicName: "Chair",
|
||||||
|
width: "2",
|
||||||
|
length: "3",
|
||||||
|
stackHeight: "1.5",
|
||||||
|
spriteId: "44",
|
||||||
|
allowStack: "1",
|
||||||
|
allowSit: "0",
|
||||||
|
interactionModesCount: "4",
|
||||||
|
effectIdMale: "9",
|
||||||
|
customparams: "{}",
|
||||||
|
notAllowed: "nope",
|
||||||
|
itemName: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 1 } });
|
||||||
|
expect(state.set).toMatchObject({
|
||||||
|
publicName: "Chair",
|
||||||
|
width: 2,
|
||||||
|
length: 3,
|
||||||
|
stackHeight: 1.5,
|
||||||
|
spriteId: 44,
|
||||||
|
allowStack: 1,
|
||||||
|
allowSit: 0,
|
||||||
|
interactionModesCount: 4,
|
||||||
|
effectIdMale: 9,
|
||||||
|
customparams: "{}",
|
||||||
|
});
|
||||||
|
expect(state.set).not.toHaveProperty("notAllowed");
|
||||||
|
expect(state.set).not.toHaveProperty("itemName");
|
||||||
|
expect(rconMock.updateCatalog).toHaveBeenCalled();
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
staffId: 7,
|
||||||
|
action: "items_base_update",
|
||||||
|
targetType: "items_base",
|
||||||
|
targetId: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/admin/items");
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/admin/items/1");
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/admin/catalog");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues when the RCON catalog refresh fails", async () => {
|
||||||
|
rconMock.updateCatalog.mockRejectedValue(new Error("rcon down"));
|
||||||
|
const res = await updateItemsBase({
|
||||||
|
id: 1,
|
||||||
|
fields: { interactionType: "gate" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 1 } });
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a database failure to an internal error result", async () => {
|
||||||
|
state.updateError = new Error("boom");
|
||||||
|
const res = await updateItemsBase({ id: 1, fields: { type: "s" } });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Internal server error" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fakeForm } from "@/test/fake-form";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
requests: [] as any[],
|
||||||
|
friendships: [] as any[],
|
||||||
|
users: [] as any[],
|
||||||
|
inserts: [] as Array<{ table: unknown; value: any }>,
|
||||||
|
deletes: [] as unknown[],
|
||||||
|
affectedDelete: 1,
|
||||||
|
emptyDeleteResult: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table) => {
|
||||||
|
if (table === schema.MessengerFriendrequests) return state.requests;
|
||||||
|
if (table === schema.MessengerFriendships) return state.friendships;
|
||||||
|
if (table === schema.User) return state.users;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const makeInsert = (table: unknown) => ({
|
||||||
|
values: (value: any) => {
|
||||||
|
state.inserts.push({ table, value });
|
||||||
|
return Promise.resolve([{ insertId: 1 }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const makeDelete = (table: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.deletes.push(table);
|
||||||
|
return Promise.resolve(
|
||||||
|
state.emptyDeleteResult ? [] : [{ affectedRows: state.affectedDelete }],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => makeInsert(table),
|
||||||
|
delete: (table: unknown) => makeDelete(table),
|
||||||
|
transaction: async (cb: (tx: any) => Promise<unknown>) =>
|
||||||
|
cb({
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => makeInsert(table),
|
||||||
|
delete: (table: unknown) => makeDelete(table),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const authMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: authMock }));
|
||||||
|
|
||||||
|
const clientIpMock = vi.hoisted(() => vi.fn());
|
||||||
|
const rateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
clientIp: clientIpMock,
|
||||||
|
rateLimit: rateLimitMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const revalidatePathMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
|
||||||
|
|
||||||
|
const redirectMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (url: string) => {
|
||||||
|
redirectMock(url);
|
||||||
|
throw Object.assign(new Error("NEXT_REDIRECT"), {
|
||||||
|
digest: `NEXT_REDIRECT;replace;${url};307;`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
acceptFriend,
|
||||||
|
declineFriendRequest,
|
||||||
|
removeFriendship,
|
||||||
|
sendOfflineMessage,
|
||||||
|
} from "./messenger";
|
||||||
|
|
||||||
|
const redirected = () => redirectMock.mock.calls.at(-1)?.[0];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.requests = [];
|
||||||
|
state.friendships = [];
|
||||||
|
state.users = [{ id: 2 }];
|
||||||
|
state.inserts = [];
|
||||||
|
state.deletes = [];
|
||||||
|
state.affectedDelete = 1;
|
||||||
|
state.emptyDeleteResult = false;
|
||||||
|
authMock.mockResolvedValue({ user: { id: 7, name: "bob" } });
|
||||||
|
clientIpMock.mockResolvedValue("1.2.3.4");
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: true, retryAfter: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirects = async (run: () => Promise<void>) => {
|
||||||
|
await expect(run()).rejects.toThrow("NEXT_REDIRECT");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("acceptFriend", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 3 });
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=ratelimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid request id", async () => {
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "abc" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing request", async () => {
|
||||||
|
state.requests = [];
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a request addressed to someone else", async () => {
|
||||||
|
state.requests = [{ id: 5, userFromId: 2, userToId: 99 }];
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a malformed self-addressed request", async () => {
|
||||||
|
state.requests = [{ id: 5, userFromId: 7, userToId: 7 }];
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(state.deletes).toContainEqual(expect.anything());
|
||||||
|
expect(redirected()).toBe("/messages?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a malformed non-positive sender request", async () => {
|
||||||
|
state.requests = [{ id: 5, userFromId: 0, userToId: 7 }];
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid request and creates both friendship rows", async () => {
|
||||||
|
state.requests = [{ id: 5, userFromId: 2, userToId: 7 }];
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(state.inserts).toHaveLength(1);
|
||||||
|
expect(state.inserts[0].value).toEqual([
|
||||||
|
{ userOneId: 7, userTwoId: 2, friendsSince: expect.any(Number) },
|
||||||
|
{ userOneId: 2, userTwoId: 7, friendsSince: expect.any(Number) },
|
||||||
|
]);
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/messages");
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/friends");
|
||||||
|
expect(redirected()).toBe("/messages?accepted=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts without inserting when a friendship already exists", async () => {
|
||||||
|
state.requests = [{ id: 5, userFromId: 2, userToId: 7 }];
|
||||||
|
state.friendships = [{ id: 1 }];
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(state.inserts).toHaveLength(0);
|
||||||
|
expect(redirected()).toBe("/messages?accepted=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected failure to ?error=error", async () => {
|
||||||
|
authMock.mockRejectedValue(new Error("db down"));
|
||||||
|
await redirects(() => acceptFriend(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("declineFriendRequest", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 3 });
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=ratelimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid request id", async () => {
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "0" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing request", async () => {
|
||||||
|
state.requests = [];
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a request addressed to someone else", async () => {
|
||||||
|
state.requests = [{ id: 5, userToId: 99 }];
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines a valid request", async () => {
|
||||||
|
state.requests = [{ id: 5, userToId: 7 }];
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "5" })));
|
||||||
|
expect(state.deletes).toContainEqual(expect.anything());
|
||||||
|
expect(redirected()).toBe("/messages?declined=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected failure to ?error=error", async () => {
|
||||||
|
authMock.mockRejectedValue(new Error("db down"));
|
||||||
|
await redirects(() => declineFriendRequest(fakeForm({ requestId: "5" })));
|
||||||
|
expect(redirected()).toBe("/messages?error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeFriendship", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "2" })));
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 3 });
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "2" })));
|
||||||
|
expect(redirected()).toBe("/friends?error=ratelimit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid or self friend ids", async () => {
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "0" })));
|
||||||
|
expect(redirected()).toBe("/friends?error=invalid");
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "7" })));
|
||||||
|
expect(redirected()).toBe("/friends?error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing friendship", async () => {
|
||||||
|
state.affectedDelete = 1;
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "2" })));
|
||||||
|
expect(state.deletes).toHaveLength(2);
|
||||||
|
expect(redirected()).toBe("/friends?removed=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a friendship that did not exist", async () => {
|
||||||
|
state.affectedDelete = 0;
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "2" })));
|
||||||
|
expect(redirected()).toBe("/friends?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a driver result with no rows", async () => {
|
||||||
|
state.emptyDeleteResult = true;
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "2" })));
|
||||||
|
expect(redirected()).toBe("/friends?error=not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected failure to ?error=error", async () => {
|
||||||
|
authMock.mockRejectedValue(new Error("db down"));
|
||||||
|
await redirects(() => removeFriendship(fakeForm({ friendId: "2" })));
|
||||||
|
expect(redirected()).toBe("/friends?error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOfflineMessage", () => {
|
||||||
|
it("redirects unauthenticated users to login", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "2", message: "hi" })),
|
||||||
|
);
|
||||||
|
expect(redirectMock).toHaveBeenCalledWith("/login");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 3 });
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "2", message: "hi" })),
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/messages?send_error=rate_limited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid friend id", async () => {
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "0", message: "hi" })),
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/messages?send_error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty message", async () => {
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "2", message: " " })),
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/messages?send_error=empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing message field", async () => {
|
||||||
|
await redirects(() => sendOfflineMessage(fakeForm({ friendId: "2" })));
|
||||||
|
expect(redirected()).toBe("/messages?send_error=empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to message a non-friend", async () => {
|
||||||
|
state.friendships = [];
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "2", message: "hi" })),
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/messages?send_error=not_friend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a friend that no longer exists", async () => {
|
||||||
|
state.friendships = [{ id: 1 }];
|
||||||
|
state.users = [];
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "2", message: "hi" })),
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/messages?send_error=invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores an offline message for a friend", async () => {
|
||||||
|
state.friendships = [{ id: 1 }];
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(
|
||||||
|
fakeForm({ friendId: "2", message: " hello there " }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(state.inserts.at(-1)?.value).toMatchObject({
|
||||||
|
userId: 2,
|
||||||
|
userFromId: 7,
|
||||||
|
message: "hello there",
|
||||||
|
sendedOn: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/messages");
|
||||||
|
expect(redirected()).toBe("/messages?sent=1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps an unexpected failure to ?error=error", async () => {
|
||||||
|
authMock.mockRejectedValue(new Error("db down"));
|
||||||
|
await redirects(() =>
|
||||||
|
sendOfflineMessage(fakeForm({ friendId: "2", message: "hi" })),
|
||||||
|
);
|
||||||
|
expect(redirected()).toBe("/messages?send_error=error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
tickets: [{ id: 1, state: 0 }] as any[],
|
||||||
|
set: null as any,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => state.tickets);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: () => ({
|
||||||
|
set: (v: any) => {
|
||||||
|
state.set = v;
|
||||||
|
return { where: () => Promise.resolve([{ affectedRows: 1 }]) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = vi.hoisted(() => ({ getCtx: vi.fn(), canAccess: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
getApiAdminContext: perm.getCtx,
|
||||||
|
canAccess: perm.canAccess,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const logAuditMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: logAuditMock }));
|
||||||
|
|
||||||
|
const rconMock = vi.hoisted(() => ({
|
||||||
|
disconnectUser: vi.fn(),
|
||||||
|
muteUser: vi.fn(),
|
||||||
|
unmuteUser: vi.fn(),
|
||||||
|
alertUser: vi.fn(),
|
||||||
|
kickAll: vi.fn(),
|
||||||
|
hotelAlert: vi.fn(),
|
||||||
|
staffAlert: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: rconMock }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
assignCfhTicket,
|
||||||
|
broadcastAlert,
|
||||||
|
closeCfhTicket,
|
||||||
|
quickAlert,
|
||||||
|
quickKick,
|
||||||
|
quickMute,
|
||||||
|
quickRoomKick,
|
||||||
|
quickUnmute,
|
||||||
|
updateCfhState,
|
||||||
|
} from "./moderation";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 7, username: "admin", rank: 7, name: "admin" } },
|
||||||
|
permissions: { has: () => true },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.tickets = [{ id: 1, state: 0 }];
|
||||||
|
state.set = null;
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(true);
|
||||||
|
for (const fn of Object.values(rconMock)) fn.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CFH ticket actions", () => {
|
||||||
|
it("assigns a ticket to the moderator and audits it", async () => {
|
||||||
|
const res = await assignCfhTicket({ ticketId: 1 });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.set).toEqual({ modId: 7, state: 1 });
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: 7,
|
||||||
|
action: "cfh_assign",
|
||||||
|
targetId: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns not-found when assigning a missing ticket", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
const res = await assignCfhTicket({ ticketId: 9 });
|
||||||
|
expect(res).toEqual({ ok: false, error: "SupportTicket #9 not found" });
|
||||||
|
expect(state.set).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the ticket state with before/after audit data", async () => {
|
||||||
|
const res = await updateCfhState({ ticketId: 1, state: 3 });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(state.set).toEqual({ state: 3, modId: 7 });
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "cfh_state_change",
|
||||||
|
before: { state: 0 },
|
||||||
|
after: { state: 3 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an out-of-range state and a missing ticket", async () => {
|
||||||
|
const invalid = await updateCfhState({ ticketId: 1, state: 4 });
|
||||||
|
expect(invalid.ok).toBe(false);
|
||||||
|
state.tickets = [];
|
||||||
|
const missing = await updateCfhState({ ticketId: 1, state: 1 });
|
||||||
|
expect(missing).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "SupportTicket #1 not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a ticket", async () => {
|
||||||
|
const res = await closeCfhTicket({ ticketId: 1 });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(state.set).toEqual({ state: 2, modId: 7 });
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "cfh_close" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("quick mod actions", () => {
|
||||||
|
it("kicks a user via RCON", async () => {
|
||||||
|
const res = await quickKick({ userId: 11 });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(rconMock.disconnectUser).toHaveBeenCalledWith(11);
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "mod_kick", targetId: 11 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mutes a user for a duration", async () => {
|
||||||
|
const res = await quickMute({ userId: 11, duration: 600 });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(rconMock.muteUser).toHaveBeenCalledWith(11, 600);
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "mod_mute",
|
||||||
|
after: { duration: 600 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a mute duration above the one-year maximum", async () => {
|
||||||
|
const res = await quickMute({ userId: 11, duration: 525_601 });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(rconMock.muteUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unmutes a user", async () => {
|
||||||
|
await quickUnmute({ userId: 11 });
|
||||||
|
expect(rconMock.unmuteUser).toHaveBeenCalledWith(11);
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "mod_unmute" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("alerts a user", async () => {
|
||||||
|
await quickAlert({ userId: 11, message: "be nice" });
|
||||||
|
expect(rconMock.alertUser).toHaveBeenCalledWith(11, "be nice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty alert and a non-positive user id", async () => {
|
||||||
|
expect((await quickAlert({ userId: 11, message: "" })).ok).toBe(false);
|
||||||
|
expect((await quickKick({ userId: 0 })).ok).toBe(false);
|
||||||
|
expect(rconMock.alertUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kicks everyone in a room", async () => {
|
||||||
|
await quickRoomKick({ roomId: 3 });
|
||||||
|
expect(rconMock.kickAll).toHaveBeenCalledWith(3);
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "mod_room_kick", targetId: 3 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcasts a hotel alert", async () => {
|
||||||
|
const res = await broadcastAlert({ message: "hi", type: "hotel" });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(rconMock.hotelAlert).toHaveBeenCalledWith("hi");
|
||||||
|
expect(rconMock.staffAlert).not.toHaveBeenCalled();
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "mod_broadcast_hotel" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("broadcasts a staff alert", async () => {
|
||||||
|
await broadcastAlert({ message: "hi", type: "staff" });
|
||||||
|
expect(rconMock.staffAlert).toHaveBeenCalledWith("hi");
|
||||||
|
expect(rconMock.hotelAlert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown broadcast type", async () => {
|
||||||
|
const res = await broadcastAlert({ message: "hi", type: "other" } as never);
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(rconMock.hotelAlert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authorization", () => {
|
||||||
|
it("returns Unauthorized with no admin context", async () => {
|
||||||
|
perm.getCtx.mockResolvedValue(null);
|
||||||
|
expect(await quickKick({ userId: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies mod actions without the required permission", async () => {
|
||||||
|
perm.canAccess.mockReturnValue(false);
|
||||||
|
expect(await quickMute({ userId: 1, duration: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
expect(rconMock.muteUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
groups: [] as any[],
|
||||||
|
users: [] as any[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((_table, projection) =>
|
||||||
|
"accountCount" in projection ? state.groups : state.users,
|
||||||
|
);
|
||||||
|
return { ...schema, db: fake };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const requirePermissionMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: requirePermissionMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { detectMultiAccounts } from "./multi-account-detect";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.groups = [];
|
||||||
|
state.users = [];
|
||||||
|
requirePermissionMock.mockResolvedValue({ id: 7, rank: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectMultiAccounts", () => {
|
||||||
|
it("checks the users.view permission before querying", async () => {
|
||||||
|
await detectMultiAccounts({ minAccounts: 2, limit: 10 });
|
||||||
|
expect(requirePermissionMock).toHaveBeenCalledWith(PERMS.USERS_VIEW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a permission denial", async () => {
|
||||||
|
requirePermissionMock.mockRejectedValue(new Error("Denied"));
|
||||||
|
await expect(
|
||||||
|
detectMultiAccounts({ minAccounts: 2, limit: 10 }),
|
||||||
|
).rejects.toThrow("Denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty cluster list when no IPs qualify", async () => {
|
||||||
|
const res = await detectMultiAccounts({ minAccounts: 3, limit: 5 });
|
||||||
|
expect(res).toEqual({ ok: true, data: { clusters: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds one cluster per qualifying IP with its accounts", async () => {
|
||||||
|
state.groups = [
|
||||||
|
{ ipCurrent: "1.2.3.4", accountCount: 3n },
|
||||||
|
{ ipCurrent: "5.6.7.8", accountCount: 2 },
|
||||||
|
];
|
||||||
|
state.users = [
|
||||||
|
{ id: 1, username: "alice", rank: 1, online: "1" },
|
||||||
|
{ id: 2, username: "bob", rank: 2, online: "0" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = await detectMultiAccounts({ minAccounts: 2, limit: 10 });
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.data.clusters).toHaveLength(2);
|
||||||
|
expect(res.data.clusters[0]).toEqual({
|
||||||
|
key: "1.2.3.4",
|
||||||
|
label: "IP: 1.2.3.4",
|
||||||
|
accountCount: 3,
|
||||||
|
accounts: [
|
||||||
|
{ id: 1, username: "alice", rank: 1, online: "1" },
|
||||||
|
{ id: 2, username: "bob", rank: 2, online: "0" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.data.clusters[1].key).toBe("5.6.7.8");
|
||||||
|
expect(res.data.clusters[1].accountCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
userSelect: [{ total: 0 }] as any[],
|
||||||
|
role: [{ id: 10, slug: "rank_1" }] as any[],
|
||||||
|
permissions: [{ id: 100 }, { id: 101 }] as any[],
|
||||||
|
executeResults: [1, 2, 3] as number[],
|
||||||
|
executeIndex: 0,
|
||||||
|
inserts: [] as any[],
|
||||||
|
upserts: [] as any[],
|
||||||
|
updates: [] as any[],
|
||||||
|
deletes: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
|
||||||
|
function makeValues(v: any) {
|
||||||
|
state.inserts.push(v);
|
||||||
|
const p: any = Promise.resolve([{ insertId: 1 }]);
|
||||||
|
p.onDuplicateKeyUpdate = (u: any) => {
|
||||||
|
state.upserts.push(u);
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
};
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
const tx = {
|
||||||
|
insert: () => ({ values: makeValues }),
|
||||||
|
delete: () => ({
|
||||||
|
where: () => {
|
||||||
|
state.deletes++;
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const fake = createFakeDb((table) => {
|
||||||
|
if (table === schema.User) return state.userSelect;
|
||||||
|
if (table === schema.AclRole) return state.role;
|
||||||
|
if (table === schema.AclPermission) return state.permissions;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: () => ({ values: makeValues }),
|
||||||
|
update: () => ({
|
||||||
|
set: (v: any) => {
|
||||||
|
state.updates.push(v);
|
||||||
|
return { where: () => Promise.resolve([{ affectedRows: 1 }]) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
delete: () => ({
|
||||||
|
where: () => {
|
||||||
|
state.deletes++;
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transaction: async (cb: (t: typeof tx) => Promise<unknown>) => cb(tx),
|
||||||
|
execute: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{ affectedRows: state.executeResults[state.executeIndex++] ?? 1 },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = vi.hoisted(() => ({ getCtx: vi.fn(), canAccess: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
getApiAdminContext: perm.getCtx,
|
||||||
|
canAccess: perm.canAccess,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const logStaffActivityMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: logStaffActivityMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ranksMock = vi.hoisted(() => ({
|
||||||
|
createEmulatorRank: vi.fn(),
|
||||||
|
deleteEmulatorRank: vi.fn(),
|
||||||
|
updateEmulatorRank: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/permission-ranks", () => ranksMock);
|
||||||
|
|
||||||
|
const rconMock = vi.hoisted(() => ({ send: vi.fn() }));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: rconMock }));
|
||||||
|
|
||||||
|
const revalidateTagMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidateTag: revalidateTagMock }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRank,
|
||||||
|
deleteRank,
|
||||||
|
repairAdminNavAclGrants,
|
||||||
|
saveRank,
|
||||||
|
setCmsPermissions,
|
||||||
|
} from "./permissions";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 7, username: "admin", rank: 7, name: "admin" } },
|
||||||
|
permissions: { has: () => true },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.userSelect = [{ total: 0 }];
|
||||||
|
state.role = [{ id: 10, slug: "rank_1" }];
|
||||||
|
state.permissions = [{ id: 100 }, { id: 101 }];
|
||||||
|
state.executeResults = [1, 2, 3];
|
||||||
|
state.executeIndex = 0;
|
||||||
|
state.inserts = [];
|
||||||
|
state.upserts = [];
|
||||||
|
state.updates = [];
|
||||||
|
state.deletes = 0;
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(true);
|
||||||
|
ranksMock.createEmulatorRank.mockResolvedValue(5);
|
||||||
|
ranksMock.deleteEmulatorRank.mockResolvedValue(undefined);
|
||||||
|
ranksMock.updateEmulatorRank.mockResolvedValue(undefined);
|
||||||
|
rconMock.send.mockResolvedValue(true);
|
||||||
|
revalidateTagMock.mockReturnValue(undefined);
|
||||||
|
logStaffActivityMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createRank", () => {
|
||||||
|
it("creates the emulator rank, syncs the CMS role and refreshes RCON", async () => {
|
||||||
|
const res = await createRank({ rank_name: "Manager", level: 5 });
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 5 } });
|
||||||
|
expect(ranksMock.createEmulatorRank).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
{
|
||||||
|
rank_name: "Manager",
|
||||||
|
level: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(state.inserts).toContainEqual({
|
||||||
|
slug: "rank_5",
|
||||||
|
title: "Manager",
|
||||||
|
description: "CMS role synchronized from permission_ranks",
|
||||||
|
});
|
||||||
|
expect(state.upserts).toContainEqual({ set: { title: "Manager" } });
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "rank_create", targetId: 5 }),
|
||||||
|
);
|
||||||
|
expect(rconMock.send).toHaveBeenCalledWith("updatepermissions");
|
||||||
|
expect(revalidateTagMock).toHaveBeenCalledWith("permissions", {
|
||||||
|
expire: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the rank name and level", async () => {
|
||||||
|
expect((await createRank({ rank_name: "", level: 1 })).ok).toBe(false);
|
||||||
|
expect((await createRank({ rank_name: "x", level: 0 })).ok).toBe(false);
|
||||||
|
expect(ranksMock.createEmulatorRank).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns Unauthorized without a context", async () => {
|
||||||
|
perm.getCtx.mockResolvedValue(null);
|
||||||
|
expect(await createRank({ rank_name: "x", level: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteRank", () => {
|
||||||
|
it("refuses to delete a rank still assigned to users", async () => {
|
||||||
|
state.userSelect = [{ total: 3 }];
|
||||||
|
const res = await deleteRank({ id: 1 });
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Cannot delete: 3 users have this rank",
|
||||||
|
});
|
||||||
|
expect(ranksMock.deleteEmulatorRank).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing count as zero and deletes the rank and role", async () => {
|
||||||
|
state.userSelect = [];
|
||||||
|
const res = await deleteRank({ id: 1 });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(ranksMock.deleteEmulatorRank).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(state.deletes).toBe(3);
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "rank_delete" }),
|
||||||
|
);
|
||||||
|
expect(rconMock.send).toHaveBeenCalledWith("updatepermissions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips ACL cleanup when no CMS role exists", async () => {
|
||||||
|
state.role = [];
|
||||||
|
const res = await deleteRank({ id: 9 });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(ranksMock.deleteEmulatorRank).toHaveBeenCalled();
|
||||||
|
expect(state.deletes).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveRank", () => {
|
||||||
|
it("updates the emulator rank and the CMS role title", async () => {
|
||||||
|
const res = await saveRank({ id: 1, fields: { rank_name: "New" } });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(ranksMock.updateEmulatorRank).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
1,
|
||||||
|
{ rank_name: "New" },
|
||||||
|
);
|
||||||
|
expect(state.updates).toContainEqual({ title: "New" });
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "rank_update" }),
|
||||||
|
);
|
||||||
|
expect(revalidateTagMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch the CMS role when rank_name is absent", async () => {
|
||||||
|
await saveRank({ id: 1, fields: { level: 2 } });
|
||||||
|
expect(state.updates).toHaveLength(0);
|
||||||
|
expect(ranksMock.updateEmulatorRank).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setCmsPermissions", () => {
|
||||||
|
it("replaces the role's permission grants inside a transaction", async () => {
|
||||||
|
const res = await setCmsPermissions({
|
||||||
|
roleId: 10,
|
||||||
|
permissionSlugs: ["admin.a", "admin.b"],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.deletes).toBe(1);
|
||||||
|
expect(state.inserts).toContainEqual([
|
||||||
|
{ modelId: 10, modelType: "Role", permissionId: 100 },
|
||||||
|
{ modelId: 10, modelType: "Role", permissionId: 101 },
|
||||||
|
]);
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "Updated 2 permissions for rank_1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears grants when no matching permissions are supplied", async () => {
|
||||||
|
state.permissions = [];
|
||||||
|
const res = await setCmsPermissions({
|
||||||
|
roleId: 10,
|
||||||
|
permissionSlugs: [],
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(state.deletes).toBe(1);
|
||||||
|
expect(state.inserts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the role does not exist", async () => {
|
||||||
|
state.role = [];
|
||||||
|
const res = await setCmsPermissions({
|
||||||
|
roleId: 99,
|
||||||
|
permissionSlugs: ["admin.a"],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Role not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects more than 500 permission slugs", async () => {
|
||||||
|
const res = await setCmsPermissions({
|
||||||
|
roleId: 10,
|
||||||
|
permissionSlugs: Array.from({ length: 501 }, (_, i) => `p${i}`),
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("repairAdminNavAclGrants", () => {
|
||||||
|
it("reports how many rows the three repair statements inserted", async () => {
|
||||||
|
state.executeResults = [2, 3, 5];
|
||||||
|
const res = await repairAdminNavAclGrants();
|
||||||
|
expect(res).toEqual({ ok: true, data: { inserted: 10 } });
|
||||||
|
expect(logStaffActivityMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "acl_nav_grants_repair",
|
||||||
|
description: expect.stringContaining("10 rows inserted"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(revalidateTagMock).toHaveBeenCalledWith("permissions", {
|
||||||
|
expire: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces missing affectedRows to NaN-safe arithmetic", async () => {
|
||||||
|
state.executeResults = [0, 0, 0];
|
||||||
|
const res = await repairAdminNavAclGrants();
|
||||||
|
expect(res).toEqual({ ok: true, data: { inserted: 0 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies access without the permissions.manage slug", async () => {
|
||||||
|
perm.canAccess.mockReturnValue(false);
|
||||||
|
const res = await repairAdminNavAclGrants();
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
polls: [] as any[],
|
||||||
|
questions: [] as any[],
|
||||||
|
votes: [] as any[],
|
||||||
|
inserts: [] as Array<{ table: unknown; value: any }>,
|
||||||
|
updates: [] as any[],
|
||||||
|
deletes: [] as unknown[],
|
||||||
|
nextId: 55,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table) => {
|
||||||
|
if (table === schema.WebsitePoll) return state.polls;
|
||||||
|
if (table === schema.WebsitePollQuestion) return state.questions;
|
||||||
|
if (table === schema.WebsitePollVote) return state.votes;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const makeInsert = (table: unknown) => ({
|
||||||
|
values: (value: any) => {
|
||||||
|
state.inserts.push({ table, value });
|
||||||
|
return Promise.resolve([{ insertId: state.nextId++ }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => makeInsert(table),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (value: any) => {
|
||||||
|
state.updates.push({ table, value });
|
||||||
|
return { where: () => Promise.resolve([{ affectedRows: 1 }]) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.deletes.push(table);
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
transaction: async (cb: (tx: any) => Promise<unknown>) =>
|
||||||
|
cb({ insert: (table: unknown) => makeInsert(table) }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = vi.hoisted(() => ({ getCtx: vi.fn(), canAccess: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
getApiAdminContext: perm.getCtx,
|
||||||
|
canAccess: perm.canAccess,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const authMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: authMock }));
|
||||||
|
|
||||||
|
const rateLimitMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: rateLimitMock,
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
|
||||||
|
|
||||||
|
const logAuditMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: logAuditMock }));
|
||||||
|
|
||||||
|
const notifyMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: notifyMock }));
|
||||||
|
|
||||||
|
const revalidatePathMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
addPollQuestion,
|
||||||
|
createPoll,
|
||||||
|
deletePoll,
|
||||||
|
deletePollQuestion,
|
||||||
|
updatePoll,
|
||||||
|
updatePollQuestion,
|
||||||
|
voteOnPoll,
|
||||||
|
} from "./polls";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 7, username: "admin", rank: 7, name: "admin" } },
|
||||||
|
permissions: { has: () => true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const activePoll = {
|
||||||
|
id: 1,
|
||||||
|
status: "active",
|
||||||
|
startsAt: new Date(Date.now() - 60_000),
|
||||||
|
endsAt: new Date(Date.now() + 60_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.polls = [activePoll];
|
||||||
|
state.questions = [
|
||||||
|
{ id: 10, pollId: 1, type: "single", options: "A\nB" },
|
||||||
|
{ id: 11, pollId: 1, type: "multiple", options: "A\nB" },
|
||||||
|
{ id: 12, pollId: 1, type: "text", options: "ignored" },
|
||||||
|
];
|
||||||
|
state.votes = [];
|
||||||
|
state.inserts = [];
|
||||||
|
state.updates = [];
|
||||||
|
state.deletes = [];
|
||||||
|
state.nextId = 55;
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(true);
|
||||||
|
authMock.mockResolvedValue({ user: { id: 7, name: "voter" } });
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: true, retryAfter: 0 });
|
||||||
|
logAuditMock.mockResolvedValue(undefined);
|
||||||
|
notifyMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("voteOnPoll", () => {
|
||||||
|
it("returns Unauthorized when there is no session", async () => {
|
||||||
|
authMock.mockResolvedValue(null);
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a rate limit", async () => {
|
||||||
|
rateLimitMock.mockResolvedValue({ ok: false, retryAfter: 42 });
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toContain("Rate limited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric session id", async () => {
|
||||||
|
authMock.mockResolvedValue({ user: { id: "nope", name: "x" } });
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the vote payload shape", async () => {
|
||||||
|
expect((await voteOnPoll({ pollId: 1, votes: [] })).ok).toBe(false);
|
||||||
|
expect((await voteOnPoll({ pollId: 0, votes: [] })).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing poll", async () => {
|
||||||
|
state.polls = [];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Poll not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-active poll", async () => {
|
||||||
|
state.polls = [{ ...activePoll, status: "closed" }];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "This poll is not open for voting",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a poll that has not started", async () => {
|
||||||
|
state.polls = [{ ...activePoll, startsAt: new Date(Date.now() + 60_000) }];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "This poll has not started yet" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a poll that has ended", async () => {
|
||||||
|
state.polls = [{ ...activePoll, endsAt: new Date(Date.now() - 60_000) }];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "This poll has ended" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate votes for the same question", async () => {
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [
|
||||||
|
{ questionId: 10, answer: "A" },
|
||||||
|
{ questionId: 10, answer: "B" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Duplicate vote for the same question",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a question that does not belong to the poll", async () => {
|
||||||
|
state.questions = [{ id: 10, pollId: 2, type: "single", options: "A\nB" }];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid question for this poll",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown question id", async () => {
|
||||||
|
state.questions = [];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 99, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Invalid question for this poll",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty answer", async () => {
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: " " }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Answer is required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a text answer over 500 characters at the schema layer", async () => {
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 12, answer: "x".repeat(501) }],
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.error).toBe("Validation failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid single-choice option", async () => {
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "Z" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Invalid option selected" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid multiple-choice option", async () => {
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 11, answer: "A\nZ" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "Invalid option selected" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a second vote by the same user", async () => {
|
||||||
|
state.votes = [{ id: 1 }];
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [{ questionId: 10, answer: "A" }],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "You have already voted on this poll",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records valid single, multiple and text votes in one transaction", async () => {
|
||||||
|
const res = await voteOnPoll({
|
||||||
|
pollId: 1,
|
||||||
|
votes: [
|
||||||
|
{ questionId: 10, answer: "A" },
|
||||||
|
{ questionId: 11, answer: "A\nB" },
|
||||||
|
{ questionId: 12, answer: " free text " },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: { pollId: 1 } });
|
||||||
|
expect(state.inserts).toHaveLength(3);
|
||||||
|
expect(state.inserts[0].value).toEqual({
|
||||||
|
questionId: 10,
|
||||||
|
userId: 7,
|
||||||
|
answer: "A",
|
||||||
|
});
|
||||||
|
expect(state.inserts[2].value.answer).toBe("free text");
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/polls");
|
||||||
|
expect(revalidatePathMock).toHaveBeenCalledWith("/polls/1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("poll administration", () => {
|
||||||
|
it("creates a poll", async () => {
|
||||||
|
const res = await createPoll({ title: "Favourite pet" });
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 55 } });
|
||||||
|
expect(state.inserts[0].value).toMatchObject({
|
||||||
|
title: "Favourite pet",
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "poll_create", targetId: 55 }),
|
||||||
|
);
|
||||||
|
expect(notifyMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "poll_create",
|
||||||
|
target: "Favourite pet",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates a poll title", async () => {
|
||||||
|
expect((await createPoll({ title: "" })).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates an existing poll", async () => {
|
||||||
|
const res = await updatePoll({ id: 1, title: "Renamed" });
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 1 } });
|
||||||
|
expect(state.updates[0].value).toMatchObject({ title: "Renamed" });
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "poll_update" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing poll on update", async () => {
|
||||||
|
state.polls = [];
|
||||||
|
const res = await updatePoll({ id: 1, title: "Renamed" });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Poll not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an existing poll", async () => {
|
||||||
|
const res = await deletePoll({ id: 1 });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.deletes).toContainEqual(expect.anything());
|
||||||
|
expect(logAuditMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "poll_delete" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing poll on delete", async () => {
|
||||||
|
state.polls = [];
|
||||||
|
const res = await deletePoll({ id: 1 });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Poll not found" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds, updates and deletes questions", async () => {
|
||||||
|
const added = await addPollQuestion({
|
||||||
|
pollId: 1,
|
||||||
|
question: "Why?",
|
||||||
|
type: "single",
|
||||||
|
sortOrder: 0,
|
||||||
|
options: "A\nB",
|
||||||
|
});
|
||||||
|
expect(added).toEqual({ ok: true, data: { id: 55 } });
|
||||||
|
|
||||||
|
const updated = await updatePollQuestion({ id: 10, question: "Changed" });
|
||||||
|
expect(updated).toEqual({ ok: true, data: { id: 10 } });
|
||||||
|
expect(state.updates.at(-1)?.value).toMatchObject({ question: "Changed" });
|
||||||
|
|
||||||
|
const removed = await deletePollQuestion({ id: 10 });
|
||||||
|
expect(removed).toEqual({ ok: true, data: {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the polls.edit permission", async () => {
|
||||||
|
perm.getCtx.mockResolvedValue(null);
|
||||||
|
expect(await createPoll({ title: "x" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(false);
|
||||||
|
expect(await deletePoll({ id: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
users: [] as any[],
|
||||||
|
executes: [] as Array<{ sql: string; params: unknown[] }>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const { MySqlDialect } = await import("drizzle-orm/mysql-core");
|
||||||
|
const fake = createFakeDb(() => state.users);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
execute: (q: any) => {
|
||||||
|
const { sql, params } = new MySqlDialect().sqlToQuery(q);
|
||||||
|
state.executes.push({ sql, params });
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const perm = vi.hoisted(() => ({ getCtx: vi.fn(), canAccess: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", async () => ({
|
||||||
|
...(await import("@/lib/permission-slugs")),
|
||||||
|
getApiAdminContext: perm.getCtx,
|
||||||
|
canAccess: perm.canAccess,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: vi.fn().mockResolvedValue({ ok: true, retryAfter: 0 }),
|
||||||
|
clientIp: vi.fn().mockResolvedValue("127.0.0.1"),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({
|
||||||
|
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
addBlacklistWord,
|
||||||
|
createPrefix,
|
||||||
|
deletePrefix,
|
||||||
|
removeBlacklistWord,
|
||||||
|
updatePrefix,
|
||||||
|
updatePrefixSettings,
|
||||||
|
} from "./prefixes";
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
session: { user: { id: 7, username: "admin", rank: 7, name: "admin" } },
|
||||||
|
permissions: { has: () => true },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.users = [{ id: 42 }];
|
||||||
|
state.executes = [];
|
||||||
|
perm.getCtx.mockResolvedValue(ctx);
|
||||||
|
perm.canAccess.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createPrefix", () => {
|
||||||
|
it("inserts a prefix for an existing user", async () => {
|
||||||
|
const res = await createPrefix({
|
||||||
|
username: "alice",
|
||||||
|
text: "VIP",
|
||||||
|
color: "#fff",
|
||||||
|
icon: "star",
|
||||||
|
effect: "glow",
|
||||||
|
active: 0,
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.executes).toHaveLength(1);
|
||||||
|
expect(state.executes[0].sql).toContain("INSERT INTO custom_prefixes");
|
||||||
|
expect(state.executes[0].params).toEqual([
|
||||||
|
42,
|
||||||
|
"VIP",
|
||||||
|
"#fff",
|
||||||
|
"star",
|
||||||
|
"glow",
|
||||||
|
0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults optional fields and active to empty/1", async () => {
|
||||||
|
await createPrefix({ username: "alice", text: "VIP", color: "#fff" });
|
||||||
|
expect(state.executes[0].params).toEqual([42, "VIP", "#fff", "", "", 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the user does not exist", async () => {
|
||||||
|
state.users = [];
|
||||||
|
const res = await createPrefix({
|
||||||
|
username: "ghost",
|
||||||
|
text: "VIP",
|
||||||
|
color: "#fff",
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "User not found" });
|
||||||
|
expect(state.executes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates required fields and active bounds", async () => {
|
||||||
|
expect(
|
||||||
|
(await createPrefix({ username: "a", text: "", color: "#fff" })).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await createPrefix({
|
||||||
|
username: "a",
|
||||||
|
text: "x",
|
||||||
|
color: "#fff",
|
||||||
|
active: 2,
|
||||||
|
})
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updatePrefix", () => {
|
||||||
|
it("updates all provided fields", async () => {
|
||||||
|
await updatePrefix({
|
||||||
|
id: 5,
|
||||||
|
text: "NEW",
|
||||||
|
color: "#000",
|
||||||
|
icon: "i",
|
||||||
|
effect: "e",
|
||||||
|
active: 0,
|
||||||
|
});
|
||||||
|
expect(state.executes[0].sql).toContain("UPDATE custom_prefixes");
|
||||||
|
expect(state.executes[0].params).toEqual(["NEW", "#000", "i", "e", 0, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to active=1 and empty icon/effect", async () => {
|
||||||
|
await updatePrefix({ id: 5, text: "NEW", color: "#000" });
|
||||||
|
expect(state.executes[0].params).toEqual(["NEW", "#000", "", "", 1, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deletePrefix", () => {
|
||||||
|
it("deletes by id", async () => {
|
||||||
|
const res = await deletePrefix({ id: 9 });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.executes[0].sql).toContain("DELETE FROM custom_prefixes");
|
||||||
|
expect(state.executes[0].params).toEqual([9]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("blacklist words", () => {
|
||||||
|
it("inserts a trimmed word", async () => {
|
||||||
|
await addBlacklistWord({ word: " bad " });
|
||||||
|
expect(state.executes[0].sql).toContain("custom_prefix_blacklist");
|
||||||
|
expect(state.executes[0].params).toEqual(["bad"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an oversized or empty word", async () => {
|
||||||
|
expect((await addBlacklistWord({ word: "" })).ok).toBe(false);
|
||||||
|
expect((await addBlacklistWord({ word: "x".repeat(101) })).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a word by id", async () => {
|
||||||
|
await removeBlacklistWord({ id: 3 });
|
||||||
|
expect(state.executes[0].sql).toContain(
|
||||||
|
"DELETE FROM custom_prefix_blacklist",
|
||||||
|
);
|
||||||
|
expect(state.executes[0].params).toEqual([3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updatePrefixSettings", () => {
|
||||||
|
it("upserts only whitelisted keys", async () => {
|
||||||
|
const res = await updatePrefixSettings({
|
||||||
|
settings: { enabled: "1", bogus: "x", max_length: "10" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.executes).toHaveLength(2);
|
||||||
|
const keys = state.executes.map((e) => e.params[0]);
|
||||||
|
expect(keys).toEqual(["enabled", "max_length"]);
|
||||||
|
expect(state.executes[0].sql).toContain("custom_prefix_settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when every key is unknown", async () => {
|
||||||
|
await updatePrefixSettings({ settings: { nope: "1" } });
|
||||||
|
expect(state.executes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("authorization", () => {
|
||||||
|
it("returns Unauthorized without a context", async () => {
|
||||||
|
perm.getCtx.mockResolvedValue(null);
|
||||||
|
expect(await deletePrefix({ id: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies prefix edits without permission", async () => {
|
||||||
|
perm.canAccess.mockReturnValue(false);
|
||||||
|
expect(
|
||||||
|
await createPrefix({ username: "a", text: "x", color: "y" }),
|
||||||
|
).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
failInsert: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw Object.assign(new Error(path), { digest: `NEXT_REDIRECT:${path}` });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) =>
|
||||||
|
state.failInsert
|
||||||
|
? Promise.reject(new Error("db down"))
|
||||||
|
: state.insert(table, values),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RadioApplications } from "@/lib/db";
|
||||||
|
import { applyDj } from "./radio-apply";
|
||||||
|
|
||||||
|
function buildForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("realName", "Jane Doe");
|
||||||
|
f.set("age", "17");
|
||||||
|
f.set("availability", "Weekends and evenings");
|
||||||
|
f.set("motivation", "I love radio and DJing");
|
||||||
|
f.set("experience", "two years");
|
||||||
|
f.set("musicStyle", "electronic");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyDj", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.failInsert = false;
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a valid application and redirects to ?submitted=1", async () => {
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/apply?submitted=1",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith(
|
||||||
|
"radio-apply:5",
|
||||||
|
2,
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(RadioApplications);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
userId: 5n,
|
||||||
|
realName: "Jane Doe",
|
||||||
|
age: 17,
|
||||||
|
availability: "Weekends and evenings",
|
||||||
|
motivation: "I love radio and DJing",
|
||||||
|
experience: "two years",
|
||||||
|
musicStyle: "electronic",
|
||||||
|
status: "pending",
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/apply");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null for optional experience and music style", async () => {
|
||||||
|
await expect(
|
||||||
|
applyDj(buildForm({ experience: "", musicStyle: "" })),
|
||||||
|
).rejects.toThrow("/radio/apply?submitted=1");
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
experience: null,
|
||||||
|
musicStyle: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates oversized fields to the column bounds", async () => {
|
||||||
|
await expect(
|
||||||
|
applyDj(buildForm({ realName: "A".repeat(300) })),
|
||||||
|
).rejects.toThrow("/radio/apply?submitted=1");
|
||||||
|
const values = state.insert.mock.calls[0][1] as {
|
||||||
|
realName: string;
|
||||||
|
};
|
||||||
|
expect(values.realName).toHaveLength(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when required fields are missing or age is not a positive integer", async () => {
|
||||||
|
await expect(applyDj(buildForm({ realName: " " }))).rejects.toThrow(
|
||||||
|
"/radio/apply?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(applyDj(buildForm({ availability: "" }))).rejects.toThrow(
|
||||||
|
"/radio/apply?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(applyDj(buildForm({ motivation: undefined }))).rejects.toThrow(
|
||||||
|
"/radio/apply?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(applyDj(buildForm({ age: "0" }))).rejects.toThrow(
|
||||||
|
"/radio/apply?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(applyDj(buildForm({ age: "abc" }))).rejects.toThrow(
|
||||||
|
"/radio/apply?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(applyDj(buildForm({ age: "17.5" }))).rejects.toThrow(
|
||||||
|
"/radio/apply?error=invalid",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when the rate limit is hit", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 300 });
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/apply?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/apply");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated or the session id is not a valid user id", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "0" } });
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "abc" } });
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows internal errors and redirects with error=error", async () => {
|
||||||
|
state.failInsert = true;
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/apply?error=error",
|
||||||
|
);
|
||||||
|
state.failInsert = false;
|
||||||
|
state.auth.mockRejectedValueOnce(new Error("auth service down"));
|
||||||
|
await expect(applyDj(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/apply?error=error",
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/apply");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
failInsert: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw Object.assign(new Error(path), { digest: `NEXT_REDIRECT:${path}` });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) =>
|
||||||
|
state.failInsert
|
||||||
|
? Promise.reject(new Error("db down"))
|
||||||
|
: state.insert(table, values),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RadioSongRequests } from "@/lib/db";
|
||||||
|
import { submitRequest } from "./radio-requests";
|
||||||
|
|
||||||
|
function buildForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("songTitle", "Never Gonna Give You Up");
|
||||||
|
f.set("artist", "Rick Astley");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("submitRequest", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.failInsert = false;
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a request with song and artist, then redirects to ?posted=1", async () => {
|
||||||
|
await expect(submitRequest(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/requests?posted=1",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith("radio-req:5", 5, 30_000);
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(RadioSongRequests);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
userId: 5n,
|
||||||
|
songTitle: "Never Gonna Give You Up",
|
||||||
|
artist: "Rick Astley",
|
||||||
|
submittedAt: expect.any(Date),
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null for whichever of song/artist is left empty", async () => {
|
||||||
|
await expect(
|
||||||
|
submitRequest(buildForm({ artist: "" })),
|
||||||
|
).rejects.toThrow("/radio/requests?posted=1");
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
songTitle: "Never Gonna Give You Up",
|
||||||
|
artist: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.insert.mockClear();
|
||||||
|
await expect(
|
||||||
|
submitRequest(buildForm({ songTitle: undefined })),
|
||||||
|
).rejects.toThrow("/radio/requests?posted=1");
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
songTitle: null,
|
||||||
|
artist: "Rick Astley",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates oversized song and artist fields", async () => {
|
||||||
|
await expect(
|
||||||
|
submitRequest(
|
||||||
|
buildForm({ songTitle: "S".repeat(300), artist: "A".repeat(300) }),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("/radio/requests?posted=1");
|
||||||
|
const values = state.insert.mock.calls[0][1] as {
|
||||||
|
songTitle: string;
|
||||||
|
artist: string;
|
||||||
|
};
|
||||||
|
expect(values.songTitle).toHaveLength(255);
|
||||||
|
expect(values.artist).toHaveLength(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests with neither song nor artist as empty", async () => {
|
||||||
|
await expect(
|
||||||
|
submitRequest(buildForm({ songTitle: " ", artist: "" })),
|
||||||
|
).rejects.toThrow("/radio/requests?error=empty");
|
||||||
|
await expect(
|
||||||
|
submitRequest(buildForm({ songTitle: undefined, artist: undefined })),
|
||||||
|
).rejects.toThrow("/radio/requests?error=empty");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 10 });
|
||||||
|
await expect(submitRequest(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/requests?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(submitRequest(buildForm())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "-4" } });
|
||||||
|
await expect(submitRequest(buildForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=error when the write fails", async () => {
|
||||||
|
state.failInsert = true;
|
||||||
|
await expect(submitRequest(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/requests?error=error",
|
||||||
|
);
|
||||||
|
state.failInsert = false;
|
||||||
|
state.auth.mockRejectedValueOnce(new Error("boom"));
|
||||||
|
await expect(submitRequest(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/requests?error=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
moderateOrThrow: vi.fn(async () => undefined),
|
||||||
|
failInsert: false,
|
||||||
|
rejectContent: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw Object.assign(new Error(path), { digest: `NEXT_REDIRECT:${path}` });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/moderation", () => ({
|
||||||
|
moderateOrThrow: state.moderateOrThrow,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) =>
|
||||||
|
state.failInsert
|
||||||
|
? Promise.reject(new Error("db down"))
|
||||||
|
: state.insert(table, values),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RadioShouts } from "@/lib/db";
|
||||||
|
import { postShout } from "./radio-shouts";
|
||||||
|
|
||||||
|
function buildForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("message", "Hello everyone!");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("postShout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.failInsert = false;
|
||||||
|
state.rejectContent = false;
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
state.moderateOrThrow.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a shouted message and redirects to ?posted=1", async () => {
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/shouts?posted=1",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith("shout:5", 5, 30_000);
|
||||||
|
expect(state.moderateOrThrow).toHaveBeenCalledWith("Hello everyone!");
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(RadioShouts);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
userId: 5n,
|
||||||
|
message: "Hello everyone!",
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/shouts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the message before validating and storing", async () => {
|
||||||
|
await expect(
|
||||||
|
postShout(buildForm({ message: "M".repeat(300) })),
|
||||||
|
).rejects.toThrow("/radio/shouts?posted=1");
|
||||||
|
const values = state.insert.mock.calls[0][1] as { message: string };
|
||||||
|
expect(values.message).toHaveLength(255);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty or whitespace-only message as invalid", async () => {
|
||||||
|
await expect(
|
||||||
|
postShout(buildForm({ message: "" })),
|
||||||
|
).rejects.toThrow("/radio/shouts?error=invalid");
|
||||||
|
await expect(
|
||||||
|
postShout(buildForm({ message: " " })),
|
||||||
|
).rejects.toThrow("/radio/shouts?error=invalid");
|
||||||
|
await expect(
|
||||||
|
postShout(buildForm({ message: undefined })),
|
||||||
|
).rejects.toThrow("/radio/shouts?error=invalid");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/radio/shouts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=moderated when content moderation rejects the message", async () => {
|
||||||
|
state.moderateOrThrow.mockRejectedValue(new Error("Blocked by word filter"));
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/shouts?error=moderated",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 8 });
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/shouts?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "0" } });
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows internal failures and redirects with error=error", async () => {
|
||||||
|
state.failInsert = true;
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/shouts?error=error",
|
||||||
|
);
|
||||||
|
state.failInsert = false;
|
||||||
|
state.auth.mockRejectedValueOnce(new Error("auth down"));
|
||||||
|
await expect(postShout(buildForm())).rejects.toThrow(
|
||||||
|
"/radio/shouts?error=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
sendCurrency: vi.fn(async () => true),
|
||||||
|
update: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
settingsRows: [] as Array<{ key: string; value: string }>,
|
||||||
|
referralsRows: [] as Array<{ id: bigint; referralsTotal: bigint }>,
|
||||||
|
settingsFail: false,
|
||||||
|
referralsFail: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw Object.assign(new Error(path), { digest: `NEXT_REDIRECT:${path}` });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/send-currency", () => ({
|
||||||
|
sendCurrency: state.sendCurrency,
|
||||||
|
currencyDb: { user: {}, usersCurrency: {} },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { send: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table: unknown, projection: Record<string, unknown>) => {
|
||||||
|
if ("referralsTotal" in projection) {
|
||||||
|
if (state.referralsFail) throw new Error("referrals down");
|
||||||
|
return state.referralsRows;
|
||||||
|
}
|
||||||
|
if ("key" in projection) {
|
||||||
|
if (state.settingsFail) throw new Error("settings down");
|
||||||
|
return state.settingsRows;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => state.update(table, values, where),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => state.insert(table, values),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { ClaimedReferralLogs, UserReferrals } from "@/lib/db";
|
||||||
|
import { claimReferral } from "./referral";
|
||||||
|
|
||||||
|
const BIG_ID = 7n;
|
||||||
|
const BIG_TEN = 10n;
|
||||||
|
|
||||||
|
describe("claimReferral", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.sendCurrency.mockResolvedValue(true);
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
state.settingsRows = [];
|
||||||
|
state.referralsRows = [{ id: BIG_ID, referralsTotal: BIG_TEN }];
|
||||||
|
state.settingsFail = false;
|
||||||
|
state.referralsFail = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated or the session id is unusable", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "0" } });
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "abc" } });
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow("/login");
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 5 });
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the default reward (5 needed, 30 diamonds) and redirects to ?claimed=1", async () => {
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?claimed=1",
|
||||||
|
);
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
UserReferrals,
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ rcon: expect.anything() }),
|
||||||
|
5,
|
||||||
|
"diamonds",
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(ClaimedReferralLogs);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
userId: 5,
|
||||||
|
ipAddress: "203.0.113.9",
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
updatedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies CMS-configured threshold, currency and amount", async () => {
|
||||||
|
state.settingsRows = [
|
||||||
|
{ key: "referrals_needed", value: "2" },
|
||||||
|
{ key: "referral_reward_amount", value: "75" },
|
||||||
|
{ key: "referral_reward_currency_type", value: "points" },
|
||||||
|
];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?claimed=1",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
5,
|
||||||
|
"points",
|
||||||
|
75,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the short referral_reward_currency key when the type key is absent", async () => {
|
||||||
|
state.settingsRows = [
|
||||||
|
{ key: "referral_reward_amount", value: "10" },
|
||||||
|
{ key: "referral_reward_currency", value: "credits" },
|
||||||
|
];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?claimed=1",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
5,
|
||||||
|
"credits",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to defaults when the settings query fails", async () => {
|
||||||
|
state.settingsFail = true;
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?claimed=1",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
5,
|
||||||
|
"diamonds",
|
||||||
|
30,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebukes users with no referrals row as no_referrals", async () => {
|
||||||
|
state.referralsRows = [];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=no_referrals",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a failed referrals read as no_referrals", async () => {
|
||||||
|
state.referralsFail = true;
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=no_referrals",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zero referrals as no_referrals", async () => {
|
||||||
|
state.referralsRows = [{ id: BIG_ID, referralsTotal: 0n }];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=no_referrals",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a tally below the threshold as not_enough", async () => {
|
||||||
|
state.referralsRows = [{ id: BIG_ID, referralsTotal: 3n }];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=not_enough",
|
||||||
|
);
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown reward currency as bad_config without granting", async () => {
|
||||||
|
state.settingsRows = [
|
||||||
|
{ key: "referral_reward_currency_type", value: "Fragments" },
|
||||||
|
];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=bad_config",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive or garbled reward amount as bad_config", async () => {
|
||||||
|
state.settingsRows = [
|
||||||
|
{ key: "referral_reward_amount", value: "abc" },
|
||||||
|
];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=bad_config",
|
||||||
|
);
|
||||||
|
state.settingsRows = [{ key: "referral_reward_amount", value: "-5" }];
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=bad_config",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls the threshold back when currency delivery fails and reports error", async () => {
|
||||||
|
state.sendCurrency.mockRejectedValueOnce(new Error("rcon unavailable"));
|
||||||
|
state.update.mockResolvedValueOnce([{ affectedRows: 1 }]);
|
||||||
|
state.update.mockResolvedValueOnce([{ affectedRows: 1 }]);
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=error",
|
||||||
|
);
|
||||||
|
expect(state.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/me");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports error when the rollback sweep fails", async () => {
|
||||||
|
state.sendCurrency.mockRejectedValueOnce(new Error("rcon unavailable"));
|
||||||
|
state.update.mockResolvedValueOnce([{ affectedRows: 1 }]);
|
||||||
|
state.update.mockRejectedValueOnce(new Error("rollback down"));
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?error=error",
|
||||||
|
);
|
||||||
|
expect(state.update).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports claimed when the audit log write fails (best-effort)", async () => {
|
||||||
|
state.insert.mockRejectedValueOnce(new Error("log down"));
|
||||||
|
await expect(claimReferral(new FormData())).rejects.toThrow(
|
||||||
|
"/me?claimed=1",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
captchaConfig: vi.fn(async () => ({
|
||||||
|
provider: "none",
|
||||||
|
siteKey: "",
|
||||||
|
field: "cf-turnstile-response",
|
||||||
|
})),
|
||||||
|
verifyCaptcha: vi.fn(async () => true),
|
||||||
|
siteGet: vi.fn(async () => ""),
|
||||||
|
siteGetBool: vi.fn(async () => false),
|
||||||
|
checkVpn: vi.fn(async () => ({ blocked: false })),
|
||||||
|
hashPassword: vi.fn(async (password: string) => `hashed:${password}`),
|
||||||
|
invalidateKey: vi.fn(async () => 1),
|
||||||
|
recordReferral: vi.fn(async () => undefined),
|
||||||
|
sendVerification: vi.fn(async () => undefined),
|
||||||
|
logger: { warn: vi.fn(), error: vi.fn() },
|
||||||
|
after: vi.fn(),
|
||||||
|
afterCb: null as null | (() => Promise<void>),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 42 }]),
|
||||||
|
userRows: [] as Array<{ id: number }>,
|
||||||
|
countTotal: 0,
|
||||||
|
failCount: false,
|
||||||
|
failUsernameCheck: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/server", () => ({
|
||||||
|
after: state.after,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/email-verification", () => ({
|
||||||
|
sendVerification: state.sendVerification,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/password", () => ({
|
||||||
|
hashPassword: state.hashPassword,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/cached-db", () => ({ invalidateKey: state.invalidateKey }));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: state.logger }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/captcha", () => ({
|
||||||
|
captchaConfig: state.captchaConfig,
|
||||||
|
verifyCaptcha: state.verifyCaptcha,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/ip-lookup", () => ({ checkVpn: state.checkVpn }));
|
||||||
|
vi.mock("@/lib/services/referrals", () => ({
|
||||||
|
recordReferral: state.recordReferral,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: {
|
||||||
|
get: state.siteGet,
|
||||||
|
getBool: state.siteGetBool,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table: unknown, projection: Record<string, unknown>) => {
|
||||||
|
if ("total" in projection) {
|
||||||
|
if (state.failCount) return Promise.reject(new Error("count down"));
|
||||||
|
return [{ total: state.countTotal }];
|
||||||
|
}
|
||||||
|
if (state.failUsernameCheck) throw new Error("check down");
|
||||||
|
return state.userRows;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => state.insert(table, values),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { User } from "@/lib/db";
|
||||||
|
import { register } from "./register";
|
||||||
|
|
||||||
|
const PREV: { error: string | null; ok: boolean } = { error: null, ok: true };
|
||||||
|
|
||||||
|
function buildForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("username", "Alice_123");
|
||||||
|
f.set("mail", "");
|
||||||
|
f.set("password", "Secret123");
|
||||||
|
f.set("password_confirmation", "Secret123");
|
||||||
|
f.set("terms", "on");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runValidRegistration() {
|
||||||
|
return await register(PREV, buildForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("register", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.siteGet.mockImplementation(async () => "");
|
||||||
|
state.siteGetBool.mockResolvedValue(false);
|
||||||
|
state.checkVpn.mockResolvedValue({ blocked: false });
|
||||||
|
state.captchaConfig.mockResolvedValue({
|
||||||
|
provider: "none",
|
||||||
|
siteKey: "",
|
||||||
|
field: "cf-turnstile-response",
|
||||||
|
});
|
||||||
|
state.verifyCaptcha.mockResolvedValue(true);
|
||||||
|
state.hashPassword.mockImplementation(
|
||||||
|
async (password: string) => `hashed:${password}`,
|
||||||
|
);
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 42 }]);
|
||||||
|
state.afterCb = null;
|
||||||
|
state.after.mockImplementation((cb: () => Promise<void>) => {
|
||||||
|
state.afterCb = cb;
|
||||||
|
});
|
||||||
|
state.userRows = [];
|
||||||
|
state.countTotal = 0;
|
||||||
|
state.failCount = false;
|
||||||
|
state.failUsernameCheck = false;
|
||||||
|
state.sendVerification.mockResolvedValue(undefined);
|
||||||
|
state.recordReferral.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the account and returns ok", async () => {
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({ error: null, ok: true });
|
||||||
|
expect(state.hashPassword).toHaveBeenCalledWith("Secret123");
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(User);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
username: "Alice_123",
|
||||||
|
password: "hashed:Secret123",
|
||||||
|
mail: null,
|
||||||
|
accountCreated: expect.any(Number),
|
||||||
|
ipRegister: "203.0.113.9",
|
||||||
|
ipCurrent: "203.0.113.9",
|
||||||
|
look: "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
|
||||||
|
termsAccepted: true,
|
||||||
|
});
|
||||||
|
expect(state.invalidateKey).toHaveBeenCalledWith("login:user:Alice_123");
|
||||||
|
expect(state.recordReferral).not.toHaveBeenCalled();
|
||||||
|
expect(state.after).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes the username and lowercases the trimmed mail", async () => {
|
||||||
|
await register(
|
||||||
|
PREV,
|
||||||
|
buildForm({ username: " Bob_88 ", mail: " [email protected] " }),
|
||||||
|
);
|
||||||
|
const values = state.insert.mock.calls[0][1] as {
|
||||||
|
username: string;
|
||||||
|
mail: string;
|
||||||
|
};
|
||||||
|
expect(values.username).toBe("Bob_88");
|
||||||
|
expect(values.mail).toBe("[email protected]");
|
||||||
|
expect(state.after).toHaveBeenCalledOnce();
|
||||||
|
await state.afterCb!();
|
||||||
|
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs a warning when the verification email fails to send", async () => {
|
||||||
|
state.sendVerification.mockRejectedValue(new Error("smtp down"));
|
||||||
|
await register(PREV, buildForm({ mail: "[email protected]" }));
|
||||||
|
await state.afterCb!();
|
||||||
|
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||||
|
"Failed to send verification email after registration",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects short usernames", async () => {
|
||||||
|
const result = await register(PREV, buildForm({ username: "ab" }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: "Username must be at least 3 characters",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects usernames containing characters outside the allowed set", async () => {
|
||||||
|
const result = await register(PREV, buildForm({ username: "bad name!" }));
|
||||||
|
expect(result.error).toContain("invalid characters");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid emails", async () => {
|
||||||
|
const result = await register(PREV, buildForm({ mail: "not-an-email" }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: "Enter a valid email address",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects weak passwords", async () => {
|
||||||
|
const result = await register(PREV, buildForm({ password: "short" }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: "Password must be at least 8 characters",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects passwords without an uppercase letter", async () => {
|
||||||
|
const result = await register(
|
||||||
|
PREV,
|
||||||
|
buildForm({ password: "s3cret123", password_confirmation: "s3cret123" }),
|
||||||
|
);
|
||||||
|
expect(result.error).toContain("uppercase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects passwords without a digit", async () => {
|
||||||
|
const result = await register(
|
||||||
|
PREV,
|
||||||
|
buildForm({ password: "Secretsecret", password_confirmation: "Secretsecret" }),
|
||||||
|
);
|
||||||
|
expect(result.error).toContain("digit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mismatched password confirmations", async () => {
|
||||||
|
const result = await register(
|
||||||
|
PREV,
|
||||||
|
buildForm({ password_confirmation: "Different1" }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ error: "Passwords do not match", ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throttles sign-ups per IP", async () => {
|
||||||
|
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result.error).toContain("Too many sign-up attempts");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies the CAPTCHA when one is configured", async () => {
|
||||||
|
state.captchaConfig.mockResolvedValue({
|
||||||
|
provider: "turnstile",
|
||||||
|
siteKey: "key",
|
||||||
|
field: "cf-turnstile-response",
|
||||||
|
});
|
||||||
|
state.verifyCaptcha.mockResolvedValueOnce(false);
|
||||||
|
const result = await register(
|
||||||
|
PREV,
|
||||||
|
buildForm({ "cf-turnstile-response": "token" }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: "Captcha verification failed. Please try again.",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
state.verifyCaptcha.mockResolvedValueOnce(true);
|
||||||
|
const ok = await register(
|
||||||
|
PREV,
|
||||||
|
buildForm({ "cf-turnstile-response": "token" }),
|
||||||
|
);
|
||||||
|
expect(ok).toEqual({ error: null, ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires accepting the terms", async () => {
|
||||||
|
const result = await register(PREV, buildForm({ terms: undefined }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: "You must accept the terms and conditions to register.",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks VPN registrations with the configured message", async () => {
|
||||||
|
state.checkVpn.mockResolvedValue({ blocked: true });
|
||||||
|
state.siteGet.mockResolvedValueOnce("Custom VPN message");
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({ error: "Custom VPN message", ok: false });
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks VPN registrations with the default message when unset", async () => {
|
||||||
|
state.checkVpn.mockResolvedValue({ blocked: true });
|
||||||
|
state.siteGet.mockResolvedValueOnce("");
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result.error).toBe(
|
||||||
|
"Registrations from VPN/proxy connections are not allowed.",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks the max-account-per-IP cap when the count is reached", async () => {
|
||||||
|
state.siteGet.mockResolvedValueOnce("2");
|
||||||
|
state.countTotal = 2;
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result.error).toContain("maximum number of accounts");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows registration below the max-account cap", async () => {
|
||||||
|
state.siteGet.mockResolvedValueOnce("2");
|
||||||
|
state.countTotal = 1;
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({ error: null, ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a failed account count as unlimited", async () => {
|
||||||
|
state.siteGet.mockResolvedValueOnce("2");
|
||||||
|
state.failCount = true;
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({ error: null, ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an already-taken username", async () => {
|
||||||
|
state.userRows = [{ id: 7 }];
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({ error: "That username is already taken", ok: false });
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a temporary failure when the uniqueness check itself fails", async () => {
|
||||||
|
state.failUsernameCheck = true;
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: "Registration is temporarily unavailable",
|
||||||
|
ok: false,
|
||||||
|
});
|
||||||
|
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||||
|
"Username uniqueness check failed during registration",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps duplicate-key insert errors to taken username and stays dry on logging", async () => {
|
||||||
|
state.insert.mockRejectedValueOnce({
|
||||||
|
cause: { code: "ER_DUP_ENTRY" },
|
||||||
|
});
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result).toEqual({ error: "That username is already taken", ok: false });
|
||||||
|
expect(state.logger.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a generic creation failure on unexpected insert errors and logs them", async () => {
|
||||||
|
state.insert.mockRejectedValueOnce(new Error("db exploded"));
|
||||||
|
const result = await runValidRegistration();
|
||||||
|
expect(result.error).toContain("Could not create the account");
|
||||||
|
expect(state.logger.error).toHaveBeenCalledWith(
|
||||||
|
"Account creation failed",
|
||||||
|
expect.objectContaining({ message: "db exploded" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a referral when the inviter is provided", async () => {
|
||||||
|
await register(PREV, buildForm({ ref: "Veteran" }));
|
||||||
|
expect(state.recordReferral).toHaveBeenCalledWith({
|
||||||
|
inviterUsername: "Veteran",
|
||||||
|
inviteeId: 42,
|
||||||
|
inviteeIp: "203.0.113.9",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a custom look when one is supplied", async () => {
|
||||||
|
await register(PREV, buildForm({ look: "hr-123-42.hd-180-1" }));
|
||||||
|
const values = state.insert.mock.calls[0][1] as { look: string };
|
||||||
|
expect(values.look).toBe("hr-123-42.hd-180-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(async () => ({ id: 100, rank: 7, username: "staff" })),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
del: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
update: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
logStaffActivity: vi.fn(async () => undefined),
|
||||||
|
notify: vi.fn(async () => undefined),
|
||||||
|
rconSend: vi.fn(async () => undefined),
|
||||||
|
roomRows: [] as Array<{ name: string }>,
|
||||||
|
denied: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: state.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { ROOMS_EDIT: "admin.room.edit", ROOMS_DELETE: "admin.room.delete" },
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("@/lib/services/staff-activity", () => ({
|
||||||
|
logStaffActivity: state.logStaffActivity,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/webhook", () => ({ notify: state.notify }));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: state.rconSend } }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table: unknown, projection: Record<string, unknown>) => {
|
||||||
|
if (state.denied) throw new Error("not allowed");
|
||||||
|
return state.roomRows;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => state.del(table, where),
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => state.update(table, values, where),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { Items, Rooms } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
bulkDeleteRoomItems,
|
||||||
|
deleteRoom,
|
||||||
|
deleteRoomItem,
|
||||||
|
roomRconAction,
|
||||||
|
updateRoom,
|
||||||
|
updateRoomItem,
|
||||||
|
} from "./rooms";
|
||||||
|
|
||||||
|
describe("rooms actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.denied = false;
|
||||||
|
state.roomRows = [{ name: "Lobby" }];
|
||||||
|
state.requirePermission.mockResolvedValue({
|
||||||
|
id: 100,
|
||||||
|
rank: 7,
|
||||||
|
username: "staff",
|
||||||
|
});
|
||||||
|
state.del.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the ROOMS_EDIT permission for updateRoomItem", async () => {
|
||||||
|
await updateRoomItem({ roomId: 9, itemId: 4, custom: "x" });
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit");
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
Items,
|
||||||
|
{ custom: "x" },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "room_item_update",
|
||||||
|
description: "Updated item #4 in room #9",
|
||||||
|
targetType: "room_item",
|
||||||
|
targetId: 4,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/rooms/9/furni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies room edits without permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(
|
||||||
|
updateRoomItem({ roomId: 9, itemId: 4 }),
|
||||||
|
).rejects.toThrow("forbidden");
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bulk deletes items filtered by room for restricted ids", async () => {
|
||||||
|
await bulkDeleteRoomItems({ roomId: 9, itemIds: [1, 2] });
|
||||||
|
expect(state.del).toHaveBeenCalledWith(Items, expect.anything());
|
||||||
|
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "room_items_bulk_delete",
|
||||||
|
description: "Deleted 2 item(s) from room #9",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/rooms/9/furni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes an empty bulk delete with a zero count", async () => {
|
||||||
|
await bulkDeleteRoomItems({ roomId: 9, itemIds: [] });
|
||||||
|
expect(state.del).toHaveBeenCalledWith(Items, expect.anything());
|
||||||
|
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ description: "Deleted 0 item(s) from room #9" }),
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/rooms/9/furni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a single room item", async () => {
|
||||||
|
await deleteRoomItem({ roomId: 9, itemId: 4 });
|
||||||
|
expect(state.del).toHaveBeenCalledWith(Items, expect.anything());
|
||||||
|
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "room_item_delete",
|
||||||
|
description: "Deleted item #4 from room #9",
|
||||||
|
targetId: 4,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/rooms/9/furni");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies bulk and single deletes without permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(
|
||||||
|
bulkDeleteRoomItems({ roomId: 9, itemIds: [1] }),
|
||||||
|
).rejects.toThrow("forbidden");
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(deleteRoomItem({ roomId: 9, itemId: 1 })).rejects.toThrow(
|
||||||
|
"forbidden",
|
||||||
|
);
|
||||||
|
expect(state.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads a room via RCON", async () => {
|
||||||
|
await roomRconAction({ roomId: 9, action: "reload" });
|
||||||
|
expect(state.rconSend).toHaveBeenCalledWith("reloadroom", { room_id: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kicks a room via RCON and notifies staff webhooks", async () => {
|
||||||
|
await roomRconAction({ roomId: 9, action: "kick" });
|
||||||
|
expect(state.rconSend).toHaveBeenCalledWith("kickall", { room_id: 9 });
|
||||||
|
expect(state.notify).toHaveBeenCalledWith({
|
||||||
|
action: "kick",
|
||||||
|
actor: "staff",
|
||||||
|
target: "9",
|
||||||
|
details: "kick",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("locks and unlocks a room via RCON", async () => {
|
||||||
|
await roomRconAction({ roomId: 9, action: "lock" });
|
||||||
|
await roomRconAction({ roomId: 9, action: "unlock" });
|
||||||
|
expect(state.rconSend).toHaveBeenCalledWith("updateroom", {
|
||||||
|
room_id: 9,
|
||||||
|
state: "locked",
|
||||||
|
});
|
||||||
|
expect(state.rconSend).toHaveBeenCalledWith("updateroom", {
|
||||||
|
room_id: 9,
|
||||||
|
state: "open",
|
||||||
|
});
|
||||||
|
expect(state.notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("performs no RCON or webhook call for an unknown action", async () => {
|
||||||
|
await roomRconAction({ roomId: 9, action: "partywave" });
|
||||||
|
expect(state.rconSend).not.toHaveBeenCalled();
|
||||||
|
expect(state.notify).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies RCON actions without permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(roomRconAction({ roomId: 9, action: "kick" })).rejects.toThrow(
|
||||||
|
"forbidden",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a room and notifies with its name", async () => {
|
||||||
|
await deleteRoom({ id: 9 });
|
||||||
|
expect(state.del).toHaveBeenCalledWith(Rooms, expect.anything());
|
||||||
|
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "room_delete",
|
||||||
|
description: "Deleted room #9",
|
||||||
|
targetId: 9,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.notify).toHaveBeenCalledWith({
|
||||||
|
action: "room_delete",
|
||||||
|
actor: "staff",
|
||||||
|
target: "Lobby",
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/rooms");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the numeric id for an unknown room on delete", async () => {
|
||||||
|
state.roomRows = [];
|
||||||
|
await deleteRoom({ id: 9 });
|
||||||
|
expect(state.notify).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ target: "#9" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies room deletion without the delete permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(deleteRoom({ id: 9 })).rejects.toThrow("forbidden");
|
||||||
|
expect(state.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates room fields while discarding the id", async () => {
|
||||||
|
await updateRoom({ id: 9, name: "New", usersMax: 50 });
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith("admin.room.edit");
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
Rooms,
|
||||||
|
{ name: "New", usersMax: 50 },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(state.logStaffActivity).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "room_update",
|
||||||
|
description: "Updated room #9",
|
||||||
|
targetId: 9,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/rooms/9");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies room updates without permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(updateRoom({ id: 9 })).rejects.toThrow("forbidden");
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
signOut: vi.fn(async () => undefined),
|
||||||
|
invalidateJwtVersionCache: vi.fn(async () => undefined),
|
||||||
|
personalTokenScope: vi.fn(() => ({
|
||||||
|
tokenableId: 5n,
|
||||||
|
tokenableType: "App\\Models\\User",
|
||||||
|
})),
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
update: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
del: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
failUpdate: false,
|
||||||
|
failDelete: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({
|
||||||
|
auth: state.auth,
|
||||||
|
signOut: state.signOut,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/jwt-version-cache", () => ({
|
||||||
|
invalidateJwtVersionCache: state.invalidateJwtVersionCache,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth/personal-token-scope", () => ({
|
||||||
|
personalTokenScope: state.personalTokenScope,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/logger", () => ({ logger: state.logger }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) =>
|
||||||
|
state.failUpdate
|
||||||
|
? Promise.reject(new Error("update down"))
|
||||||
|
: state.update(table, values, where),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) =>
|
||||||
|
state.failDelete
|
||||||
|
? Promise.reject(new Error("delete down"))
|
||||||
|
: state.del(table, where),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { PersonalAccessTokens, User } from "@/lib/db";
|
||||||
|
import { signOutAndRevokeTicket, signOutEverywhere } from "./sessions";
|
||||||
|
|
||||||
|
describe("signOutEverywhere", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.failUpdate = false;
|
||||||
|
state.failDelete = false;
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.del.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.signOut.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bumps the JWT version, clears the ticket, deletes tokens and signs out everywhere", async () => {
|
||||||
|
await signOutEverywhere();
|
||||||
|
expect(state.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.update.mock.calls[0][0]).toBe(User);
|
||||||
|
expect(state.update.mock.calls[0][1]).toMatchObject({
|
||||||
|
authTicket: "",
|
||||||
|
websiteJwtVersion: expect.anything(),
|
||||||
|
});
|
||||||
|
expect(state.invalidateJwtVersionCache).toHaveBeenCalledWith(5);
|
||||||
|
expect(state.del).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.del.mock.calls[0][0]).toBe(PersonalAccessTokens);
|
||||||
|
expect(state.signOut).toHaveBeenCalledWith({
|
||||||
|
redirectTo: "/login?signedOutAll=1",
|
||||||
|
});
|
||||||
|
expect(state.logger.warn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only signs out (to /login) when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await signOutEverywhere();
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.del).not.toHaveBeenCalled();
|
||||||
|
expect(state.signOut).toHaveBeenCalledWith({ redirectTo: "/login" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unusable session ids the same way", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "0" } });
|
||||||
|
await signOutEverywhere();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "abc" } });
|
||||||
|
await signOutEverywhere();
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.signOut).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns and keeps going when the user update fails", async () => {
|
||||||
|
state.failUpdate = true;
|
||||||
|
await signOutEverywhere();
|
||||||
|
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||||
|
"Failed to bump JWT version during sign-out-everywhere",
|
||||||
|
expect.objectContaining({ userId: 5 }),
|
||||||
|
);
|
||||||
|
expect(state.del).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns and keeps going when token revocation fails", async () => {
|
||||||
|
state.failDelete = true;
|
||||||
|
state.failUpdate = false;
|
||||||
|
await signOutEverywhere();
|
||||||
|
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||||
|
"Failed to revoke personal access tokens during sign-out-everywhere",
|
||||||
|
expect.objectContaining({ userId: 5 }),
|
||||||
|
);
|
||||||
|
expect(state.signOut).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signOutAndRevokeTicket", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.failUpdate = false;
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.signOut.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the SSO ticket and signs out to /", async () => {
|
||||||
|
await signOutAndRevokeTicket();
|
||||||
|
expect(state.update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.update.mock.calls[0][0]).toBe(User);
|
||||||
|
expect(state.update.mock.calls[0][1]).toEqual({ authTicket: "" });
|
||||||
|
expect(state.signOut).toHaveBeenCalledWith({ redirectTo: "/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still signs out when unauthenticated but skips the ticket write", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await signOutAndRevokeTicket();
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
expect(state.signOut).toHaveBeenCalledWith({ redirectTo: "/" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns but still signs out when the ticket revoke fails", async () => {
|
||||||
|
state.failUpdate = true;
|
||||||
|
await signOutAndRevokeTicket();
|
||||||
|
expect(state.logger.warn).toHaveBeenCalledWith(
|
||||||
|
"Failed to revoke SSO ticket during sign out",
|
||||||
|
expect.objectContaining({ userId: 5 }),
|
||||||
|
);
|
||||||
|
expect(state.signOut).toHaveBeenCalledWith({ redirectTo: "/" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
type RowList = Row[];
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
creditsPerUnit: vi.fn(() => 10),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 1 }]),
|
||||||
|
update: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
sendCurrency: vi.fn(async () => true),
|
||||||
|
giveBadge: vi.fn(async () => undefined),
|
||||||
|
logServerError: vi.fn(),
|
||||||
|
articleRows: [] as RowList,
|
||||||
|
userRows: [] as RowList,
|
||||||
|
badgeRows: [] as RowList,
|
||||||
|
badgeQueue: [] as RowList[],
|
||||||
|
isBadge: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw Object.assign(new Error(path), { digest: `NEXT_REDIRECT:${path}` });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/paypal", () => ({
|
||||||
|
creditsPerUnit: state.creditsPerUnit,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/send-currency", () => ({
|
||||||
|
sendCurrency: state.sendCurrency,
|
||||||
|
currencyDb: { user: {}, usersCurrency: {} },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { giveBadge: state.giveBadge },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/server-log", () => ({
|
||||||
|
logServerError: state.logServerError,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb((table: unknown, projection: Record<string, unknown>) => {
|
||||||
|
if (table === schema.UsersBadges) {
|
||||||
|
if (state.badgeQueue.length) return state.badgeQueue.shift() as RowList;
|
||||||
|
return state.badgeRows;
|
||||||
|
}
|
||||||
|
if (table === schema.User) return state.userRows;
|
||||||
|
if (table === schema.WebsiteShopArticles) return state.articleRows;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const db = {
|
||||||
|
...fake,
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => state.update(table, values, where),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) => state.insert(table, values),
|
||||||
|
}),
|
||||||
|
transaction: (fn: (tx: unknown) => Promise<unknown>) =>
|
||||||
|
state.transaction(fn, db),
|
||||||
|
};
|
||||||
|
return { ...schema, db };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { User, UsersBadges } from "@/lib/db";
|
||||||
|
import { buyShopArticle } from "./shop";
|
||||||
|
|
||||||
|
const ARTICLE: Row = {
|
||||||
|
id: 3n,
|
||||||
|
name: "StarterPack",
|
||||||
|
costs: 100,
|
||||||
|
credits: 20,
|
||||||
|
duckets: 10,
|
||||||
|
diamonds: 5,
|
||||||
|
badges: "ABC,DEF",
|
||||||
|
giveRank: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function shopForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("categoryId", "1");
|
||||||
|
f.set("articleId", "3");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buyShopArticle", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.creditsPerUnit.mockReturnValue(10);
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.sendCurrency.mockResolvedValue(true);
|
||||||
|
state.giveBadge.mockResolvedValue(undefined);
|
||||||
|
state.logServerError.mockImplementation(() => undefined);
|
||||||
|
state.articleRows = [ARTICLE];
|
||||||
|
state.userRows = [{ credits: 500, rank: 3 }];
|
||||||
|
state.badgeRows = [];
|
||||||
|
state.badgeQueue = [];
|
||||||
|
state.isBadge = false;
|
||||||
|
state.transaction.mockImplementation(
|
||||||
|
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) =>
|
||||||
|
fn(txDb),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges credits, grants configured currencies, badges and redirects with bought=1", async () => {
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1&package=StarterPack",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith("shop-buy:5", 5, 60_000);
|
||||||
|
expect(state.transaction).toHaveBeenCalled();
|
||||||
|
expect(state.update).toHaveBeenCalledWith(User, expect.anything(), expect.anything());
|
||||||
|
expect(state.insert).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(UsersBadges);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toEqual({
|
||||||
|
userId: 5,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "ABC",
|
||||||
|
});
|
||||||
|
expect(state.insert.mock.calls[1][1]).toEqual({
|
||||||
|
userId: 5,
|
||||||
|
slotId: 1,
|
||||||
|
badgeCode: "DEF",
|
||||||
|
});
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledTimes(3);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
5,
|
||||||
|
"credits",
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
5,
|
||||||
|
"duckets",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
5,
|
||||||
|
"diamonds",
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/shop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the category query param when it is not numeric", async () => {
|
||||||
|
await expect(buyShopArticle(shopForm({ categoryId: "abc" }))).rejects.toThrow(
|
||||||
|
"/shop?bought=1&package=StarterPack",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises the buyer rank when the package grants a higher rank", async () => {
|
||||||
|
state.articleRows = [{ ...ARTICLE, giveRank: 7 }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
User,
|
||||||
|
expect.objectContaining({ rank: 7 }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not lower or equal a rank, nor bump it for a null giveRank", async () => {
|
||||||
|
state.articleRows = [{ ...ARTICLE, giveRank: 2 }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
state.articleRows = [{ ...ARTICLE, giveRank: null }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.update).not.toHaveBeenCalledWith(
|
||||||
|
User,
|
||||||
|
expect.objectContaining({ rank: expect.anything() }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an RCON badge grant when the emulator errors and logs the failure", async () => {
|
||||||
|
state.giveBadge.mockRejectedValue(new Error("emulator offline"));
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.logServerError).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.logServerError).toHaveBeenCalledWith(
|
||||||
|
"shop.give_badge_failed",
|
||||||
|
expect.any(Error),
|
||||||
|
expect.objectContaining({ userId: 5, code: "ABC" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores badge codes longer than 32 characters or whitespace", async () => {
|
||||||
|
state.articleRows = [
|
||||||
|
{ ...ARTICLE, badges: "OK,, , VERYLONG_badge_code_that_exceeds_32_chars" },
|
||||||
|
];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.insert).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({ badgeCode: "OK" });
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledTimes(1);
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledWith(5, "OK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-issue a badge the user already owns", async () => {
|
||||||
|
state.badgeRows = [{ id: 1 }, { id: 2 }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
expect(state.giveBadge).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues badge slot numbers from the highest open slot", async () => {
|
||||||
|
state.badgeQueue = [[], [{ maxSlot: 4 }], [], [{ maxSlot: 9 }]];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.insert).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
badgeCode: "ABC",
|
||||||
|
slotId: 5,
|
||||||
|
});
|
||||||
|
expect(state.insert.mock.calls[1][1]).toMatchObject({
|
||||||
|
badgeCode: "DEF",
|
||||||
|
slotId: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices sub-1.00 packages at one dollar worth of credits", async () => {
|
||||||
|
state.articleRows = [{ ...ARTICLE, costs: 50 }];
|
||||||
|
state.userRows = [{ credits: 5, rank: 3 }];
|
||||||
|
// costs 50 -> dollars 1 -> price = 1 * rate(10) = 10; buyer has 5, not enough.
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=credits",
|
||||||
|
);
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
state.userRows = [{ credits: 500, rank: 3 }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
expect(state.transaction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with credits when the buyer cannot cover the price", async () => {
|
||||||
|
state.userRows = [{ credits: 5, rank: 3 }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=credits",
|
||||||
|
);
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
state.userRows = [];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=credits",
|
||||||
|
);
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-numeric article id as invalid", async () => {
|
||||||
|
await expect(
|
||||||
|
buyShopArticle(shopForm({ articleId: "t-shirt" })),
|
||||||
|
).rejects.toThrow("/shop?category=1&error=invalid");
|
||||||
|
await expect(buyShopArticle(shopForm({ articleId: "4.5" }))).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=invalid",
|
||||||
|
);
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown article as invalid", async () => {
|
||||||
|
state.articleRows = [];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=invalid",
|
||||||
|
);
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 30 });
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "0" } });
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces transaction failures as error=error and never auto-signals sends", async () => {
|
||||||
|
state.transaction.mockRejectedValue(new Error("tx deadlock"));
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=error",
|
||||||
|
);
|
||||||
|
expect(state.sendCurrency).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/shop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces currency delivery failures as error=error", async () => {
|
||||||
|
state.sendCurrency.mockRejectedValueOnce(new Error("wallet down"));
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&error=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a zero-cost package at one dollar worth of credits but still charges a nonzero amount", async () => {
|
||||||
|
state.articleRows = [{ ...ARTICLE, costs: 0 }];
|
||||||
|
await expect(buyShopArticle(shopForm())).rejects.toThrow(
|
||||||
|
"/shop?category=1&bought=1",
|
||||||
|
);
|
||||||
|
const updateCall = state.update.mock.calls[0] as [unknown, Record<string, unknown>];
|
||||||
|
expect(updateCall[1]).toHaveProperty("credits");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
type Queue = Array<Array<Record<string, unknown>>>;
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(async () => ({ user: { id: "5" } })),
|
||||||
|
rateLimit: vi.fn(async () => ({ ok: true })),
|
||||||
|
clientIp: vi.fn(async () => "203.0.113.9"),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
insert: vi.fn(async () => [{ insertId: 500 }]),
|
||||||
|
update: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
selectQueue: [] as Queue,
|
||||||
|
rows: [] as Array<Record<string, unknown>>,
|
||||||
|
failInsert: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw Object.assign(new Error(path), { digest: `NEXT_REDIRECT:${path}` });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
rateLimit: state.rateLimit,
|
||||||
|
clientIp: state.clientIp,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => {
|
||||||
|
if (state.selectQueue.length) return state.selectQueue.shift() as Queue[number];
|
||||||
|
return state.rows;
|
||||||
|
});
|
||||||
|
const db = {
|
||||||
|
...fake,
|
||||||
|
insert: (table: unknown) => ({
|
||||||
|
values: (values: unknown) =>
|
||||||
|
state.failInsert
|
||||||
|
? Promise.reject(new Error("db down"))
|
||||||
|
: state.insert(table, values),
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => state.update(table, values, where),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
transaction: (fn: (tx: unknown) => Promise<unknown>) =>
|
||||||
|
state.transaction(fn, db),
|
||||||
|
};
|
||||||
|
return { ...schema, db };
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
GuildsForumsComments,
|
||||||
|
GuildsForumsThreads,
|
||||||
|
MessengerFriendrequests,
|
||||||
|
} from "@/lib/db";
|
||||||
|
import { postThread, replyToThread, sendFriendRequest } from "./social";
|
||||||
|
|
||||||
|
function friendForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("username", "Bob");
|
||||||
|
f.set("userId", "9");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("guildId", "10");
|
||||||
|
f.set("subject", "Welcome thread");
|
||||||
|
f.set("message", "Hello from the community");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replyForm(overrides: Record<string, string | undefined> = {}) {
|
||||||
|
const f = new FormData();
|
||||||
|
f.set("guildId", "10");
|
||||||
|
f.set("threadId", "20");
|
||||||
|
f.set("message", "A thoughtful reply");
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v === undefined) f.delete(k);
|
||||||
|
else f.set(k, v);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sendFriendRequest", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
state.failInsert = false;
|
||||||
|
state.selectQueue = [];
|
||||||
|
state.rows = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a friend request and redirects to the profile with friend=sent", async () => {
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow(
|
||||||
|
"/u/Bob?friend=sent",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith("friend:5", 5, 60_000);
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(MessengerFriendrequests);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toEqual({
|
||||||
|
userFromId: 5,
|
||||||
|
userToId: 9,
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/u/Bob");
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to the root path when no username is supplied", async () => {
|
||||||
|
await expect(sendFriendRequest(friendForm({ username: "" }))).rejects.toThrow(
|
||||||
|
"/?friend=sent",
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/messages");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing or non-positive target user id as invalid", async () => {
|
||||||
|
await expect(
|
||||||
|
sendFriendRequest(friendForm({ userId: "abc" })),
|
||||||
|
).rejects.toThrow("/u/Bob?error=invalid");
|
||||||
|
await expect(sendFriendRequest(friendForm({ userId: "0" }))).rejects.toThrow(
|
||||||
|
"/u/Bob?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
sendFriendRequest(friendForm({ userId: "5.5" })),
|
||||||
|
).rejects.toThrow("/u/Bob?error=invalid");
|
||||||
|
await expect(
|
||||||
|
sendFriendRequest(friendForm({ userId: undefined })),
|
||||||
|
).rejects.toThrow("/u/Bob?error=invalid");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks sending a request to yourself", async () => {
|
||||||
|
await expect(sendFriendRequest(friendForm({ userId: "5" }))).rejects.toThrow(
|
||||||
|
"/u/Bob?error=self",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports already_friends when a friendship exists either way", async () => {
|
||||||
|
state.selectQueue = [[], [], [{ id: 1 }]];
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow(
|
||||||
|
"/u/Bob?error=already_friends",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports already_pending when an outbound request exists", async () => {
|
||||||
|
state.selectQueue = [[{ id: 1 }], [], []];
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow(
|
||||||
|
"/u/Bob?error=already_pending",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports incoming_pending when the target already asked you", async () => {
|
||||||
|
state.selectQueue = [[], [{ id: 2 }], []];
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow(
|
||||||
|
"/u/Bob?error=incoming_pending",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 9 });
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow(
|
||||||
|
"/u/Bob?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow("/login");
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "0" } });
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows write failures and redirects with error=error", async () => {
|
||||||
|
state.failInsert = true;
|
||||||
|
await expect(sendFriendRequest(friendForm())).rejects.toThrow(
|
||||||
|
"/u/Bob?error=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("postThread", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 500 }]);
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.failInsert = false;
|
||||||
|
state.selectQueue = [];
|
||||||
|
state.rows = [];
|
||||||
|
state.transaction.mockImplementation(
|
||||||
|
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) =>
|
||||||
|
fn(txDb),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a thread plus its opening comment and redirects to the forum", async () => {
|
||||||
|
state.selectQueue = [[{ id: 10 }]];
|
||||||
|
await expect(postThread(threadForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum?posted=1",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith("forum:5", 3, 60_000);
|
||||||
|
expect(state.insert).toHaveBeenCalledTimes(2);
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(GuildsForumsThreads);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
guildId: 10,
|
||||||
|
openerId: 5,
|
||||||
|
subject: "Welcome thread",
|
||||||
|
postsCount: 1,
|
||||||
|
state: 0,
|
||||||
|
pinned: 0,
|
||||||
|
locked: 0,
|
||||||
|
adminId: 0,
|
||||||
|
});
|
||||||
|
expect(state.insert.mock.calls[1][0]).toBe(GuildsForumsComments);
|
||||||
|
expect(state.insert.mock.calls[1][1]).toMatchObject({
|
||||||
|
threadId: 500,
|
||||||
|
userId: 5,
|
||||||
|
message: "Hello from the community",
|
||||||
|
state: 0,
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/guilds/10/forum");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates the subject and message to their bounds", async () => {
|
||||||
|
state.selectQueue = [[{ id: 10 }]];
|
||||||
|
await expect(
|
||||||
|
postThread(
|
||||||
|
threadForm({
|
||||||
|
subject: "S".repeat(300),
|
||||||
|
message: "M".repeat(12000),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("/guilds/10/forum?posted=1");
|
||||||
|
const subject = state.insert.mock.calls[0][1] as { subject: string };
|
||||||
|
const message = state.insert.mock.calls[1][1] as { message: string };
|
||||||
|
expect(subject.subject).toHaveLength(255);
|
||||||
|
expect(message.message).toHaveLength(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty subject or message as invalid", async () => {
|
||||||
|
await expect(postThread(threadForm({ subject: "" }))).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/new?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(postThread(threadForm({ message: " " }))).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/new?error=invalid",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing or non-positive guild id as invalid", async () => {
|
||||||
|
await expect(postThread(threadForm({ guildId: "abc" }))).rejects.toThrow(
|
||||||
|
"/guilds/new?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(postThread(threadForm({ guildId: "0" }))).rejects.toThrow(
|
||||||
|
"/guilds/new?error=invalid",
|
||||||
|
);
|
||||||
|
await expect(postThread(threadForm({ guildId: "5.5" }))).rejects.toThrow(
|
||||||
|
"/guilds/new?error=invalid",
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not_found when the guild does not exist", async () => {
|
||||||
|
state.selectQueue = [[]];
|
||||||
|
await expect(postThread(threadForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/new?error=not_found",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 4 });
|
||||||
|
await expect(postThread(threadForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/new?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(postThread(threadForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows transaction failures and redirects with error=error", async () => {
|
||||||
|
state.selectQueue = [[{ id: 10 }]];
|
||||||
|
state.transaction.mockRejectedValue(new Error("tx abort"));
|
||||||
|
await expect(postThread(threadForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/new?error=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replyToThread", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "5" } });
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: true });
|
||||||
|
state.insert.mockResolvedValue([{ insertId: 1 }]);
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.failInsert = false;
|
||||||
|
state.selectQueue = [];
|
||||||
|
state.rows = [];
|
||||||
|
state.transaction.mockImplementation(
|
||||||
|
async (fn: (tx: unknown) => Promise<unknown>, txDb: unknown) =>
|
||||||
|
fn(txDb),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends a comment and bumps the thread counter", async () => {
|
||||||
|
state.selectQueue = [[{ id: 20, locked: 0, postsCount: 3 }]];
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?replied=1",
|
||||||
|
);
|
||||||
|
expect(state.rateLimit).toHaveBeenCalledWith("forum-reply:5", 5, 60_000);
|
||||||
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
|
expect(state.insert.mock.calls[0][0]).toBe(GuildsForumsComments);
|
||||||
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
|
threadId: 20,
|
||||||
|
userId: 5,
|
||||||
|
message: "A thoughtful reply",
|
||||||
|
state: 0,
|
||||||
|
});
|
||||||
|
expect(state.update).toHaveBeenCalledOnce();
|
||||||
|
expect(state.update.mock.calls[0][0]).toBe(GuildsForumsThreads);
|
||||||
|
expect(state.update.mock.calls[0][1]).toMatchObject({
|
||||||
|
postsCount: 4,
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
});
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/guilds/10/forum");
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith(
|
||||||
|
"/guilds/10/forum/20",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors a null posts_count at one", async () => {
|
||||||
|
state.selectQueue = [[{ id: 20, locked: 0, postsCount: null }]];
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?replied=1",
|
||||||
|
);
|
||||||
|
expect(state.update.mock.calls[0][1]).toMatchObject({ postsCount: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing or non-positive ids by redirecting to /guilds", async () => {
|
||||||
|
await expect(replyToThread(replyForm({ guildId: "abc" }))).rejects.toThrow(
|
||||||
|
"/guilds",
|
||||||
|
);
|
||||||
|
await expect(replyToThread(replyForm({ threadId: "0" }))).rejects.toThrow(
|
||||||
|
"/guilds",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
expect(state.revalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports not_found when the thread does not match the guild", async () => {
|
||||||
|
state.selectQueue = [[]];
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?error=not_found",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects replies to locked threads", async () => {
|
||||||
|
state.selectQueue = [[{ id: 20, locked: 1, postsCount: 3 }]];
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?error=locked",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty message as invalid on the thread page", async () => {
|
||||||
|
await expect(replyToThread(replyForm({ message: "" }))).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?error=invalid",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects with error=ratelimit when throttled", async () => {
|
||||||
|
state.rateLimit.mockResolvedValue({ ok: false, retryAfter: 3 });
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?error=ratelimit",
|
||||||
|
);
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirects to /login when unauthenticated", async () => {
|
||||||
|
state.auth.mockResolvedValue({ user: { id: undefined } });
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow("/login");
|
||||||
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows transaction failures and redirects with error=error", async () => {
|
||||||
|
state.selectQueue = [[{ id: 20, locked: 0, postsCount: 3 }]];
|
||||||
|
state.transaction.mockRejectedValue(new Error("tx abort"));
|
||||||
|
await expect(replyToThread(replyForm())).rejects.toThrow(
|
||||||
|
"/guilds/10/forum/20?error=error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
requirePermission: vi.fn(async () => ({ id: 100, rank: 7, username: "staff" })),
|
||||||
|
revalidatePath: vi.fn(),
|
||||||
|
del: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
update: vi.fn(async () => [{ affectedRows: 1 }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({
|
||||||
|
requirePermission: state.requirePermission,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { CATALOG_EDIT: "admin.catalog.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: state.revalidatePath }));
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const fake = createFakeDb(() => []);
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: {
|
||||||
|
...fake,
|
||||||
|
delete: (table: unknown) => ({
|
||||||
|
where: (where: unknown) => state.del(table, where),
|
||||||
|
}),
|
||||||
|
update: (table: unknown) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: (where: unknown) => state.update(table, values, where),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { Soundtracks } from "@/lib/db";
|
||||||
|
import { deleteSoundtrack, updateSoundtrack } from "./soundtracks";
|
||||||
|
|
||||||
|
describe("soundtrack actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.requirePermission.mockResolvedValue({
|
||||||
|
id: 100,
|
||||||
|
rank: 7,
|
||||||
|
username: "staff",
|
||||||
|
});
|
||||||
|
state.del.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
state.update.mockResolvedValue([{ affectedRows: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a soundtrack with the catalog edit permission", async () => {
|
||||||
|
await deleteSoundtrack({ id: 12 });
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith("admin.catalog.edit");
|
||||||
|
expect(state.del).toHaveBeenCalledWith(Soundtracks, expect.anything());
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/sounds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies deletion without the catalog edit permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(deleteSoundtrack({ id: 12 })).rejects.toThrow("forbidden");
|
||||||
|
expect(state.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a soundtrack with the catalog edit permission", async () => {
|
||||||
|
await updateSoundtrack({
|
||||||
|
id: 12,
|
||||||
|
name: "Spring",
|
||||||
|
author: "Vivaldi",
|
||||||
|
track: "data:wav",
|
||||||
|
length: 90,
|
||||||
|
});
|
||||||
|
expect(state.requirePermission).toHaveBeenCalledWith("admin.catalog.edit");
|
||||||
|
expect(state.update).toHaveBeenCalledWith(
|
||||||
|
Soundtracks,
|
||||||
|
{ name: "Spring", author: "Vivaldi", track: "data:wav", length: 90 },
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(state.revalidatePath).toHaveBeenCalledWith("/admin/sounds");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies updates without the catalog edit permission", async () => {
|
||||||
|
state.requirePermission.mockRejectedValueOnce(new Error("forbidden"));
|
||||||
|
await expect(
|
||||||
|
updateSoundtrack({ id: 12, name: "x", author: "y", track: "z", length: 1 }),
|
||||||
|
).rejects.toThrow("forbidden");
|
||||||
|
expect(state.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createMockAuth,
|
||||||
|
createMockDb,
|
||||||
|
createMockLogStaffActivity,
|
||||||
|
createMockRevalidatePath,
|
||||||
|
createMockRcon,
|
||||||
|
} from "./test-helpers";
|
||||||
|
|
||||||
|
describe("test-helpers", () => {
|
||||||
|
it("createMockDb returns vi.fn-backed query methods", () => {
|
||||||
|
const db = createMockDb();
|
||||||
|
expect(Object.keys(db).sort()).toEqual(["delete", "insert", "select", "update"]);
|
||||||
|
for (const method of ["insert", "update", "delete", "select"]) {
|
||||||
|
expect(db[method as keyof typeof db]).toBeTypeOf("function");
|
||||||
|
expect(vi.isMockFunction(db[method as keyof typeof db])).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createMockAuth returns next-auth-like object", () => {
|
||||||
|
const auth = createMockAuth();
|
||||||
|
expect(auth).toEqual({ auth: expect.any(Function), handlers: {}, signOut: expect.any(Function) });
|
||||||
|
expect(vi.isMockFunction(auth.auth)).toBe(true);
|
||||||
|
expect(vi.isMockFunction(auth.signOut)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createMockRcon exposes the RCON command surface", () => {
|
||||||
|
const rcon = createMockRcon();
|
||||||
|
expect(Object.keys(rcon).sort()).toEqual([
|
||||||
|
"alertUser",
|
||||||
|
"disconnectUser",
|
||||||
|
"giveBadge",
|
||||||
|
"giveCredits",
|
||||||
|
"muteUser",
|
||||||
|
"removeBadge",
|
||||||
|
"unmuteUser",
|
||||||
|
]);
|
||||||
|
for (const fn of Object.values(rcon)) {
|
||||||
|
expect(vi.isMockFunction(fn)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createMockLogStaffActivity and createMockRevalidatePath return fresh mocks", () => {
|
||||||
|
expect(vi.isMockFunction(createMockLogStaffActivity())).toBe(true);
|
||||||
|
expect(vi.isMockFunction(createMockRevalidatePath())).toBe(true);
|
||||||
|
const a = createMockRevalidatePath();
|
||||||
|
const b = createMockRevalidatePath();
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: [] as string[],
|
||||||
|
existing: [] as { id: number }[],
|
||||||
|
insertCall: undefined as unknown,
|
||||||
|
updateCall: undefined as unknown,
|
||||||
|
deleteCall: undefined as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const actionErrorClass = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class ActionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ActionError";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction: (opts: { permission?: string | readonly string[]; schema?: z.ZodType }, handler: (ctx: any) => unknown) => {
|
||||||
|
return async (input: unknown) => {
|
||||||
|
const needed = Array.isArray(opts.permission)
|
||||||
|
? opts.permission
|
||||||
|
: [opts.permission ?? ""];
|
||||||
|
if (!needed.some((slug) => state.allowed.includes(slug))) {
|
||||||
|
return { ok: false, error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
const ctx: any = {
|
||||||
|
session: { user: { id: 100, name: "staff", rank: 10 } },
|
||||||
|
};
|
||||||
|
if (opts.schema) {
|
||||||
|
const parsed = opts.schema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.data = parsed.data;
|
||||||
|
} else {
|
||||||
|
ctx.data = input;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handler(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof actionErrorClass) {
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
ActionError: actionErrorClass,
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { TICKETS_EDIT: "admin.tickets.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const db = createFakeDb((table) => {
|
||||||
|
if (table === schema.WebsiteTicketTemplate) return state.existing;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
db.insert = vi.fn((table) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
state.insertCall = { table, values };
|
||||||
|
return Promise.resolve([{ insertId: 73n }]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
db.update = vi.fn((table) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.updateCall = { table, values };
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
db.delete = vi.fn((table) => ({
|
||||||
|
where: (condition: unknown) => {
|
||||||
|
state.deleteCall = { table, condition };
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { ...schema, db };
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTemplate,
|
||||||
|
deleteTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
} from "./ticket-templates";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.allowed = ["admin.tickets.edit"];
|
||||||
|
state.existing = [];
|
||||||
|
state.insertCall = undefined;
|
||||||
|
state.updateCall = undefined;
|
||||||
|
state.deleteCall = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validTemplate = {
|
||||||
|
title: "Welcome",
|
||||||
|
content: "Hello and welcome aboard!",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createTemplate", () => {
|
||||||
|
it("creates a template and returns its id", async () => {
|
||||||
|
const res = await createTemplate(validTemplate);
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 73 } });
|
||||||
|
expect(state.insertCall.values).toMatchObject(validTemplate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies schema defaults (category and sortOrder)", async () => {
|
||||||
|
const res = await createTemplate({
|
||||||
|
title: "Refund",
|
||||||
|
content: "We will process your refund",
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(state.insertCall.values).toMatchObject({
|
||||||
|
category: "general",
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coerces a numeric sortOrder string", async () => {
|
||||||
|
await createTemplate({ ...validTemplate, sortOrder: "5" });
|
||||||
|
expect(state.insertCall.values).toMatchObject({ sortOrder: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty titles and content", async () => {
|
||||||
|
expect(
|
||||||
|
await createTemplate({ title: "", content: "x" }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
expect(
|
||||||
|
await createTemplate({ title: "T", content: "" }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the tickets edit permission", async () => {
|
||||||
|
state.allowed = [];
|
||||||
|
expect(await createTemplate(validTemplate)).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateTemplate", () => {
|
||||||
|
it("updates an existing template", async () => {
|
||||||
|
state.existing = [{ id: 3 }];
|
||||||
|
const res = await updateTemplate({ id: 3, title: "Renamed" });
|
||||||
|
expect(res).toEqual({ ok: true, data: { id: 3 } });
|
||||||
|
expect(state.updateCall.values).toMatchObject({ title: "Renamed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a missing template", async () => {
|
||||||
|
state.existing = [];
|
||||||
|
expect(await updateTemplate({ id: 99, title: "Ghost" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Template not found",
|
||||||
|
});
|
||||||
|
expect(state.updateCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive id", async () => {
|
||||||
|
expect(await updateTemplate({ id: 0, title: "X" })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deleteTemplate", () => {
|
||||||
|
it("deletes a template by id", async () => {
|
||||||
|
const res = await deleteTemplate({ id: 10 });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.deleteCall.condition).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-positive id", async () => {
|
||||||
|
expect(await deleteTemplate({ id: -3 })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the tickets edit permission", async () => {
|
||||||
|
state.allowed = [];
|
||||||
|
expect(await deleteTemplate({ id: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const actionErrorClass = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class ActionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ActionError";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: [] as string[],
|
||||||
|
tickets: [] as { id: number; assigneeId: number | null; status: string; priority: string }[],
|
||||||
|
insertCall: undefined as unknown,
|
||||||
|
updateCall: undefined as unknown,
|
||||||
|
rowsForSelect: [] as unknown[],
|
||||||
|
selectTable: undefined as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction: (opts: { permission?: string | readonly string[]; schema?: z.ZodType }, handler: (ctx: any) => unknown) => {
|
||||||
|
return async (input: unknown) => {
|
||||||
|
const needed = Array.isArray(opts.permission)
|
||||||
|
? opts.permission
|
||||||
|
: [opts.permission ?? ""];
|
||||||
|
if (!needed.some((slug) => state.allowed.includes(slug))) {
|
||||||
|
return { ok: false, error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
const ctx: any = {
|
||||||
|
session: { user: { id: 100, name: "staff", rank: 10 } },
|
||||||
|
};
|
||||||
|
if (opts.schema) {
|
||||||
|
const parsed = opts.schema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.data = parsed.data;
|
||||||
|
} else {
|
||||||
|
ctx.data = input;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handler(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof actionErrorClass) {
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
ActionError: actionErrorClass,
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { TICKETS_EDIT: "admin.tickets.edit", MOD_TICKETS_EDIT: "mod.tickets.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const db = createFakeDb((table, projection) => {
|
||||||
|
state.selectTable = table;
|
||||||
|
if ("total" in projection) return [{ total: 0 }];
|
||||||
|
if (table === schema.WebsiteTicket) return state.tickets;
|
||||||
|
return state.rowsForSelect;
|
||||||
|
});
|
||||||
|
db.insert = vi.fn((table) => ({
|
||||||
|
values: (values: unknown) => {
|
||||||
|
state.insertCall = { table, values };
|
||||||
|
return Promise.resolve([{ insertId: 1n }]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
db.update = vi.fn((table) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.updateCall = { table, values };
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return { ...schema, db };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
import {
|
||||||
|
adminReplyTicket,
|
||||||
|
assignTicket,
|
||||||
|
updateTicketPriority,
|
||||||
|
updateTicketStatus,
|
||||||
|
} from "./tickets";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.allowed = ["admin.tickets.edit"];
|
||||||
|
state.rowsForSelect = [];
|
||||||
|
state.tickets = [];
|
||||||
|
state.insertCall = undefined;
|
||||||
|
state.updateCall = undefined;
|
||||||
|
state.selectTable = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adminReplyTicket", () => {
|
||||||
|
it("rejects without the tickets edit permission", async () => {
|
||||||
|
state.allowed = [];
|
||||||
|
expect(
|
||||||
|
await adminReplyTicket({ ticketId: 1, message: "Hello there" }),
|
||||||
|
).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates the reply payload", async () => {
|
||||||
|
const res = await adminReplyTicket({ ticketId: 0, message: "" });
|
||||||
|
expect(res).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
expect(res.ok ? null : res.fieldErrors?.message).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the ticket does not exist", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
expect(await adminReplyTicket({ ticketId: 5, message: "Hello" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a staff message and auto-assigns an unassigned ticket", async () => {
|
||||||
|
state.tickets = [{ id: 1, assigneeId: null, status: "open", priority: "low" }];
|
||||||
|
const res = await adminReplyTicket({ ticketId: 1, message: "Working on it" });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.insertCall.values).toMatchObject({
|
||||||
|
ticketId: 1,
|
||||||
|
userId: 100,
|
||||||
|
message: "Working on it",
|
||||||
|
isStaff: 1,
|
||||||
|
});
|
||||||
|
expect(state.updateCall.values).toMatchObject({
|
||||||
|
status: "waiting",
|
||||||
|
assigneeId: 100,
|
||||||
|
});
|
||||||
|
expect(logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "ticket_reply", targetId: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clobber an existing assignee", async () => {
|
||||||
|
state.tickets = [{ id: 2, assigneeId: 55, status: "open", priority: "low" }];
|
||||||
|
await adminReplyTicket({ ticketId: 2, message: "Already owned" });
|
||||||
|
expect(state.updateCall.values).toMatchObject({ status: "waiting" });
|
||||||
|
expect(state.updateCall.values.assigneeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateTicketStatus", () => {
|
||||||
|
it("sets closedAt when closing a ticket", async () => {
|
||||||
|
state.tickets = [{ id: 3, assigneeId: 55, status: "waiting", priority: "low" }];
|
||||||
|
const res = await updateTicketStatus({ ticketId: 3, status: "closed" });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.updateCall.values).toMatchObject({ status: "closed" });
|
||||||
|
expect(state.updateCall.values.closedAt).toBeInstanceOf(Date);
|
||||||
|
expect(logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "ticket_status_change",
|
||||||
|
before: { status: "waiting" },
|
||||||
|
after: { status: "closed" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-assigns when moving an unowned ticket to in_progress", async () => {
|
||||||
|
state.tickets = [{ id: 4, assigneeId: null, status: "open", priority: "low" }];
|
||||||
|
await updateTicketStatus({ ticketId: 4, status: "in_progress" });
|
||||||
|
expect(state.updateCall.values).toMatchObject({
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeId: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reassign when already assigned", async () => {
|
||||||
|
state.tickets = [{ id: 5, assigneeId: 9, status: "open", priority: "low" }];
|
||||||
|
await updateTicketStatus({ ticketId: 5, status: "in_progress" });
|
||||||
|
expect(state.updateCall.values.assigneeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the ticket does not exist", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
expect(await updateTicketStatus({ ticketId: 9, status: "open" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assignTicket", () => {
|
||||||
|
it("marks an assigned ticket in_progress", async () => {
|
||||||
|
state.tickets = [{ id: 6, assigneeId: null, status: "open", priority: "low" }];
|
||||||
|
const res = await assignTicket({ ticketId: 6, assigneeId: 42 });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.updateCall.values).toMatchObject({
|
||||||
|
assigneeId: 42,
|
||||||
|
status: "in_progress",
|
||||||
|
});
|
||||||
|
expect(logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "ticket_assign",
|
||||||
|
before: { assigneeId: null },
|
||||||
|
after: { assigneeId: 42 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the ticket to open when unassigning", async () => {
|
||||||
|
state.tickets = [{ id: 7, assigneeId: 42, status: "in_progress", priority: "low" }];
|
||||||
|
await assignTicket({ ticketId: 7, assigneeId: null });
|
||||||
|
expect(state.updateCall.values).toMatchObject({
|
||||||
|
assigneeId: null,
|
||||||
|
status: "open",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the ticket does not exist", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
expect(await assignTicket({ ticketId: 1, assigneeId: 2 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateTicketPriority", () => {
|
||||||
|
it("updates the priority and audits before/after", async () => {
|
||||||
|
state.tickets = [{ id: 8, assigneeId: 5, status: "open", priority: "low" }];
|
||||||
|
const res = await updateTicketPriority({ ticketId: 8, priority: "urgent" });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.updateCall.values).toMatchObject({ priority: "urgent" });
|
||||||
|
expect(logAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "ticket_priority_change",
|
||||||
|
before: { priority: "low" },
|
||||||
|
after: { priority: "urgent" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the ticket does not exist", async () => {
|
||||||
|
state.tickets = [];
|
||||||
|
expect(await updateTicketPriority({ ticketId: 1, priority: "high" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Ticket not found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown priority value", async () => {
|
||||||
|
const res = await updateTicketPriority({ ticketId: 1, priority: "max" as never });
|
||||||
|
expect(res).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: [] as string[],
|
||||||
|
saveCmsTranslation: vi.fn(),
|
||||||
|
translationError: vi.fn<
|
||||||
|
(code: string, args?: { key?: string }) => string
|
||||||
|
>(),
|
||||||
|
patchJson5: vi.fn(),
|
||||||
|
readFile: vi.fn(),
|
||||||
|
writeFile: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const actionErrorClass = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class ActionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ActionError";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction: (
|
||||||
|
opts: { permission?: string | readonly string[]; schema?: any },
|
||||||
|
handler: (ctx: any) => unknown,
|
||||||
|
) => {
|
||||||
|
return async (input: unknown) => {
|
||||||
|
const needed = Array.isArray(opts.permission)
|
||||||
|
? opts.permission
|
||||||
|
: [opts.permission ?? ""];
|
||||||
|
if (!needed.some((slug) => state.allowed.includes(slug))) {
|
||||||
|
return { ok: false, error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
const ctx: any = {
|
||||||
|
session: { user: { id: 100, name: "staff", rank: 10 } },
|
||||||
|
};
|
||||||
|
if (opts.schema) {
|
||||||
|
const parsed = opts.schema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.data = parsed.data;
|
||||||
|
} else {
|
||||||
|
ctx.data = input;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handler(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof actionErrorClass) {
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
ActionError: actionErrorClass,
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { SETTINGS_EDIT: "admin.settings.edit" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
|
||||||
|
|
||||||
|
vi.mock("next-intl/server", () => ({
|
||||||
|
getTranslations: async () => state.translationError,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:fs/promises", () => ({
|
||||||
|
readFile: state.readFile,
|
||||||
|
writeFile: state.writeFile,
|
||||||
|
mkdir: vi.fn(),
|
||||||
|
default: { readFile: state.readFile, writeFile: state.writeFile, mkdir: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/cms-translations", () => ({
|
||||||
|
CmsTranslationError: actionErrorClass,
|
||||||
|
saveCmsTranslation: state.saveCmsTranslation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/json5-patch", () => ({
|
||||||
|
patchJson5: state.patchJson5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetClientTranslationFile = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@/lib/client-translation-files", async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<typeof import("@/lib/client-translation-files")>();
|
||||||
|
return { ...original, getClientTranslationFile: mockGetClientTranslationFile };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { CLIENT_TRANSLATION_FILES, getClientTranslationFile } from "@/lib/client-translation-files";
|
||||||
|
import { saveClientTranslations, saveTranslations } from "./translations";
|
||||||
|
|
||||||
|
const snapshot = { messages: { "a.b": "x" }, revision: "ab".repeat(32) };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.allowed = ["admin.settings.edit"];
|
||||||
|
state.translationError.mockImplementation(
|
||||||
|
(code: string, args?: { key?: string }) => `[${code}:${args?.key ?? ""}]`,
|
||||||
|
);
|
||||||
|
mockGetClientTranslationFile.mockImplementation((id: string) =>
|
||||||
|
CLIENT_TRANSLATION_FILES.find((f) => f.id === id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveTranslations", () => {
|
||||||
|
it("persists a valid changeset and revalidates the layout", async () => {
|
||||||
|
state.saveCmsTranslation.mockResolvedValue(snapshot);
|
||||||
|
const res = await saveTranslations({
|
||||||
|
locale: "en",
|
||||||
|
revision: "a".repeat(64),
|
||||||
|
changes: { "nav.home": "Home" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: { snapshot } });
|
||||||
|
expect(state.saveCmsTranslation).toHaveBeenCalledWith(
|
||||||
|
"en",
|
||||||
|
"a".repeat(64),
|
||||||
|
{ "nav.home": "Home" },
|
||||||
|
);
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/", "layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a translated conflict message on CmsTranslationError", async () => {
|
||||||
|
const err = new actionErrorClass("conflict: ");
|
||||||
|
(err as { code: string; key: string }).code = "conflict";
|
||||||
|
(err as { code: string; key: string }).key = "";
|
||||||
|
state.saveCmsTranslation.mockRejectedValue(err);
|
||||||
|
const res = await saveTranslations({
|
||||||
|
locale: "en",
|
||||||
|
revision: "b".repeat(64),
|
||||||
|
changes: {},
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "[conflict:]" });
|
||||||
|
expect(mockRevalidatePath).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the errored key into the translation call", async () => {
|
||||||
|
const err = new actionErrorClass("unknownKey: k");
|
||||||
|
(err as { code: string; key: string }).code = "unknownKey";
|
||||||
|
(err as { code: string; key: string }).key = "missing.key";
|
||||||
|
state.saveCmsTranslation.mockRejectedValue(err);
|
||||||
|
const res = await saveTranslations({
|
||||||
|
locale: "en",
|
||||||
|
revision: "c".repeat(64),
|
||||||
|
changes: {},
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: false, error: "[unknownKey:missing.key]" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws errors that are not CmsTranslationError", async () => {
|
||||||
|
state.saveCmsTranslation.mockRejectedValue(new Error("disk full"));
|
||||||
|
await expect(
|
||||||
|
saveTranslations({
|
||||||
|
locale: "en",
|
||||||
|
revision: "d".repeat(64),
|
||||||
|
changes: {},
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("disk full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsupported locale and a malformed revision", async () => {
|
||||||
|
expect(
|
||||||
|
await saveTranslations({ locale: "xx" as never, revision: "a".repeat(64), changes: {} }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
expect(
|
||||||
|
await saveTranslations({ locale: "en", revision: "short", changes: {} }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the settings edit permission", async () => {
|
||||||
|
state.allowed = [];
|
||||||
|
expect(
|
||||||
|
await saveTranslations({ locale: "en", revision: "a".repeat(64), changes: {} }),
|
||||||
|
).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveClientTranslations", () => {
|
||||||
|
it("reports an unknown file id", async () => {
|
||||||
|
mockGetClientTranslationFile.mockReturnValueOnce(undefined);
|
||||||
|
const res = await saveClientTranslations({ fileId: "badge-texts-en", data: {} });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unknown file" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to write read-only files", async () => {
|
||||||
|
const res = await saveClientTranslations({ fileId: "ui-texts-en", data: {} });
|
||||||
|
expect(res).toEqual({ ok: false, error: "File is read-only" });
|
||||||
|
expect(state.writeFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a plain JSON file", async () => {
|
||||||
|
state.readFile.mockResolvedValue('"{}"');
|
||||||
|
const res = await saveClientTranslations({
|
||||||
|
fileId: "badge-texts-en",
|
||||||
|
data: { x: "y" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: { commentsLost: false, unpatchedKeys: [] } });
|
||||||
|
expect(state.writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("badge-texts-en.json"),
|
||||||
|
JSON.stringify({ x: "y" }, null, 4),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a surgical JSON5 patch when every key is patchable", async () => {
|
||||||
|
state.readFile.mockResolvedValue('{"greeting": "hi",}');
|
||||||
|
state.patchJson5.mockReturnValue({ content: '{"greeting": "ciao",}', unpatchedKeys: [] });
|
||||||
|
const res = await saveClientTranslations({
|
||||||
|
fileId: "ui-texts-it",
|
||||||
|
data: { greeting: "ciao" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: { commentsLost: false, unpatchedKeys: [] } });
|
||||||
|
expect(state.patchJson5).toHaveBeenCalledWith(
|
||||||
|
'{"greeting": "hi",}',
|
||||||
|
{ greeting: "hi" },
|
||||||
|
{ greeting: "ciao" },
|
||||||
|
);
|
||||||
|
expect(state.writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("UITexts.json5"),
|
||||||
|
'{"greeting": "ciao",}',
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to re-serialization and flags comment loss for unpatched keys", async () => {
|
||||||
|
state.readFile.mockResolvedValue('{"known": "old",}');
|
||||||
|
state.patchJson5.mockReturnValue({ content: '{"known": "old",}', unpatchedKeys: ["brand-new"] });
|
||||||
|
const res = await saveClientTranslations({
|
||||||
|
fileId: "ui-texts-it",
|
||||||
|
data: { known: "new", "brand-new": "val" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: true,
|
||||||
|
data: { commentsLost: true, unpatchedKeys: ["brand-new"] },
|
||||||
|
});
|
||||||
|
expect(state.writeFile).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("UITexts.json5"),
|
||||||
|
JSON.stringify({ known: "new", "brand-new": "val" }, null, 4),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-object on-disk payloads before patching", async () => {
|
||||||
|
state.readFile.mockResolvedValue("[1,2,3]");
|
||||||
|
state.patchJson5.mockReturnValue({ content: "[]", unpatchedKeys: ["x"] });
|
||||||
|
const res = await saveClientTranslations({
|
||||||
|
fileId: "external-texts",
|
||||||
|
data: { x: "1" },
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ ok: true, data: { commentsLost: true, unpatchedKeys: ["x"] } });
|
||||||
|
expect(state.patchJson5).toHaveBeenCalledWith("[1,2,3]", {}, { x: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates fileId against the whitelist", async () => {
|
||||||
|
const res = await saveClientTranslations({ fileId: "hack" as never, data: {} });
|
||||||
|
expect(res).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the settings edit permission", async () => {
|
||||||
|
state.allowed = [];
|
||||||
|
const res = await saveClientTranslations({ fileId: "ui-texts-it", data: {} });
|
||||||
|
expect(res).toEqual({ ok: false, error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets file-system errors propagate", async () => {
|
||||||
|
state.readFile.mockRejectedValue(new Error("EACCES"));
|
||||||
|
await expect(
|
||||||
|
saveClientTranslations({ fileId: "ui-texts-it", data: {} }),
|
||||||
|
).rejects.toThrow("EACCES");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
auth: vi.fn(),
|
||||||
|
authSession: undefined as { user: { id: string; name: string } } | null,
|
||||||
|
updateCall: undefined as unknown,
|
||||||
|
rconSetMotto: vi.fn(),
|
||||||
|
failDbUpdate: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const databaseErrorClass = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class DatabaseError extends Error {
|
||||||
|
constructor(message: string, cause?: unknown) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DatabaseError";
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth", () => ({ auth: state.auth }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/foundation/action", () => ({
|
||||||
|
authAction: (
|
||||||
|
opts: { schema?: z.ZodType },
|
||||||
|
handler: (ctx: any) => unknown,
|
||||||
|
) => {
|
||||||
|
return async (input: unknown) => {
|
||||||
|
const session = await state.auth();
|
||||||
|
if (!session?.user) return { ok: false, error: "Unauthorized" };
|
||||||
|
const ctx: any = {
|
||||||
|
session: {
|
||||||
|
user: { id: Number(session.user.id), name: session.user.name },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (opts.schema) {
|
||||||
|
const parsed = opts.schema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.data = parsed.data;
|
||||||
|
} else {
|
||||||
|
ctx.data = input;
|
||||||
|
}
|
||||||
|
return handler(ctx);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/foundation/errors", () => ({
|
||||||
|
DatabaseError: databaseErrorClass,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/rcon", () => ({
|
||||||
|
rcon: { setMotto: state.rconSetMotto },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRevalidatePath = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: mockRevalidatePath }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const db = createFakeDb(() => []);
|
||||||
|
db.update = vi.fn((table) => ({
|
||||||
|
set: (values: unknown) => ({
|
||||||
|
where: () => {
|
||||||
|
state.updateCall = { table, values };
|
||||||
|
if (state.failDbUpdate)
|
||||||
|
return Promise.reject(new Error("connection lost"));
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return { ...schema, db };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { DatabaseError } from "@/lib/foundation/errors";
|
||||||
|
import { updateMotto, updateMottoAction } from "./user-settings";
|
||||||
|
|
||||||
|
const mockingForm = (motto: string): FormData =>
|
||||||
|
({ get: (key: string) => (key === "motto" ? motto : null) }) as unknown as FormData;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.auth.mockResolvedValue({ user: { id: "42", name: "player" } });
|
||||||
|
state.authSession = null;
|
||||||
|
state.updateCall = undefined;
|
||||||
|
state.rconSetMotto.mockResolvedValue(true);
|
||||||
|
state.failDbUpdate = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateMotto", () => {
|
||||||
|
it("persists the motto, syncs RCON and revalidates /settings", async () => {
|
||||||
|
await updateMotto(
|
||||||
|
mockingForm(" hello world "),
|
||||||
|
);
|
||||||
|
expect(state.updateCall.values).toEqual({ motto: " hello world " });
|
||||||
|
expect(state.rconSetMotto).toHaveBeenCalledWith(42, " hello world ");
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalises NFC and truncates the motto to 127 characters", async () => {
|
||||||
|
const long = "é".repeat(200);
|
||||||
|
await updateMotto(mockingForm(long));
|
||||||
|
expect(state.updateCall.values.motto).toHaveLength(127);
|
||||||
|
expect(state.rconSetMotto).toHaveBeenCalledWith(42, "é".repeat(127));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still succeeds when RCON fails (best-effort sync)", async () => {
|
||||||
|
state.rconSetMotto.mockRejectedValue(new Error("emulator down"));
|
||||||
|
await updateMotto(mockingForm("ok"));
|
||||||
|
expect(state.updateCall.values).toEqual({ motto: "ok" });
|
||||||
|
expect(mockRevalidatePath).toHaveBeenCalledWith("/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a DatabaseError when the DB update fails", async () => {
|
||||||
|
state.failDbUpdate = true;
|
||||||
|
await expect(updateMotto(mockingForm("boom"))).rejects.toThrow(
|
||||||
|
databaseErrorClass,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
updateMotto(mockingForm("boom")),
|
||||||
|
).rejects.toThrow("Failed to update motto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sync RCON when the DB update fails", async () => {
|
||||||
|
state.failDbUpdate = true;
|
||||||
|
await expect(updateMotto(mockingForm("boom"))).rejects.toThrow(
|
||||||
|
databaseErrorClass,
|
||||||
|
);
|
||||||
|
expect(state.rconSetMotto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateMottoAction", () => {
|
||||||
|
it("denies unauthenticated callers", async () => {
|
||||||
|
state.auth.mockResolvedValue(null);
|
||||||
|
expect(await updateMottoAction({ motto: "nope" })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
expect(state.updateCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mottos longer than 127 characters", async () => {
|
||||||
|
const res = await updateMottoAction({ motto: "x".repeat(128) });
|
||||||
|
expect(res).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
expect(state.updateCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("schema boundary", () => {
|
||||||
|
it("accepts exactly 127 characters", async () => {
|
||||||
|
const res = await updateMottoAction({ motto: "x".repeat(127) });
|
||||||
|
expect(res).toEqual({ ok: true, data: {} });
|
||||||
|
expect(state.updateCall.values).toEqual({ motto: "x".repeat(127) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-string mottos", async () => {
|
||||||
|
expect(
|
||||||
|
await updateMottoAction({ motto: 5 as unknown as string }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DatabaseError propagation", () => {
|
||||||
|
it("is an instance of the exported error class", () => {
|
||||||
|
const err = new databaseErrorClass("db");
|
||||||
|
expect(err).toBeInstanceOf(DatabaseError);
|
||||||
|
expect(err.name).toBe("DatabaseError");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
allowed: [] as string[],
|
||||||
|
watches: [] as { id: number; staffId: number; targetUserId: number; reason: string }[],
|
||||||
|
insertCall: undefined as unknown,
|
||||||
|
deleteCall: undefined as unknown,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const actionErrorClass = vi.hoisted(
|
||||||
|
() =>
|
||||||
|
class ActionError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ActionError";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action", () => ({
|
||||||
|
adminAction: (opts: { permission?: string | readonly string[]; schema?: z.ZodType }, handler: (ctx: any) => unknown) => {
|
||||||
|
return async (input: unknown) => {
|
||||||
|
const needed = Array.isArray(opts.permission)
|
||||||
|
? opts.permission
|
||||||
|
: [opts.permission ?? ""];
|
||||||
|
if (!needed.some((slug) => state.allowed.includes(slug))) {
|
||||||
|
return { ok: false, error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
const ctx: any = {
|
||||||
|
session: { user: { id: 100, name: "staff", rank: 10 } },
|
||||||
|
};
|
||||||
|
if (opts.schema) {
|
||||||
|
const parsed = opts.schema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Validation failed",
|
||||||
|
fieldErrors: z.flattenError(parsed.error).fieldErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ctx.data = parsed.data;
|
||||||
|
} else {
|
||||||
|
ctx.data = input;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await handler(ctx);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof actionErrorClass) throw error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/safe-action-shared", () => ({
|
||||||
|
ActionError: actionErrorClass,
|
||||||
|
actionOk: (data?: unknown) => ({ ok: true, data: data ?? {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/permission-slugs", () => ({
|
||||||
|
PERMS: { USERS_VIEW: "admin.users.view" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockRevalidateTag = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("next/cache", () => ({ revalidateTag: mockRevalidateTag }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
const db = createFakeDb((table) => {
|
||||||
|
if (table === schema.UserWatch) return state.watches;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
db.insert = vi.fn((table) => ({
|
||||||
|
values: (
|
||||||
|
values: { staffId: number; targetUserId: number; reason: string },
|
||||||
|
) => {
|
||||||
|
state.insertCall = { table, values };
|
||||||
|
return Promise.resolve([{ insertId: 1n }]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
db.delete = vi.fn((table) => ({
|
||||||
|
where: (condition: unknown) => {
|
||||||
|
state.deleteCall = { table, condition };
|
||||||
|
return Promise.resolve([{ affectedRows: 1 }]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return { ...schema, db };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { toggleUserWatch } from "./watch";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.allowed = ["admin.users.view"];
|
||||||
|
state.watches = [];
|
||||||
|
state.insertCall = undefined;
|
||||||
|
state.deleteCall = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toggleUserWatch", () => {
|
||||||
|
it("creates a watch when none exists and returns watching: true", async () => {
|
||||||
|
const res = await toggleUserWatch({ targetUserId: 42, reason: "suspicious" });
|
||||||
|
expect(res).toEqual({ ok: true, data: { watching: true } });
|
||||||
|
expect(state.insertCall.values).toEqual({
|
||||||
|
staffId: 100,
|
||||||
|
targetUserId: 42,
|
||||||
|
reason: "suspicious",
|
||||||
|
});
|
||||||
|
expect(mockRevalidateTag).toHaveBeenCalledWith("user-watch:100", { expire: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the reason to an empty string", async () => {
|
||||||
|
await toggleUserWatch({ targetUserId: 7 });
|
||||||
|
expect(state.insertCall.values).toEqual({
|
||||||
|
staffId: 100,
|
||||||
|
targetUserId: 7,
|
||||||
|
reason: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an existing watch and returns watching: false", async () => {
|
||||||
|
state.watches = [{ id: 9, staffId: 100, targetUserId: 42, reason: "x" }];
|
||||||
|
const res = await toggleUserWatch({ targetUserId: 42 });
|
||||||
|
expect(res).toEqual({ ok: true, data: { watching: false } });
|
||||||
|
expect(state.deleteCall.condition).toBeDefined();
|
||||||
|
expect(mockRevalidateTag).toHaveBeenCalledWith("user-watch:100", { expire: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the users view permission", async () => {
|
||||||
|
state.allowed = [];
|
||||||
|
expect(await toggleUserWatch({ targetUserId: 1 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Unauthorized",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid target user ids and oversized reasons", async () => {
|
||||||
|
expect(
|
||||||
|
await toggleUserWatch({ targetUserId: -1 }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
expect(
|
||||||
|
await toggleUserWatch({ targetUserId: 0 }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
expect(
|
||||||
|
await toggleUserWatch({ targetUserId: 1, reason: "x".repeat(256) }),
|
||||||
|
).toMatchObject({ ok: false, error: "Validation failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { CatalogPackages } from "./catalog-packages";
|
||||||
|
|
||||||
|
const columns = CatalogPackages[Symbol.for("drizzle:Columns")] as Record<
|
||||||
|
string,
|
||||||
|
{ name: string; notNull: boolean; primary: boolean; dataType: string }
|
||||||
|
>;
|
||||||
|
|
||||||
|
describe("CatalogPackages table schema", () => {
|
||||||
|
it("maps to the website_catalog_packages table", () => {
|
||||||
|
expect(CatalogPackages[Symbol.for("drizzle:Name")]).toBe(
|
||||||
|
"website_catalog_packages",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines a uuid primary key", () => {
|
||||||
|
expect(columns.id).toMatchObject({ name: "id", primary: true, notNull: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines the package metadata columns as NOT NULL", () => {
|
||||||
|
for (const key of ["name", "version", "status", "mode", "payload"]) {
|
||||||
|
expect(columns[key]?.notNull).toBe(true);
|
||||||
|
expect(columns[key]?.name).toBe(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the version column numeric and payload longtext", () => {
|
||||||
|
expect(columns.version.dataType).toBe("number");
|
||||||
|
expect(columns.payload.dataType).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks an updated_at timestamp and exposes $inferSelect/$inferInsert types", () => {
|
||||||
|
expect(columns.updatedAt.name).toBe("updated_at");
|
||||||
|
expect(columns.updatedAt.notNull).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MySqlIndex } from "drizzle-orm/mysql-core";
|
||||||
|
import {
|
||||||
|
GamedataDocs,
|
||||||
|
GamedataFurnidata,
|
||||||
|
GamedataTexts,
|
||||||
|
} from "./schema-gamedata";
|
||||||
|
|
||||||
|
type Col = {
|
||||||
|
name: string;
|
||||||
|
notNull: boolean;
|
||||||
|
primary: boolean;
|
||||||
|
enumValues?: readonly string[];
|
||||||
|
dataType: string;
|
||||||
|
generated?: { mode: string };
|
||||||
|
default?: unknown;
|
||||||
|
hasDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function columnsOf(t: unknown): Record<string, Col> {
|
||||||
|
return (t as Record<symbol, unknown>)[
|
||||||
|
Symbol.for("drizzle:Columns")
|
||||||
|
] as Record<string, Col>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexesOf(t: unknown): MySqlIndex[] {
|
||||||
|
const builder = (t as Record<symbol, unknown>)[
|
||||||
|
Symbol.for("drizzle:ExtraConfigBuilder")
|
||||||
|
];
|
||||||
|
if (typeof builder !== "function") return [];
|
||||||
|
const result = (builder as (cols: unknown) => unknown)(columnsOf(t));
|
||||||
|
return (Array.isArray(result) ? result : []) as MySqlIndex[];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gamedata schema tables", () => {
|
||||||
|
it("GamedataFurnidata maps to gamedata_furnidata with indexed columns", () => {
|
||||||
|
const t = GamedataFurnidata as unknown as {
|
||||||
|
[Symbol.for("drizzle:Name")]: string;
|
||||||
|
};
|
||||||
|
expect(t[Symbol.for("drizzle:Name") as never]).toBe("gamedata_furnidata");
|
||||||
|
const cols = columnsOf(GamedataFurnidata);
|
||||||
|
expect(cols.id).toMatchObject({ name: "id", primary: true, notNull: true });
|
||||||
|
expect(cols.spriteId.name).toBe("sprite_id");
|
||||||
|
expect(cols.kind.enumValues).toEqual(["s", "i", "e"]);
|
||||||
|
expect(cols.payload.name).toBe("payload");
|
||||||
|
|
||||||
|
const indexes = indexesOf(GamedataFurnidata);
|
||||||
|
expect(indexes.map((i) => i.config.columns.map((c) => c.name))).toEqual([
|
||||||
|
["source", "sprite_id"],
|
||||||
|
["class_name"],
|
||||||
|
["kind"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GamedataDocs maps to gamedata_docs with a virtual title column", () => {
|
||||||
|
const t = GamedataDocs as unknown as {
|
||||||
|
[Symbol.for("drizzle:Name")]: string;
|
||||||
|
};
|
||||||
|
expect(t[Symbol.for("drizzle:Name") as never]).toBe("gamedata_docs");
|
||||||
|
const cols = columnsOf(GamedataDocs);
|
||||||
|
expect(cols.category.notNull).toBe(true);
|
||||||
|
expect(cols.payloadSha1.name).toBe("payload_sha1");
|
||||||
|
expect(cols.title.generated?.mode).toBe("virtual");
|
||||||
|
expect(cols.title.notNull).toBe(true);
|
||||||
|
|
||||||
|
const indexes = indexesOf(GamedataDocs);
|
||||||
|
expect(indexes.map((i) => i.config.columns.map((c) => c.name))).toEqual([
|
||||||
|
["category", "doc_key"],
|
||||||
|
["title"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GamedataTexts maps to gamedata_texts keyed by (category, text_key)", () => {
|
||||||
|
const t = GamedataTexts as unknown as {
|
||||||
|
[Symbol.for("drizzle:Name")]: string;
|
||||||
|
};
|
||||||
|
expect(t[Symbol.for("drizzle:Name") as never]).toBe("gamedata_texts");
|
||||||
|
const cols = columnsOf(GamedataTexts);
|
||||||
|
expect(cols.textKey.name).toBe("text_key");
|
||||||
|
expect(cols.value.name).toBe("value");
|
||||||
|
expect(cols.id.notNull).toBe(true);
|
||||||
|
|
||||||
|
const indexes = indexesOf(GamedataTexts);
|
||||||
|
expect(indexes.map((i) => i.config.columns.map((c) => c.name))).toEqual([
|
||||||
|
["category", "text_key"],
|
||||||
|
["text_key"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes default values for furnidata and docs", () => {
|
||||||
|
const furniCols = columnsOf(GamedataFurnidata);
|
||||||
|
expect(furniCols.source.hasDefault).toBe(true);
|
||||||
|
expect(furniCols.kind.hasDefault).toBe(true);
|
||||||
|
const docsCols = columnsOf(GamedataDocs);
|
||||||
|
expect(docsCols.payloadSha1.hasDefault).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
settings: {} as Record<string, unknown>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: {
|
||||||
|
get: async (key: string, fallback?: unknown) =>
|
||||||
|
key in state.settings ? state.settings[key] : fallback,
|
||||||
|
getBool: async (key: string, fallback: boolean) =>
|
||||||
|
key in state.settings ? Boolean(state.settings[key]) : fallback,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { checkVpn } from "./ip-lookup";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown) {
|
||||||
|
return { ok: true, json: async () => body };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.settings = {};
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkVpn", () => {
|
||||||
|
it("does not block empty or private addresses", async () => {
|
||||||
|
expect(await checkVpn("")).toEqual({ blocked: false });
|
||||||
|
expect(await checkVpn("127.0.0.1")).toEqual({ blocked: false });
|
||||||
|
expect(await checkVpn("10.1.2.3")).toEqual({ blocked: false });
|
||||||
|
expect(await checkVpn("192.168.1.5")).toEqual({ blocked: false });
|
||||||
|
expect(await checkVpn("172.16.0.1")).toEqual({ blocked: false });
|
||||||
|
expect(await checkVpn("::1")).toEqual({ blocked: false });
|
||||||
|
expect(await checkVpn("localhost")).toEqual({ blocked: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block when the feature is disabled", async () => {
|
||||||
|
state.settings.vpn_block_enabled = false;
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({ blocked: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks ipqualityscore hits", async () => {
|
||||||
|
state.settings.vpn_block_enabled = true;
|
||||||
|
state.settings.vpn_provider = "ipqualityscore";
|
||||||
|
state.settings.vpn_api_key = "key-1";
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ vpn: true }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({
|
||||||
|
blocked: true,
|
||||||
|
reason: "VPN/proxy detected",
|
||||||
|
});
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/key-1/8.8.8.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes clean ipqualityscore responses", async () => {
|
||||||
|
state.settings.vpn_block_enabled = true;
|
||||||
|
state.settings.vpn_provider = "ipqualityscore";
|
||||||
|
state.settings.vpn_api_key = "key-1";
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse({ proxy: false })),
|
||||||
|
);
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({ blocked: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips ipqualityscore when no api key is configured", async () => {
|
||||||
|
state.settings.vpn_block_enabled = true;
|
||||||
|
state.settings.vpn_provider = "ipqualityscore";
|
||||||
|
state.settings.vpn_api_key = "";
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({ blocked: false });
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks proxycheck hits with the detected type", async () => {
|
||||||
|
state.settings.vpn_block_enabled = true;
|
||||||
|
state.settings.vpn_provider = "proxycheck";
|
||||||
|
state.settings.vpn_api_key = "abc";
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
jsonResponse({ "8.8.8.8": { proxy: "yes", type: "Tor" } }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({
|
||||||
|
blocked: true,
|
||||||
|
reason: "Tor detected",
|
||||||
|
});
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0])).toContain("key=abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes clean proxycheck responses", async () => {
|
||||||
|
state.settings.vpn_block_enabled = true;
|
||||||
|
state.settings.vpn_provider = "proxycheck";
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse({ "8.8.8.8": {} })),
|
||||||
|
);
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({ blocked: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails open when the provider throws", async () => {
|
||||||
|
state.settings.vpn_block_enabled = true;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn().mockRejectedValue(new Error("network down")),
|
||||||
|
);
|
||||||
|
expect(await checkVpn("8.8.8.8")).toEqual({ blocked: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const control = vi.hoisted(() => ({
|
||||||
|
mode: "ok" as "ok" | "fail",
|
||||||
|
posted: [] as Array<Record<string, unknown>>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:worker_threads", () => {
|
||||||
|
type Handler = (...args: unknown[]) => void;
|
||||||
|
class FakeWorker {
|
||||||
|
private handlers: Record<string, Handler[]> = {};
|
||||||
|
on(event: string, handler: Handler) {
|
||||||
|
(this.handlers[event] ??= []).push(handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
emit(event: string, ...args: unknown[]) {
|
||||||
|
for (const handler of this.handlers[event] ?? []) handler(...args);
|
||||||
|
}
|
||||||
|
postMessage(req: Record<string, unknown>) {
|
||||||
|
control.posted.push(req);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (control.mode === "fail") {
|
||||||
|
this.emit("message", {
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: "worker boom",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (req.type) {
|
||||||
|
case "init":
|
||||||
|
this.emit("message", { id: req.id, ok: true });
|
||||||
|
break;
|
||||||
|
case "prepare":
|
||||||
|
this.emit("message", {
|
||||||
|
id: req.id,
|
||||||
|
ok: true,
|
||||||
|
pendingTexts: ["a", "b"],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "finalize":
|
||||||
|
this.emit("message", {
|
||||||
|
id: req.id,
|
||||||
|
ok: true,
|
||||||
|
json: '{"x":1}',
|
||||||
|
translatedRoom: 1,
|
||||||
|
translatedWall: 2,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "patchPrepare":
|
||||||
|
this.emit("message", {
|
||||||
|
id: req.id,
|
||||||
|
ok: true,
|
||||||
|
pendingTexts: ["c"],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "patchFinalize":
|
||||||
|
this.emit("message", {
|
||||||
|
id: req.id,
|
||||||
|
ok: true,
|
||||||
|
json: '{"y":2}',
|
||||||
|
patched: 4,
|
||||||
|
added: 5,
|
||||||
|
total: 6,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.emit("message", {
|
||||||
|
id: req.id,
|
||||||
|
ok: false,
|
||||||
|
error: "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
|
}
|
||||||
|
return { Worker: FakeWorker };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/furni-data", () => ({
|
||||||
|
readFurniData: async () => ({ roomitemtypes: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/furni-data-i18n", () => ({
|
||||||
|
FURNIDATA_LANGUAGES: [
|
||||||
|
{ hotel: "com", lang: "en" },
|
||||||
|
{ hotel: "com", lang: "nl" },
|
||||||
|
{ hotel: "es", lang: "es" },
|
||||||
|
],
|
||||||
|
fetchTranslationsForHotel: async () => new Map(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const savedNodeEnv = process.env.NODE_ENV;
|
||||||
|
const savedVitest = process.env.VITEST;
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
delete process.env.VITEST;
|
||||||
|
|
||||||
|
import { getTranslationWorker } from "./translation-pool";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTranslationWorker", () => {
|
||||||
|
it("returns null in the test environment", () => {
|
||||||
|
vi.stubEnv("NODE_ENV", "test");
|
||||||
|
vi.stubEnv("VITEST", "true");
|
||||||
|
expect(getTranslationWorker()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the full worker interface", async () => {
|
||||||
|
control.mode = "ok";
|
||||||
|
const worker = getTranslationWorker();
|
||||||
|
expect(worker).not.toBeNull();
|
||||||
|
if (!worker) return;
|
||||||
|
|
||||||
|
await worker.ensureInit();
|
||||||
|
expect(await worker.prepare("nl", "com")).toEqual(["a", "b"]);
|
||||||
|
expect(await worker.finalize("nl", new Map([["a", "b"]]))).toEqual({
|
||||||
|
json: '{"x":1}',
|
||||||
|
translatedRoom: 1,
|
||||||
|
translatedWall: 2,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await worker.patchPrepare("{}", "nl", "com", [
|
||||||
|
{ key: "a", value: "b" },
|
||||||
|
] as never),
|
||||||
|
).toEqual(["c"]);
|
||||||
|
expect(await worker.patchFinalize("nl", new Map())).toEqual({
|
||||||
|
json: '{"y":2}',
|
||||||
|
patched: 4,
|
||||||
|
added: 5,
|
||||||
|
total: 6,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces worker errors as rejections", async () => {
|
||||||
|
control.mode = "fail";
|
||||||
|
const worker = getTranslationWorker();
|
||||||
|
if (!worker) return;
|
||||||
|
await expect(worker.prepare("nl", "com")).rejects.toThrow("worker boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
process.env.NODE_ENV = savedNodeEnv;
|
||||||
|
if (savedVitest === undefined) delete process.env.VITEST;
|
||||||
|
else process.env.VITEST = savedVitest;
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
scopes: [] as Record<string, unknown>[],
|
||||||
|
values: [] as Record<string, unknown>[],
|
||||||
|
settings: {} as Record<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
|
siteSettings: {
|
||||||
|
getMany: async () => state.settings,
|
||||||
|
get: async (key: string, fallback: string | null) =>
|
||||||
|
state.settings[key] ?? fallback,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/db", async () => {
|
||||||
|
const schema = await import("@/db/schema");
|
||||||
|
const makeQuery = (table: unknown) => {
|
||||||
|
const rows = () =>
|
||||||
|
table === schema.ThemeScope ? state.scopes : state.values;
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
const self = () => query;
|
||||||
|
query.where = self;
|
||||||
|
query.orderBy = self;
|
||||||
|
query.limit = self;
|
||||||
|
query.then = (
|
||||||
|
resolve: (value: unknown) => unknown,
|
||||||
|
reject: (error: unknown) => unknown,
|
||||||
|
) => Promise.resolve(rows()).then(resolve, reject);
|
||||||
|
query.catch = (reject: (error: unknown) => unknown) =>
|
||||||
|
Promise.resolve(rows()).catch(reject);
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...schema,
|
||||||
|
db: { select: () => ({ from: (table: unknown) => makeQuery(table) }) },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
EFFECT_DEFAULTS,
|
||||||
|
LAYOUT_DEFAULTS,
|
||||||
|
MEDIA_DEFAULTS,
|
||||||
|
} from "@/lib/theme-blocks";
|
||||||
|
import { THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||||
|
import {
|
||||||
|
type ResolvedTheme,
|
||||||
|
generateScopedCss,
|
||||||
|
getAllScopes,
|
||||||
|
getFullScopeValues,
|
||||||
|
getScopeValues,
|
||||||
|
resolveTheme,
|
||||||
|
} from "./theme-resolver";
|
||||||
|
|
||||||
|
function scope(overrides: Partial<Record<string, unknown>>) {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
name: "Scope",
|
||||||
|
type: "global",
|
||||||
|
parentId: null,
|
||||||
|
siteDomain: null,
|
||||||
|
routePath: null,
|
||||||
|
moduleId: null,
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(
|
||||||
|
scopeId: number,
|
||||||
|
settingKey: string,
|
||||||
|
settingVal: string,
|
||||||
|
mode = "light",
|
||||||
|
) {
|
||||||
|
return { scopeId, settingKey, settingVal, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.scopes = [];
|
||||||
|
state.values = [];
|
||||||
|
state.settings = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTheme", () => {
|
||||||
|
it("falls back to global defaults when no scope matches", async () => {
|
||||||
|
state.settings.color_primary = "#111111";
|
||||||
|
state.settings.color_primary_dark = "#222222";
|
||||||
|
const resolved = await resolveTheme({});
|
||||||
|
expect(resolved.contributingScopes).toEqual([]);
|
||||||
|
expect(resolved.palette.color_primary).toBe("#111111");
|
||||||
|
expect(resolved.darkPalette.color_primary).toBe("#222222");
|
||||||
|
expect(resolved.layout).toEqual(LAYOUT_DEFAULTS);
|
||||||
|
expect(resolved.effects).toEqual(EFFECT_DEFAULTS);
|
||||||
|
expect(resolved.media).toEqual(MEDIA_DEFAULTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges scope values over defaults", async () => {
|
||||||
|
state.scopes = [scope({ id: 1, type: "global" })];
|
||||||
|
state.values = [
|
||||||
|
value(1, "color_primary", "#abc123"),
|
||||||
|
value(1, "color_primary", "#dark123", "dark"),
|
||||||
|
value(1, "block:hero", "true"),
|
||||||
|
value(1, "block:footer", "false"),
|
||||||
|
value(1, "layout:max_width", "1200px"),
|
||||||
|
value(1, "effect:card_shadow", "none"),
|
||||||
|
value(1, "media:logo_url", "http://logo"),
|
||||||
|
value(1, "custom:css", "body{}"),
|
||||||
|
value(1, "custom:html", "<b></b>"),
|
||||||
|
];
|
||||||
|
const resolved = await resolveTheme({});
|
||||||
|
expect(resolved.palette.color_primary).toBe("#abc123");
|
||||||
|
expect(resolved.darkPalette.color_primary).toBe("#dark123");
|
||||||
|
expect(resolved.blocks).toEqual({ hero: true, footer: false });
|
||||||
|
expect(resolved.layout["layout:max_width"]).toBe("1200px");
|
||||||
|
expect(resolved.effects["effect:card_shadow"]).toBe("none");
|
||||||
|
expect(resolved.media["media:logo_url"]).toBe("http://logo");
|
||||||
|
expect(resolved.customCss).toBe("body{}");
|
||||||
|
expect(resolved.customHtml).toBe("<b></b>");
|
||||||
|
// untouched keys keep their fallbacks
|
||||||
|
expect(resolved.palette.color_text).toBe("#f59e0b");
|
||||||
|
expect(resolved.darkPalette.color_text).toBe("#0b0d14");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the most specific matching scope and walks ancestry", async () => {
|
||||||
|
state.scopes = [
|
||||||
|
scope({ id: 1, type: "global", sortOrder: 0 }),
|
||||||
|
scope({
|
||||||
|
id: 2,
|
||||||
|
type: "site",
|
||||||
|
parentId: 1,
|
||||||
|
siteDomain: "hotel.test",
|
||||||
|
sortOrder: 1,
|
||||||
|
}),
|
||||||
|
scope({
|
||||||
|
id: 3,
|
||||||
|
type: "route",
|
||||||
|
parentId: 2,
|
||||||
|
routePath: "/shop",
|
||||||
|
sortOrder: 2,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
state.values = [
|
||||||
|
value(1, "color_primary", "#global"),
|
||||||
|
value(2, "color_primary", "#site"),
|
||||||
|
value(3, "color_primary", "#route"),
|
||||||
|
];
|
||||||
|
const resolved = await resolveTheme({
|
||||||
|
siteDomain: "hotel.test",
|
||||||
|
routePath: "/shop",
|
||||||
|
});
|
||||||
|
expect(resolved.palette.color_primary).toBe("#route");
|
||||||
|
expect(resolved.contributingScopes.map((s) => s.id)).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches module scopes", async () => {
|
||||||
|
state.scopes = [
|
||||||
|
scope({ id: 1, type: "module", moduleId: "catalog" }),
|
||||||
|
];
|
||||||
|
state.values = [value(1, "color_primary", "#module")];
|
||||||
|
const resolved = await resolveTheme({ moduleId: "catalog" });
|
||||||
|
expect(resolved.palette.color_primary).toBe("#module");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateScopedCss", () => {
|
||||||
|
function resolvedTheme(
|
||||||
|
overrides: Partial<ResolvedTheme> = {},
|
||||||
|
): ResolvedTheme {
|
||||||
|
const palette = Object.fromEntries(
|
||||||
|
THEME_COLOR_KEYS.map((key) => [key, "#111111"]),
|
||||||
|
) as ResolvedTheme["palette"];
|
||||||
|
const darkPalette = Object.fromEntries(
|
||||||
|
THEME_COLOR_KEYS.map((key) => [key, "#222222"]),
|
||||||
|
) as ResolvedTheme["darkPalette"];
|
||||||
|
return {
|
||||||
|
palette,
|
||||||
|
darkPalette,
|
||||||
|
contributingScopes: [],
|
||||||
|
cssVariables: {},
|
||||||
|
blocks: {},
|
||||||
|
layout: { ...LAYOUT_DEFAULTS },
|
||||||
|
effects: { ...EFFECT_DEFAULTS },
|
||||||
|
media: { ...MEDIA_DEFAULTS },
|
||||||
|
customCss: "",
|
||||||
|
customHtml: "",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("emits root variables plus layout, effect and media vars", () => {
|
||||||
|
const { rootCss } = generateScopedCss(resolvedTheme());
|
||||||
|
expect(rootCss).toContain(":root{--color-primary:#111111;");
|
||||||
|
expect(rootCss).toContain("html.dark{--color-primary:#222222;");
|
||||||
|
expect(rootCss).toContain("--theme-max-width:");
|
||||||
|
expect(rootCss).toContain("--theme-card-shadow:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits scoped selectors and escapes media urls", () => {
|
||||||
|
const { rootCss, scopedCssBlocks } = generateScopedCss(
|
||||||
|
resolvedTheme({
|
||||||
|
contributingScopes: [
|
||||||
|
scope({ id: 1, type: "global" }),
|
||||||
|
scope({ id: 2, type: "site", siteDomain: "hotel.test" }),
|
||||||
|
scope({ id: 3, type: "module", moduleId: "shop" }),
|
||||||
|
scope({ id: 4, type: "route", routePath: "/shop" }),
|
||||||
|
],
|
||||||
|
media: {
|
||||||
|
...MEDIA_DEFAULTS,
|
||||||
|
"media:logo_url": 'http://logo/"quoted"',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(rootCss).toContain('--theme-logo-url:url("http://logo/\\"quoted\\"")');
|
||||||
|
expect(scopedCssBlocks).toHaveLength(3);
|
||||||
|
expect(scopedCssBlocks[0]).toMatch(/^\[data-theme-site="hotel\.test"\]\{/);
|
||||||
|
expect(scopedCssBlocks[0]).toContain("--color-primary:#111111;");
|
||||||
|
expect(scopedCssBlocks[0]).toContain("--gradient-to:#111111;");
|
||||||
|
expect(scopedCssBlocks[1]).toContain('[data-theme-module="shop"]');
|
||||||
|
expect(scopedCssBlocks[2]).toContain('[data-theme-route="/shop"]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("admin helpers", () => {
|
||||||
|
it("getAllScopes normalises bigint ids", async () => {
|
||||||
|
state.scopes = [
|
||||||
|
scope({ id: 7n, parentId: 3n, type: "site" }),
|
||||||
|
];
|
||||||
|
const rows = await getAllScopes();
|
||||||
|
expect(rows[0]?.id).toBe(7);
|
||||||
|
expect(rows[0]?.parentId).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getScopeValues splits light and dark", async () => {
|
||||||
|
state.values = [
|
||||||
|
value(1, "color_primary", "#light"),
|
||||||
|
value(1, "color_primary", "#dark", "dark"),
|
||||||
|
];
|
||||||
|
expect(await getScopeValues(1)).toEqual({
|
||||||
|
color_primary: { light: "#light", dark: "#dark" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getFullScopeValues buckets every key family", async () => {
|
||||||
|
state.values = [
|
||||||
|
value(1, "color_primary", "#light"),
|
||||||
|
value(1, "color_primary", "#dark", "dark"),
|
||||||
|
value(1, "block:hero", "true"),
|
||||||
|
value(1, "layout:max_width", "1000px"),
|
||||||
|
value(1, "effect:card_shadow", "sm"),
|
||||||
|
value(1, "media:logo_url", "http://x"),
|
||||||
|
value(1, "custom:css", "a{}"),
|
||||||
|
];
|
||||||
|
const full = await getFullScopeValues(1);
|
||||||
|
expect(full.light.color_primary).toBe("#light");
|
||||||
|
expect(full.dark.color_primary).toBe("#dark");
|
||||||
|
expect(full.blocks["block:hero"]).toBe("true");
|
||||||
|
expect(full.layout["layout:max_width"]).toBe("1000px");
|
||||||
|
expect(full.effects["effect:card_shadow"]).toBe("sm");
|
||||||
|
expect(full.media["media:logo_url"]).toBe("http://x");
|
||||||
|
expect(full.custom["custom:css"]).toBe("a{}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -351,7 +351,7 @@ export function generateScopedCss(resolved: ResolvedTheme): {
|
|||||||
rootCss: string;
|
rootCss: string;
|
||||||
scopedCssBlocks: string[];
|
scopedCssBlocks: string[];
|
||||||
} {
|
} {
|
||||||
const rootCss = `:root{${THEME_COLOR_KEYS.map((k) => `--${CSS_VAR_MAP[k]}:${resolved.palette[k]};`).join("")}}html.dark{${THEME_COLOR_KEYS.map((k) => `--${CSS_VAR_MAP[k]}:${resolved.darkPalette[k]};`).join("")}}`;
|
let rootCss = `:root{${THEME_COLOR_KEYS.map((k) => `--${CSS_VAR_MAP[k]}:${resolved.palette[k]};`).join("")}}html.dark{${THEME_COLOR_KEYS.map((k) => `--${CSS_VAR_MAP[k]}:${resolved.darkPalette[k]};`).join("")}}`;
|
||||||
|
|
||||||
const scopedCssBlocks: string[] = [];
|
const scopedCssBlocks: string[] = [];
|
||||||
|
|
||||||
@@ -392,15 +392,10 @@ export function generateScopedCss(resolved: ResolvedTheme): {
|
|||||||
// Root: add layout + effects + media
|
// Root: add layout + effects + media
|
||||||
if (layoutVars || effectVars || mediaVars) {
|
if (layoutVars || effectVars || mediaVars) {
|
||||||
const extra = [layoutVars, effectVars, mediaVars].filter(Boolean).join(";");
|
const extra = [layoutVars, effectVars, mediaVars].filter(Boolean).join(";");
|
||||||
rootCss.replace("}", `${extra}}`);
|
|
||||||
// Inject into root
|
// Inject into root
|
||||||
const rootEnd = rootCss.lastIndexOf("}");
|
const rootEnd = rootCss.lastIndexOf("}");
|
||||||
if (rootEnd !== -1) {
|
if (rootEnd !== -1) {
|
||||||
const patched = `${rootCss.slice(0, rootEnd)}${extra};${rootCss.slice(rootEnd)}`;
|
rootCss = `${rootCss.slice(0, rootEnd)}${extra};${rootCss.slice(rootEnd)}`;
|
||||||
return {
|
|
||||||
rootCss: patched,
|
|
||||||
scopedCssBlocks,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper: a Drizzle-shaped fake DB that records every statement and can
|
||||||
|
* throw per kind, so server-action unit tests can assert on the exact
|
||||||
|
* insert/update/delete calls the action performs.
|
||||||
|
*
|
||||||
|
* Every call is appended to `config.ops`. `insert().values()` is awaitable and
|
||||||
|
* also exposes `onDuplicateKeyUpdate({ set })` (upsert), matching the shapes
|
||||||
|
* the CMS actions use.
|
||||||
|
*/
|
||||||
|
export type FakeDbOp =
|
||||||
|
| {
|
||||||
|
kind: "insert";
|
||||||
|
table: unknown;
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
onDuplicate?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "update";
|
||||||
|
table: unknown;
|
||||||
|
set: Record<string, unknown>;
|
||||||
|
where?: unknown;
|
||||||
|
}
|
||||||
|
| { kind: "delete"; table: unknown; where?: unknown }
|
||||||
|
| { kind: "select"; table: unknown; projection: Record<string, unknown> }
|
||||||
|
| { kind: "execute"; query: unknown };
|
||||||
|
|
||||||
|
export interface FakeActionDbConfig {
|
||||||
|
ops: FakeDbOp[];
|
||||||
|
insertResult?: unknown;
|
||||||
|
updateResult?: unknown;
|
||||||
|
deleteResult?: unknown;
|
||||||
|
selectResult?: unknown;
|
||||||
|
executeResult?: unknown;
|
||||||
|
/** Return a truthy value (optionally a message string) to make this kind fail. */
|
||||||
|
fail?: (kind: FakeDbOp["kind"], op: FakeDbOp) => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFakeActionDb(config: FakeActionDbConfig) {
|
||||||
|
const resultOf = (kind: FakeDbOp["kind"]) => {
|
||||||
|
switch (kind) {
|
||||||
|
case "insert":
|
||||||
|
return config.insertResult ?? [{ insertId: 1 }];
|
||||||
|
case "update":
|
||||||
|
return config.updateResult ?? [];
|
||||||
|
case "delete":
|
||||||
|
return config.deleteResult ?? [];
|
||||||
|
case "select":
|
||||||
|
return config.selectResult ?? [];
|
||||||
|
case "execute":
|
||||||
|
return config.executeResult ?? [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (kind: FakeDbOp["kind"], op: FakeDbOp): Promise<unknown> => {
|
||||||
|
const outcome = config.fail ? config.fail(kind, op) : undefined;
|
||||||
|
if (outcome) {
|
||||||
|
const msg = typeof outcome === "string" ? outcome : `fake ${kind} failed`;
|
||||||
|
return Promise.reject(new Error(msg));
|
||||||
|
}
|
||||||
|
return Promise.resolve(resultOf(kind));
|
||||||
|
};
|
||||||
|
|
||||||
|
const insert = (table: unknown) => ({
|
||||||
|
values: (values: Record<string, unknown>) => {
|
||||||
|
const op: FakeDbOp = { kind: "insert", table, values };
|
||||||
|
config.ops.push(op);
|
||||||
|
const execute = () => run("insert", op);
|
||||||
|
return {
|
||||||
|
then: (onFulfilled: (value: unknown) => unknown, onRejected: (error: unknown) => unknown) =>
|
||||||
|
execute().then(onFulfilled, onRejected),
|
||||||
|
catch: (onRejected: (error: unknown) => unknown) =>
|
||||||
|
execute().catch(onRejected),
|
||||||
|
onDuplicateKeyUpdate: (dup: { set: Record<string, unknown> }) => {
|
||||||
|
op.onDuplicate = dup.set;
|
||||||
|
return run("insert", op);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = (table: unknown) => ({
|
||||||
|
set: (set: Record<string, unknown>) => ({
|
||||||
|
where: (cond: unknown) => {
|
||||||
|
const op: FakeDbOp = { kind: "update", table, set, where: cond };
|
||||||
|
config.ops.push(op);
|
||||||
|
return run("update", op);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = (table: unknown) => ({
|
||||||
|
where: (cond: unknown) => {
|
||||||
|
const op: FakeDbOp = { kind: "delete", table, where: cond };
|
||||||
|
config.ops.push(op);
|
||||||
|
return run("delete", op);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeQuery = (table: unknown, projection: Record<string, unknown>) => {
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
const self = () => query;
|
||||||
|
for (const method of [
|
||||||
|
"where",
|
||||||
|
"orderBy",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"leftJoin",
|
||||||
|
"innerJoin",
|
||||||
|
"groupBy",
|
||||||
|
"having",
|
||||||
|
"for",
|
||||||
|
]) {
|
||||||
|
query[method] = self;
|
||||||
|
}
|
||||||
|
const selectOp = (): FakeDbOp => ({ kind: "select", table, projection });
|
||||||
|
query.then = (onFulfilled: (value: unknown) => unknown, onRejected: (error: unknown) => unknown) =>
|
||||||
|
run("select", selectOp()).then(onFulfilled, onRejected);
|
||||||
|
query.catch = (onRejected: (error: unknown) => unknown) =>
|
||||||
|
run("select", selectOp()).catch(onRejected);
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
insert,
|
||||||
|
update,
|
||||||
|
delete: del,
|
||||||
|
select: (projection: Record<string, unknown> = {}) => ({
|
||||||
|
from: (table: unknown) => makeQuery(table, projection),
|
||||||
|
}),
|
||||||
|
execute: (sql: SQL | string) => {
|
||||||
|
const op: FakeDbOp = { kind: "execute", query: sql };
|
||||||
|
config.ops.push(op);
|
||||||
|
return run("execute", op);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertOps(dbOps: FakeDbOp[]): FakeDbOp[] {
|
||||||
|
return dbOps.filter((op) => op.kind === "insert");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOps(dbOps: FakeDbOp[]): FakeDbOp[] {
|
||||||
|
return dbOps.filter((op) => op.kind === "update");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteOps(dbOps: FakeDbOp[]): FakeDbOp[] {
|
||||||
|
return dbOps.filter((op) => op.kind === "delete");
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { SQL } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper for mocking the Drizzle query builder without a database.
|
||||||
|
*
|
||||||
|
* Usage inside a `vi.mock("@/lib/db", ...)` factory:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* vi.mock("@/lib/db", async () => {
|
||||||
|
* const schema = await import("@/db/schema");
|
||||||
|
* const { createFakeDb } = await import("@/test/fake-db");
|
||||||
|
* return {
|
||||||
|
* ...schema,
|
||||||
|
* db: createFakeDb((table) => (table === schema.User ? users : [])),
|
||||||
|
* };
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `resolve` receives the table object passed to `.from(...)` and the select
|
||||||
|
* projection (so callers can detect `count()` queries via a `total` key).
|
||||||
|
*/
|
||||||
|
export type FakeDbResolver = (
|
||||||
|
table: unknown,
|
||||||
|
projection: Record<string, unknown>,
|
||||||
|
) => unknown[] | Promise<unknown[]>;
|
||||||
|
|
||||||
|
function makeQuery(
|
||||||
|
table: unknown,
|
||||||
|
projection: Record<string, unknown>,
|
||||||
|
resolve: FakeDbResolver,
|
||||||
|
) {
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
const self = () => query;
|
||||||
|
for (const method of [
|
||||||
|
"where",
|
||||||
|
"orderBy",
|
||||||
|
"limit",
|
||||||
|
"offset",
|
||||||
|
"leftJoin",
|
||||||
|
"innerJoin",
|
||||||
|
"groupBy",
|
||||||
|
"having",
|
||||||
|
"for",
|
||||||
|
]) {
|
||||||
|
query[method] = self;
|
||||||
|
}
|
||||||
|
query.then = (
|
||||||
|
onFulfilled: (value: unknown) => unknown,
|
||||||
|
onRejected: (error: unknown) => unknown,
|
||||||
|
) => Promise.resolve(resolve(table, projection)).then(onFulfilled, onRejected);
|
||||||
|
query.catch = (onRejected: (error: unknown) => unknown) =>
|
||||||
|
Promise.resolve(resolve(table, projection)).catch(onRejected);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFakeDb(resolve: FakeDbResolver) {
|
||||||
|
return {
|
||||||
|
select: (projection: Record<string, unknown> = {}) => ({
|
||||||
|
from: (table: unknown) => makeQuery(table, projection, resolve),
|
||||||
|
}),
|
||||||
|
execute: (query: SQL | string) => resolve(query, {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user