- 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
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
}); |