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,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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user