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
+13875
-11
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -51,14 +51,15 @@ export async function uploadBadge(formData: FormData): Promise<void> {
|
||||
back("error", "File must be a GIF or PNG image");
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
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");
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const gif = await toBadgeGif(buffer);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(target, gif);
|
||||
} catch {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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:/");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -31,7 +31,9 @@ function parseId(raw: FormDataEntryValue | null): bigint | null {
|
||||
|
||||
/** Clamp a form value to a non-negative integer (defaulting to `fallback`). */
|
||||
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;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -351,7 +351,7 @@ export function generateScopedCss(resolved: ResolvedTheme): {
|
||||
rootCss: 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[] = [];
|
||||
|
||||
@@ -392,15 +392,10 @@ export function generateScopedCss(resolved: ResolvedTheme): {
|
||||
// Root: add layout + effects + media
|
||||
if (layoutVars || effectVars || mediaVars) {
|
||||
const extra = [layoutVars, effectVars, mediaVars].filter(Boolean).join(";");
|
||||
rootCss.replace("}", `${extra}}`);
|
||||
// Inject into root
|
||||
const rootEnd = rootCss.lastIndexOf("}");
|
||||
if (rootEnd !== -1) {
|
||||
const patched = `${rootCss.slice(0, rootEnd)}${extra};${rootCss.slice(rootEnd)}`;
|
||||
return {
|
||||
rootCss: patched,
|
||||
scopedCssBlocks,
|
||||
};
|
||||
rootCss = `${rootCss.slice(0, rootEnd)}${extra};${rootCss.slice(rootEnd)}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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