Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m52s

- Remove .prettierrc (dead config, Biome replaces Prettier)
- Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat
- Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit
- Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts
- Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars
- Replace barrel export src/types/index.ts with direct @/types/common imports
- Make trustHost conditional (development only) in auth.ts
- Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations
- Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
This commit is contained in:
openhands committed 2026-07-26 20:28:11 +02:00
1 parent b922f6d49f
commit 17847545dd
292 files changed
+69195 -67915

No files matched your search

+11 -8
View File
@@ -6,20 +6,20 @@ vi.mock("@/lib/logger", () => ({
logger: { error: loggerError },
}));
import { wrapAction, actionError } from "./action-helper";
import { actionError, wrapAction } from "./action-helper";
describe("wrapAction", () => {
it("returns success result on happy path", async () => {
const fn = vi.fn().mockResolvedValue({ id: 1 });
const result = await wrapAction(fn, "test_action");
expect(result.success).toBe(true);
expect(result.ok).toBe(true);
expect(result.data).toEqual({ id: 1 });
});
it("returns error result on thrown exception", async () => {
const fn = vi.fn().mockRejectedValue(new Error("DB error"));
const result = await wrapAction(fn, "failing_action");
expect(result.success).toBe(false);
expect(result.ok).toBe(false);
expect(result.error).toBe("DB error");
expect(loggerError).toHaveBeenCalled();
});
@@ -27,20 +27,20 @@ describe("wrapAction", () => {
it("catches non-Error values gracefully", async () => {
const fn = vi.fn().mockRejectedValue("string error");
const result = await wrapAction(fn, "bad_action");
expect(result.success).toBe(false);
expect(result.ok).toBe(false);
});
it("returns data returned by fn", async () => {
const fn = vi.fn().mockResolvedValue(undefined);
const result = await wrapAction(fn, "void_action");
expect(result.success).toBe(true);
expect(result.ok).toBe(true);
});
});
describe("actionError", () => {
it("returns failure result with message", () => {
const result = actionError("some_action", "something went wrong");
expect(result.success).toBe(false);
expect(result.ok).toBe(false);
expect(result.error).toBe("something went wrong");
});
@@ -48,7 +48,10 @@ describe("actionError", () => {
actionError("audit_action", "audit failed");
expect(loggerError).toHaveBeenCalledWith(
"Action failed: audit_action",
expect.objectContaining({ action: "audit_action", error: "audit failed" }),
expect.objectContaining({
action: "audit_action",
error: "audit failed",
}),
);
});
});
});
+25 -19
View File
@@ -1,29 +1,35 @@
import { logger } from "@/lib/logger";
export interface ActionResult<T = void> {
success: boolean;
data?: T;
error?: string;
ok: boolean;
data?: T;
error?: string;
}
export async function wrapAction<T>(
fn: () => Promise<T>,
actionName: string,
fn: () => Promise<T>,
actionName: string,
): Promise<ActionResult<T>> {
try {
const data = await fn();
return { success: true, data };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
logger.error(`Action failed: ${actionName}`, {
action: actionName,
error: message,
});
return { success: false, error: message };
}
try {
const data = await fn();
return { ok: true, data };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
logger.error(`Action failed: ${actionName}`, {
action: actionName,
error: message,
});
return { ok: false, error: message };
}
}
export function actionError(actionName: string, message: string): ActionResult<never> {
logger.error(`Action failed: ${actionName}`, { action: actionName, error: message });
return { success: false, error: message };
export function actionError(
actionName: string,
message: string,
): ActionResult<never> {
logger.error(`Action failed: ${actionName}`, {
action: actionName,
error: message,
});
return { ok: false, error: message };
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("admin CSRF wiring", () => {
it("defaults CSRF on for mutating withAdmin handlers", () => {
+1 -2
View File
@@ -1,4 +1,4 @@
import type { ListParams } from "@/types";
import type { ListParams } from "@/types/common";
export const PER_PAGE_OPTIONS = [10, 20, 50] as const;
const DEFAULT_PER_PAGE = 20;
@@ -31,4 +31,3 @@ export function calcPagination(total: number, page: number, perPage: number) {
offset: (Math.min(page, lastPage) - 1) * perPage,
};
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
+6 -10
View File
@@ -18,16 +18,12 @@ describe("positiveBigInt", () => {
expect(positiveBigInt(raw)).toBe(BigInt(raw));
});
it.each([
null,
"",
"0",
"-1",
"1.5",
"abc",
])("rejects invalid id %s", (raw) => {
expect(positiveBigInt(raw)).toBeNull();
});
it.each([null, "", "0", "-1", "1.5", "abc"])(
"rejects invalid id %s",
(raw) => {
expect(positiveBigInt(raw)).toBeNull();
},
);
});
describe("pagination", () => {
+13 -12
View File
@@ -1,10 +1,11 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { env } from "@/env";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { getCachedJwtVersion } from "@/lib/auth/jwt-version-cache";
import { LaravelEncrypter } from "@/lib/auth/laravel-encrypter";
import { checkLogin } from "@/lib/auth/password";
import { verifyTotp } from "@/lib/auth/totp";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { siteSettings } from "@/lib/services/site-settings";
@@ -23,7 +24,9 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
const secret = new LaravelEncrypter(appKey).decrypt(user.twoFactorSecret);
if (verifyTotp(code, secret)) return true;
} catch {
/* fall through to recovery */
logger.warn(
"2FA TOTP verification failed, falling through to recovery codes",
);
}
// Try recovery codes
@@ -32,6 +35,7 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
try {
codes = JSON.parse(user.twoFactorRecoveryCodes) as string[];
} catch {
logger.warn("Failed to parse 2FA recovery codes JSON");
return false;
}
const idx = codes.indexOf(code);
@@ -50,7 +54,7 @@ async function verify2faCode(userId: number, code: string): Promise<boolean> {
}
export const { handlers, signOut, auth } = NextAuth({
trustHost: true,
trustHost: env.NODE_ENV !== "production",
secret: env.AUTH_SECRET,
session: { strategy: "jwt", maxAge: 24 * 60 * 60 },
pages: { signIn: "/login" },
@@ -131,7 +135,9 @@ export const { handlers, signOut, auth } = NextAuth({
},
});
} catch {
/* ignore */
logger.warn("Failed to record login log for user", {
userId: user.id,
});
}
return {
@@ -147,9 +153,7 @@ export const { handlers, signOut, auth } = NextAuth({
async jwt({ token, user, account }) {
if (user) {
token.jwtVersion =
(user as { jwtVersion?: number }).jwtVersion ??
token.jwtVersion ??
0;
(user as { jwtVersion?: number }).jwtVersion ?? token.jwtVersion ?? 0;
token.jwtCheckedAt = Date.now();
}
@@ -166,17 +170,14 @@ export const { handlers, signOut, auth } = NextAuth({
if (Date.now() - lastCheck >= 60_000) {
try {
const version = await getCachedJwtVersion(Number(token.sub));
if (
version === null ||
(token.jwtVersion ?? 0) !== version
) {
if (version === null || (token.jwtVersion ?? 0) !== version) {
token.invalid = true;
delete token.sub;
return token;
}
token.jwtCheckedAt = Date.now();
} catch {
/* keep session on transient DB/cache errors */
logger.warn("JWT version check failed, keeping session");
}
}
}
+3 -1
View File
@@ -78,7 +78,9 @@ describe("checkLogin", () => {
expect(res.valid).toBe(true);
expect(res.upgradedHash).toMatch(/^\$2y\$/);
expect((res.upgradedHash as string).length).toBeLessThanOrEqual(60);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(true);
expect(await verifyPassword("oldpass", res.upgradedHash as string)).toBe(
true,
);
});
it("does NOT upgrade md5 when conversion is disabled", async () => {
+6 -7
View File
@@ -25,11 +25,10 @@ describe("databaseUserId", () => {
expect(databaseUserId(42n)).toBe(42);
});
it.each([
0n,
-1n,
BigInt(Number.MAX_SAFE_INTEGER) + 1n,
])("rejects unsafe database id %s", (value) => {
expect(databaseUserId(value)).toBeNull();
});
it.each([0n, -1n, BigInt(Number.MAX_SAFE_INTEGER) + 1n])(
"rejects unsafe database id %s",
(value) => {
expect(databaseUserId(value)).toBeNull();
},
);
});
+3 -3
View File
@@ -22,10 +22,10 @@ describe("production deploy workflow", () => {
it("reclaims ownership before git operations so www-data files can be overwritten", () => {
expect(workflow).toContain(
'sudo chown -R "$' + "{DEPLOY_USER}:" + '$' + '{DEPLOY_GROUP}"',
'sudo chown -R "$' + "{DEPLOY_USER}:" + "$" + '{DEPLOY_GROUP}"',
);
const reclaimAt = deployJob.indexOf(
'sudo chown -R "$' + "{DEPLOY_USER}:" + '$' + '{DEPLOY_GROUP}"',
'sudo chown -R "$' + "{DEPLOY_USER}:" + "$" + '{DEPLOY_GROUP}"',
);
const fetchAt = deployJob.indexOf('git -C "${LIVE}" fetch origin --prune');
expect(reclaimAt).toBeGreaterThan(-1);
@@ -79,7 +79,7 @@ describe("production deploy workflow", () => {
'export APP_VERSION="$(git -C "${LIVE}" rev-parse --short origin/main)"',
);
expect(workflow).toContain(
'export NEXT_PUBLIC_APP_VERSION="$' + "{APP_VERSION}\"",
'export NEXT_PUBLIC_APP_VERSION="$' + '{APP_VERSION}"',
);
});
+8 -11
View File
@@ -9,16 +9,13 @@ describe("formPositiveBigInt", () => {
expect(formPositiveBigInt(formData, "id")).toBe(42n);
});
it.each([
"",
"0",
"-1",
"1.5",
"invalid",
])("rejects invalid identifier %s", (value) => {
const formData = new FormData();
formData.set("id", value);
it.each(["", "0", "-1", "1.5", "invalid"])(
"rejects invalid identifier %s",
(value) => {
const formData = new FormData();
formData.set("id", value);
expect(formPositiveBigInt(formData, "id")).toBeNull();
});
expect(formPositiveBigInt(formData, "id")).toBeNull();
},
);
});
+6 -1
View File
@@ -4,7 +4,12 @@ import { resolveImagerBase } from "@/lib/imager";
export function avatarImageUrl(
base: string,
look: string,
opts: { size?: "s" | "m" | "l"; headOnly?: boolean; direction?: number; headDirection?: number } = {},
opts: {
size?: "s" | "m" | "l";
headOnly?: boolean;
direction?: number;
headDirection?: number;
} = {},
): string {
const resolved = resolveImagerBase(base);
const params = new URLSearchParams({ figure: look, img_format: "png" });
+10 -2
View File
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import type { IpAddress } from "./types";
const CSRF_BYTES = 32;
@@ -23,12 +24,14 @@ async function isRequestSecure(): Promise<boolean> {
if (proto === "https") return true;
if (proto === "http") return false;
} catch {
// headers unavailable during static analysis
logger.warn(
"Headers unavailable during static analysis in isRequestSecure",
);
}
try {
if (env.APP_URL) return new URL(env.APP_URL).protocol === "https:";
} catch {
// ignore malformed APP_URL
logger.warn("Malformed APP_URL in isRequestSecure");
}
return process.env.NODE_ENV === "production";
}
@@ -86,6 +89,7 @@ export function safeRedirect(
if (url.host === "localhost" || url.host === "127.0.0.1")
return destination;
} catch {
logger.warn("Failed to parse redirect URL", { destination });
if (isSafePath(destination)) return destination;
}
return fallback;
@@ -130,6 +134,7 @@ function trySetCsrfCookie(
c.set(opts.name, opts.value, opts);
return true;
} catch {
logger.warn("Failed to set CSRF cookie", { name: opts.name });
return false;
}
}
@@ -155,6 +160,7 @@ export async function setCsrfCookie(): Promise<string> {
return "";
} catch {
logger.warn("Failed to set CSRF cookie (outer)");
return "";
}
}
@@ -174,6 +180,7 @@ export async function validateCsrfToken(token: string): Promise<boolean> {
if (!stored) return false;
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
} catch {
logger.warn("CSRF token validation failed");
return false;
}
}
@@ -244,6 +251,7 @@ export async function extractClientIpAsync(): Promise<IpAddress> {
h.get("x-real-ip") ??
"0.0.0.0") as IpAddress;
} catch {
logger.warn("Failed to get client IP from headers");
return "0.0.0.0" as IpAddress;
}
}
-2
View File
@@ -15,5 +15,3 @@ export async function resolveHotelName(): Promise<string> {
if (fromEnv) return fromEnv;
return FALLBACK_HOTEL_NAME;
}
+1 -5
View File
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
getAvatarUrl,
resolveImagerBase,
resolveUpstreamBase,
} from "./imager";
import { getAvatarUrl, resolveImagerBase, resolveUpstreamBase } from "./imager";
describe("resolveImagerBase", () => {
it("defaults to /imaging when no public URL env is set", () => {
+3 -2
View File
@@ -8,13 +8,14 @@
export type { AvatarOptions } from "@/types/admin";
import type { AvatarOptions } from "@/types/admin";
import { env } from "@/env";
import type { AvatarOptions } from "@/types/admin";
/** Build an absolute default imager URL from public env vars. */
function defaultImagerUrl(): string {
const pub = env.NEXT_PUBLIC_IMAGER_URL?.trim();
if (pub && !pub.includes("/api/imaging/avatar")) return pub.replace(/\/+$/, "");
if (pub && !pub.includes("/api/imaging/avatar"))
return pub.replace(/\/+$/, "");
const app = env.NEXT_PUBLIC_APP_URL?.trim();
if (app) return `${app.replace(/\/+$/, "")}/imaging`;
return "/imaging";
+1 -2
View File
@@ -8,8 +8,7 @@ const level: LogLevel =
(env.NODE_ENV === "production" ? "info" : "debug");
const isProd = env.NODE_ENV === "production";
const isTest =
env.NODE_ENV === "test" || process.env.VITEST === "true";
const isTest = env.NODE_ENV === "test" || process.env.VITEST === "true";
const pinoLogger = pino({
level,
+6 -3
View File
@@ -1,5 +1,6 @@
import "server-only";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
const CACHE_TTL: Record<string, number> = {
@@ -26,7 +27,9 @@ function shouldCache(model: string, action: string): boolean {
}
function queryKey(model: string, action: string, args: unknown): string {
const hash = JSON.stringify(args).replace(/["{}[\],]/g, "").slice(0, 120);
const hash = JSON.stringify(args)
.replace(/["{}[\],]/g, "")
.slice(0, 120);
return `pq:${model}:${action}:${hash}`;
}
@@ -57,7 +60,7 @@ async function _cachedQuery<T>(
return JSON.parse(cached) as T;
}
} catch {
// ignore
logger.warn("Cache read failed", { key });
}
const result = await fn();
@@ -65,7 +68,7 @@ async function _cachedQuery<T>(
try {
await redis.setex(key, ttl, JSON.stringify(result));
} catch {
// ignore
logger.warn("Cache write failed", { key });
}
return result;
+3 -3
View File
@@ -4,9 +4,9 @@ import { resolvePrismaConnectionLimit } from "./prisma-pool";
describe("resolvePrismaConnectionLimit", () => {
it("caps the pool during production build phases", () => {
expect(resolvePrismaConnectionLimit(40, "phase-production-build")).toBe(5);
expect(
resolvePrismaConnectionLimit(40, "phase-production-compile"),
).toBe(5);
expect(resolvePrismaConnectionLimit(40, "phase-production-compile")).toBe(
5,
);
expect(resolvePrismaConnectionLimit(3, "phase-production-build")).toBe(3);
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { logger } from "@/lib/logger";
import { headers } from "next/headers";
import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis";
type Bucket = { count: number; resetAt: number };
+8 -6
View File
@@ -12,10 +12,7 @@ const globalForRedis = globalThis as unknown as {
function createRedis(): Redis | null {
const url = env.REDIS_URL;
if (!url) {
if (
env.NODE_ENV === "production" &&
!globalForRedis.redisMissingWarned
) {
if (env.NODE_ENV === "production" && !globalForRedis.redisMissingWarned) {
globalForRedis.redisMissingWarned = true;
logger.error(
"[redis] REDIS_URL is unset in production. Rate limits, site-settings cache, and JWT session invalidation fall back to in-process memory and will not work correctly across multiple instances or restarts. Set REDIS_URL in .env.",
@@ -32,9 +29,14 @@ function createRedis(): Redis | null {
},
lazyConnect: true,
});
client.on("error", () => {});
client.on("error", (err) => {
logger.warn("[redis] Connection error", { error: String(err) });
});
return client;
} catch {
} catch (err) {
logger.warn("[redis] Failed to create Redis client", {
error: String(err),
});
return null;
}
}
+4 -1
View File
@@ -27,7 +27,10 @@ export function handleActionError(error: unknown): ActionResult<never> {
return {
ok: false,
error: "Validation failed",
fieldErrors: z.flattenError(error).fieldErrors as Record<string, string[]>,
fieldErrors: z.flattenError(error).fieldErrors as Record<
string,
string[]
>,
};
}
if (error instanceof Error && error.name === "ActionError") {
+2 -1
View File
@@ -43,7 +43,8 @@ export function redactSentryEvent(
) as typeof event.request.data;
}
}
if (event.extra) event.extra = redactObject(event.extra) as typeof event.extra;
if (event.extra)
event.extra = redactObject(event.extra) as typeof event.extra;
if (event.contexts) {
event.contexts = redactObject(event.contexts) as typeof event.contexts;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
const create = vi.hoisted(() => vi.fn());
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const createFn = vi.hoisted(() => vi.fn());
const sendMailFn = vi.hoisted(() => vi.fn().mockResolvedValue(true));
-1
View File
@@ -16,7 +16,6 @@ import { sendMail } from "@/lib/services/email";
// fetch (Node 18+/Next 16) — no extra packages.
//
export type AlertSeverity =
| "info"
| "notice"
+41 -6
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const create = vi.hoisted(() => vi.fn());
const findMany = vi.hoisted(() => vi.fn());
@@ -14,7 +14,7 @@ vi.mock("@/lib/prisma", () => ({
vi.mock("@/env", () => ({ env: {} }));
import { logAudit, getAuditLogs } from "./audit";
import { getAuditLogs, logAudit } from "./audit";
beforeEach(() => {
vi.clearAllMocks();
@@ -87,8 +87,28 @@ describe("logAudit", () => {
describe("getAuditLogs", () => {
it("returns paginated logs with usernames", async () => {
findMany.mockResolvedValue([
{ id: 1, userId: 1, action: "test", target: "user", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-01" },
{ id: 2, userId: 2, action: "test2", target: "room", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-02" },
{
id: 1,
userId: 1,
action: "test",
target: "user",
targetId: null,
before: null,
after: null,
diff: null,
createdAt: "2024-01-01",
},
{
id: 2,
userId: 2,
action: "test2",
target: "room",
targetId: null,
before: null,
after: null,
diff: null,
createdAt: "2024-01-02",
},
]);
count.mockResolvedValue(2);
userFindMany.mockResolvedValue([
@@ -110,14 +130,29 @@ describe("getAuditLogs", () => {
await getAuditLogs({ search: "test" });
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { OR: [{ action: { contains: "test" } }, { target: { contains: "test" } }] },
where: {
OR: [
{ action: { contains: "test" } },
{ target: { contains: "test" } },
],
},
}),
);
});
it("falls back to User #id for unknown users", async () => {
findMany.mockResolvedValue([
{ id: 1, userId: 99, action: "x", target: "y", targetId: null, before: null, after: null, diff: null, createdAt: "2024-01-01" },
{
id: 1,
userId: 99,
action: "x",
target: "y",
targetId: null,
before: null,
after: null,
diff: null,
createdAt: "2024-01-01",
},
]);
count.mockResolvedValue(1);
userFindMany.mockResolvedValue([]);
+65 -7
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.hoisted(() => vi.fn());
const findMany = vi.hoisted(() => vi.fn());
@@ -11,7 +11,14 @@ const catalogUpdateMany = vi.hoisted(() => vi.fn());
vi.mock("@/lib/prisma", () => ({
prisma: {
catalogPages: { findUnique, findMany, update, create, delete: catalogDelete, updateMany: catalogUpdateMany },
catalogPages: {
findUnique,
findMany,
update,
create,
delete: catalogDelete,
updateMany: catalogUpdateMany,
},
$queryRaw: queryRaw,
$executeRaw: executeRaw,
},
@@ -19,8 +26,15 @@ vi.mock("@/lib/prisma", () => ({
vi.mock("@/env", () => ({ env: {} }));
import { buildNestedTree, toInt, movePage, deletePage, getAncestors, createPage } from "./catalog-tree";
import type { TreeNode } from "@/types/catalog";
import {
buildNestedTree,
createPage,
deletePage,
getAncestors,
movePage,
toInt,
} from "./catalog-tree";
beforeEach(() => {
vi.clearAllMocks();
@@ -44,9 +58,48 @@ describe("toInt", () => {
describe("buildNestedTree", () => {
const flat: TreeNode[] = [
{ id: 1, caption: "Root", parentId: -1, depth: 0, orderNum: 1, enabled: "1", visible: "1", iconImage: 0, iconColor: 0, pageLayout: "default_3x3", childCount: 2, itemCount: 0 },
{ id: 2, caption: "Child", parentId: 1, depth: 1, orderNum: 1, enabled: "1", visible: "1", iconImage: 0, iconColor: 0, pageLayout: "default_3x3", childCount: 1, itemCount: 5 },
{ id: 3, caption: "Grandchild", parentId: 2, depth: 2, orderNum: 1, enabled: "1", visible: "1", iconImage: 0, iconColor: 0, pageLayout: "default_3x3", childCount: 0, itemCount: 0 },
{
id: 1,
caption: "Root",
parentId: -1,
depth: 0,
orderNum: 1,
enabled: "1",
visible: "1",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 2,
itemCount: 0,
},
{
id: 2,
caption: "Child",
parentId: 1,
depth: 1,
orderNum: 1,
enabled: "1",
visible: "1",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 1,
itemCount: 5,
},
{
id: 3,
caption: "Grandchild",
parentId: 2,
depth: 2,
orderNum: 1,
enabled: "1",
visible: "1",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 0,
itemCount: 0,
},
];
it("builds nested tree from flat nodes", () => {
@@ -89,7 +142,12 @@ describe("createPage", () => {
it("converts boolean visible/enabled to string", async () => {
create.mockResolvedValue({ id: 1 });
await createPage({ parentId: 0, caption: "x", visible: false, enabled: false });
await createPage({
parentId: 0,
caption: "x",
visible: false,
enabled: false,
});
expect(create.mock.calls[0][0].data.visible).toBe("0");
expect(create.mock.calls[0][0].data.enabled).toBe("0");
});
+12 -12
View File
@@ -234,10 +234,10 @@ export async function cloneSingleFurni(params: {
// Rollback: remove both downloaded files so we don't leave orphaned assets.
await fs.unlink(/*turbopackIgnore: true*/ nitroPath).catch(() => {});
await fs.unlink(/*turbopackIgnore: true*/ iconPath).catch(() => {});
logger.warn(
"[clone-import] items_base insert failed for",
{ classname, error: (err as Error).message },
);
logger.warn("[clone-import] items_base insert failed for", {
classname,
error: (err as Error).message,
});
return {
ok: false,
classname,
@@ -254,10 +254,10 @@ export async function cloneSingleFurni(params: {
itemType,
);
} catch (err) {
logger.warn(
"[clone-import] FurnitureData append failed for",
{ classname, error: (err as Error).message },
);
logger.warn("[clone-import] FurnitureData append failed for", {
classname,
error: (err as Error).message,
});
warnings.push(`FurnitureData append failed: ${(err as Error).message}`);
}
@@ -272,10 +272,10 @@ export async function cloneSingleFurni(params: {
return nextCatalogId;
});
} catch (err) {
logger.warn(
"[clone-import] catalog entry failed for",
{ classname, error: (err as Error).message },
);
logger.warn("[clone-import] catalog entry failed for", {
classname,
error: (err as Error).message,
});
warnings.push(`catalog entry failed: ${(err as Error).message}`);
}
+9 -13
View File
@@ -436,7 +436,6 @@ export async function importSingleFurni(params: {
const starIdx = classname.indexOf("*");
const hasColor = starIdx !== -1;
const baseClassname = hasColor ? classname.substring(0, starIdx) : classname;
const _colorNum = hasColor ? classname.substring(starIdx + 1) : "2";
const iconFileName = classname.replace(/\*/g, "_");
const safeSwfName = baseClassname;
const safeNitroName = baseClassname;
@@ -536,10 +535,9 @@ export async function importSingleFurni(params: {
warnings.push("SWF not available for Nitro conversion");
}
} catch (err) {
logger.warn(
"[import-furni] SWF to Nitro conversion failed",
{ error: (err as Error).message },
);
logger.warn("[import-furni] SWF to Nitro conversion failed", {
error: (err as Error).message,
});
warnings.push(
`SWF to Nitro conversion failed: ${(err as Error).message}`,
);
@@ -665,10 +663,9 @@ export async function importSingleFurni(params: {
try {
await appendFurniEntry(furniEntry, itemType);
} catch (err) {
logger.warn(
"[import-furni] Failed to update FurnitureData.json",
{ error: (err as Error).message },
);
logger.warn("[import-furni] Failed to update FurnitureData.json", {
error: (err as Error).message,
});
warnings.push("FurnitureData.json update failed");
}
}
@@ -707,10 +704,9 @@ export async function importSingleFurni(params: {
});
}
} catch (err) {
logger.warn(
"[import-furni] Failed to create catalog entry",
{ error: (err as Error).message },
);
logger.warn("[import-furni] Failed to create catalog entry", {
error: (err as Error).message,
});
warnings.push("Catalog entry creation failed");
}
+7 -12
View File
@@ -5,13 +5,13 @@
export type { HabboItFurniEntry } from "@/types/furni";
import { logger } from "@/lib/logger";
import type { HabboItFurniEntry } from "@/types/furni";
import {
habboFurnidataUrl,
type HabboGamedataHotel,
habboFurnidataUrl,
} from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import type { HabboItFurniEntry } from "@/types/furni";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
@@ -30,11 +30,7 @@ export async function getHabboItFurnidata(): Promise<
const hotel = await getHabboGamedataHotel();
const now = Date.now();
if (
cache &&
cacheHotel === hotel &&
now - cacheTimestamp < CACHE_TTL
) {
if (cache && cacheHotel === hotel && now - cacheTimestamp < CACHE_TTL) {
return cache;
}
@@ -125,10 +121,9 @@ export async function getHabboItFurnidata(): Promise<
cacheTimestamp = Date.now();
return map;
} catch (err) {
logger.warn(
"[habbo-furnidata] Fetch error",
{ error: (err as Error).message },
);
logger.warn("[habbo-furnidata] Fetch error", {
error: (err as Error).message,
});
return cache || new Map();
} finally {
loading = null;
+4 -8
View File
@@ -7,13 +7,13 @@
export type { HabboAssetBadge } from "@/types/furni";
import { logger } from "@/lib/logger";
import type { HabboAssetBadge } from "@/types/furni";
import {
habboExternalTextsUrl,
type HabboGamedataHotel,
habboExternalTextsUrl,
} from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
import type { HabboAssetBadge } from "@/types/furni";
const BADGE_IMAGE_BASE = "https://images.habbo.com/c_images/album1584";
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
@@ -39,11 +39,7 @@ async function loadBadges(): Promise<{
}> {
const hotel = await getHabboGamedataHotel();
const now = Date.now();
if (
badgeCache &&
cacheHotel === hotel &&
now - cacheTimestamp < CACHE_TTL
) {
if (badgeCache && cacheHotel === hotel && now - cacheTimestamp < CACHE_TTL) {
return { badges: badgeCache, hotel };
}
if (loadingPromise) {
+8 -3
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const findMany = vi.hoisted(() => vi.fn());
@@ -29,7 +29,10 @@ describe("moderation", () => {
it("blocks text containing a filtered word", async () => {
findMany.mockResolvedValue([{ word: "badword" }]);
const result = await isAllowed("this contains badword here");
expect(result).toEqual({ ok: false, reason: 'Blocked by word filter: "badword"' });
expect(result).toEqual({
ok: false,
reason: 'Blocked by word filter: "badword"',
});
});
it("allows empty text", async () => {
@@ -46,7 +49,9 @@ describe("moderation", () => {
it("moderateOrThrow throws on blocked content", async () => {
findMany.mockResolvedValue([{ word: "bad" }]);
await expect(moderateOrThrow("this is bad")).rejects.toThrow("Blocked by word filter");
await expect(moderateOrThrow("this is bad")).rejects.toThrow(
"Blocked by word filter",
);
});
it("moderateOrThrow resolves on clean content", async () => {
+7 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/env", () => ({
env: {
@@ -10,7 +10,12 @@ vi.mock("@/env", () => ({
},
}));
import { isPayPalConfigured, creditsPerUnit, PAYPAL_API, PAYPAL_CURRENCY } from "./paypal";
import {
creditsPerUnit,
isPayPalConfigured,
PAYPAL_API,
PAYPAL_CURRENCY,
} from "./paypal";
describe("paypal", () => {
it("isPayPalConfigured returns true when credentials are set", () => {
+1 -2
View File
@@ -4,8 +4,7 @@
import { env } from "@/env";
export const PAYPAL_API =
env.PAYPAL_API?.replace(/\/+$/, "") ??
"https://api-m.sandbox.paypal.com";
env.PAYPAL_API?.replace(/\/+$/, "") ?? "https://api-m.sandbox.paypal.com";
export const PAYPAL_CURRENCY = env.PAYPAL_CURRENCY.toUpperCase();
+2 -2
View File
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import {
getRankPermissionColumn,
toPermissionValue,
splitRankUpdateFields,
RANK_GENERAL_FIELDS,
splitRankUpdateFields,
toPermissionValue,
} from "./permission-ranks";
describe("getRankPermissionColumn", () => {
+13 -3
View File
@@ -19,7 +19,12 @@ describe("sendCurrency", () => {
giveDiamonds: vi.fn(),
givePointsGotw: vi.fn(),
};
const result = await sendCurrency({ rcon, db: {} as any }, 1, "credits", 100);
const result = await sendCurrency(
{ rcon, db: {} as any },
1,
"credits",
100,
);
expect(result).toBe(true);
expect(rcon.giveCredits).toHaveBeenCalledWith(1, 100);
});
@@ -55,7 +60,12 @@ describe("sendCurrency", () => {
giveDiamonds: vi.fn(),
givePointsGotw: vi.fn(),
};
const result = await sendCurrency({ rcon, db: {} as any }, 1, "duckets", 50);
const result = await sendCurrency(
{ rcon, db: {} as any },
1,
"duckets",
50,
);
expect(result).toBe(true);
expect(rcon.giveDuckets).toHaveBeenCalledWith(1, 50);
});
@@ -138,4 +148,4 @@ describe("sendCurrency", () => {
}),
);
});
});
});
+8 -7
View File
@@ -1,6 +1,7 @@
import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { logger } from "@/lib/logger";
import { prisma } from "@/lib/prisma";
import { redis } from "@/lib/redis";
@@ -25,6 +26,7 @@ class SiteSettings {
});
return new Map(rows.map((r) => [r.key, r.value]));
} catch {
logger.warn("Failed to load site settings from database, using defaults");
return new Map(Object.entries(DEFAULTS));
}
}
@@ -38,7 +40,7 @@ class SiteSettings {
return new Map(Object.entries(parsed));
}
} catch {
// Redis unavailable — fall through
logger.warn("Redis cache read failed for site settings");
}
}
@@ -56,7 +58,7 @@ class SiteSettings {
JSON.stringify(obj),
);
} catch {
// non-critical
logger.warn("Failed to write site settings to Redis cache");
}
}
@@ -78,13 +80,12 @@ class SiteSettings {
return fallback;
}
async getMany(
keys: string[],
): Promise<Record<string, string | null>> {
async getMany(keys: string[]): Promise<Record<string, string | null>> {
const map = await this.load();
const result: Record<string, string | null> = {};
for (const key of keys) {
result[key] = map.get(key) ?? (key in DEFAULTS ? DEFAULTS[key] as string : null);
result[key] =
map.get(key) ?? (key in DEFAULTS ? (DEFAULTS[key] as string) : null);
}
return result;
}
@@ -111,7 +112,7 @@ class SiteSettings {
try {
await redis.del(REDIS_CACHE_KEY);
} catch {
// non-critical
logger.warn("Failed to invalidate Redis cache for site settings");
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const create = vi.hoisted(() => vi.fn());
+1 -8
View File
@@ -5,12 +5,7 @@
* then bundles everything into the .nitro format expected
* by the Nitro HTML5 client.
*/
import { gunzipSync, inflateSync } from "node:zlib";
import type {
BatchConversionItem,
BatchConversionResult,
ConversionResult,
} from "@/types/furni";
import type { ConversionResult } from "@/types/furni";
import {
parseBitsJpeg2,
parseBitsJpeg3,
@@ -535,5 +530,3 @@ export function extractIconFromSwf(
return null;
}
}
+12 -3
View File
@@ -35,7 +35,10 @@ export interface UploadParams {
customparams?: string;
}
const MIGRATIONS_DIR = path.resolve(/*turbopackIgnore: true*/ process.cwd(), "prisma/migrations");
const MIGRATIONS_DIR = path.resolve(
/*turbopackIgnore: true*/ process.cwd(),
"prisma/migrations",
);
let itemsBaseIdAllocChain: Promise<unknown> = Promise.resolve();
@@ -99,7 +102,10 @@ async function writeSqlMigration(params: {
const padded = String(num).padStart(4, "0");
const className = params.classname.replace(/[^a-zA-Z0-9_-]/g, "_");
const fileName = `${padded}_import_furni_${className}.sql`;
const filePath = path.join(/*turbopackIgnore: true*/ MIGRATIONS_DIR, fileName);
const filePath = path.join(
/*turbopackIgnore: true*/ MIGRATIONS_DIR,
fileName,
);
const lines: string[] = [
`-- Migration: ${fileName}`,
@@ -192,7 +198,10 @@ export async function uploadSingleFurni(params: {
const { iconDir, nitroDir } = await getFurniAssetDirs();
const nitroFileName = `${classname}.nitro`;
const nitroPath = path.join(/*turbopackIgnore: true*/ nitroDir, nitroFileName);
const nitroPath = path.join(
/*turbopackIgnore: true*/ nitroDir,
nitroFileName,
);
if (existsSync(nitroPath)) {
return {
ok: false,
+1 -1
View File
@@ -1,5 +1,5 @@
import { logger } from "@/lib/logger";
import { env } from "@/env";
import { logger } from "@/lib/logger";
import { prisma } from "../prisma";
export type { WebhookAction } from "@/types/admin";
+51 -47
View File
@@ -65,13 +65,13 @@ describe("built-in theme presets", () => {
}
});
it.each([
"#22c55e",
"#ef4444",
])("derives readable text for default button %s", (background) => {
const foreground = readableColor("#ffffff", [background]);
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
});
it.each(["#22c55e", "#ef4444"])(
"derives readable text for default button %s",
(background) => {
const foreground = readableColor("#ffffff", [background]);
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
},
);
});
describe("readableColor", () => {
@@ -101,25 +101,26 @@ describe("readableColor", () => {
});
describe("deriveAdminPalette", () => {
it.each(
Object.entries(PRESETS),
)("keeps admin text readable for %s", (_name, preset) => {
for (const palette of [preset.light, preset.dark]) {
const admin = deriveAdminPalette(palette);
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(
4.5,
);
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(
contrastRatio(admin.accentForeground, admin.accent),
).toBeGreaterThanOrEqual(4.5);
}
});
it.each(Object.entries(PRESETS))(
"keeps admin text readable for %s",
(_name, preset) => {
for (const palette of [preset.light, preset.dark]) {
const admin = deriveAdminPalette(palette);
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(
4.5,
);
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(
contrastRatio(admin.muted, admin.surface),
).toBeGreaterThanOrEqual(4.5);
expect(
contrastRatio(admin.accentForeground, admin.accent),
).toBeGreaterThanOrEqual(4.5);
}
},
);
it("does not copy hostile public structural colors into admin surfaces", () => {
const admin = deriveAdminPalette({
@@ -153,9 +154,9 @@ describe("deriveAdminPalette", () => {
expect(admin.canvas).toBe("#eef1f4");
expect(admin.surface).toBe("#f5f7fa");
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.sidebarText, admin.sidebar)).toBeGreaterThanOrEqual(
4.5,
);
expect(
contrastRatio(admin.sidebarText, admin.sidebar),
).toBeGreaterThanOrEqual(4.5);
});
it("ignores dark admin overrides while in light mode", () => {
@@ -184,7 +185,9 @@ describe("deriveAdminPalette", () => {
},
);
expect(contrastRatio(admin.text, admin.canvas)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(4.5);
expect(contrastRatio(admin.text, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
expect(contrastRatio(admin.muted, admin.surface)).toBeGreaterThanOrEqual(
4.5,
);
@@ -249,19 +252,20 @@ describe("ensureReadableThemeColors", () => {
});
describe("derivePublicForegrounds", () => {
it.each(
Object.entries(PRESETS),
)("makes every semantic pair readable for %s", (_name, preset) => {
for (const palette of [preset.light, preset.dark]) {
const result = derivePublicForegrounds(palette);
for (const pair of result.pairs) {
expect(
contrastRatio(pair.foreground, pair.background),
pair.name,
).toBeGreaterThanOrEqual(4.5);
it.each(Object.entries(PRESETS))(
"makes every semantic pair readable for %s",
(_name, preset) => {
for (const palette of [preset.light, preset.dark]) {
const result = derivePublicForegrounds(palette);
for (const pair of result.pairs) {
expect(
contrastRatio(pair.foreground, pair.background),
pair.name,
).toBeGreaterThanOrEqual(4.5);
}
}
}
});
},
);
it("covers admin hierarchy and every semantic status", () => {
const required = [
@@ -286,11 +290,11 @@ describe("derivePublicForegrounds", () => {
(candidate) => candidate.name === name,
);
expect(pair, `missing ${name}`).toBeDefined();
const safePair = pair as NonNullable<typeof pair>;
expect(
contrastRatio(safePair.foreground, safePair.background),
name,
).toBeGreaterThanOrEqual(4.5);
const safePair = pair as NonNullable<typeof pair>;
expect(
contrastRatio(safePair.foreground, safePair.background),
name,
).toBeGreaterThanOrEqual(4.5);
}
}
}
+1 -2
View File
@@ -416,8 +416,7 @@ function completePalette(
values.button_secondary_color ?? merged.color_accent;
merged.button_outline_color =
values.button_outline_color ?? merged.border_color;
merged.panel_border_color =
values.panel_border_color ?? merged.border_color;
merged.panel_border_color = values.panel_border_color ?? merged.border_color;
merged.gradient_from = values.gradient_from ?? merged.color_primary;
merged.gradient_to = values.gradient_to ?? merged.color_accent;
return merged;
+15 -20
View File
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createEventSchema,
eventTypeSchema,
} from "./event";
import { createEventSchema, eventTypeSchema } from "./event";
describe("eventTypeSchema", () => {
it("accepts valid type", () => {
@@ -19,7 +16,9 @@ describe("eventTypeSchema", () => {
});
it("rejects empty name", () => {
expect(eventTypeSchema.safeParse({ name: "", slug: "test" }).success).toBe(false);
expect(eventTypeSchema.safeParse({ name: "", slug: "test" }).success).toBe(
false,
);
expect(eventTypeSchema.safeParse({ slug: "test" }).success).toBe(false);
});
@@ -36,12 +35,10 @@ describe("eventTypeSchema", () => {
it("rejects slug with spaces", () => {
expect(
eventTypeSchema
.safeParse({
name: "Test",
slug: "test event",
})
.success,
eventTypeSchema.safeParse({
name: "Test",
slug: "test event",
}).success,
).toBe(false);
});
});
@@ -68,14 +65,12 @@ describe("createEventSchema", () => {
it("rejects missing title", () => {
expect(
createEventSchema
.safeParse({
title: "",
description: "Has desc",
typeId: 1,
startsAt: "2025-07-25T10:00:00Z",
})
.success,
createEventSchema.safeParse({
title: "",
description: "Has desc",
typeId: 1,
startsAt: "2025-07-25T10:00:00Z",
}).success,
).toBe(false);
});
@@ -102,4 +97,4 @@ describe("createEventSchema", () => {
expect(result.data.isRecurring).toBe(0);
}
});
});
});
+10 -13
View File
@@ -2,9 +2,9 @@ import { describe, expect, it } from "vitest";
import {
createPollSchema,
updatePollSchema,
pollQuestionSchema,
pollVoteSchema,
updatePollSchema,
voteOnPollSchema,
} from "./poll";
@@ -61,17 +61,14 @@ describe("pollQuestionSchema", () => {
it("rejects empty question", () => {
expect(
pollQuestionSchema
.safeParse({ pollId: 1, question: "", options: "Red" })
pollQuestionSchema.safeParse({ pollId: 1, question: "", options: "Red" })
.success,
).toBe(false);
});
it("rejects missing pollId", () => {
expect(
pollQuestionSchema
.safeParse({ question: "Test?", options: "A" })
.success,
pollQuestionSchema.safeParse({ question: "Test?", options: "A" }).success,
).toBe(false);
});
});
@@ -111,9 +108,9 @@ describe("voteOnPollSchema", () => {
});
it("rejects empty votes array", () => {
expect(
voteOnPollSchema.safeParse({ pollId: 1, votes: [] }).success,
).toBe(false);
expect(voteOnPollSchema.safeParse({ pollId: 1, votes: [] }).success).toBe(
false,
);
});
it("rejects too many votes (more than 50)", () => {
@@ -121,9 +118,9 @@ describe("voteOnPollSchema", () => {
questionId: i + 1,
answer: `Answer ${i + 1}`,
}));
expect(
voteOnPollSchema.safeParse({ pollId: 1, votes }).success,
).toBe(false);
expect(voteOnPollSchema.safeParse({ pollId: 1, votes }).success).toBe(
false,
);
});
});
@@ -137,4 +134,4 @@ describe("updatePollSchema", () => {
const result = updatePollSchema.safeParse({ title: "New Title" });
if (result.success) expect(result.data.title).toBe("New Title");
});
});
});
+8 -20
View File
@@ -27,38 +27,26 @@ describe("updateRoomSchema", () => {
});
it("rejects name too long", () => {
expect(
updateRoomSchema
.safeParse({ name: "a".repeat(61) })
.success,
).toBe(false);
expect(updateRoomSchema.safeParse({ name: "a".repeat(61) }).success).toBe(
false,
);
});
it("rejects description too long", () => {
expect(
updateRoomSchema
.safeParse({ description: "b".repeat(513) })
.success,
updateRoomSchema.safeParse({ description: "b".repeat(513) }).success,
).toBe(false);
});
it("rejects invalid state", () => {
expect(
updateRoomSchema
.safeParse({ state: "banana" })
.success,
).toBe(false);
expect(updateRoomSchema.safeParse({ state: "banana" }).success).toBe(false);
});
it("rejects usersMax below 1", () => {
expect(
updateRoomSchema.safeParse({ usersMax: 0 }).success,
).toBe(false);
expect(updateRoomSchema.safeParse({ usersMax: 0 }).success).toBe(false);
});
it("rejects usersMax above 100", () => {
expect(
updateRoomSchema.safeParse({ usersMax: 101 }).success,
).toBe(false);
expect(updateRoomSchema.safeParse({ usersMax: 101 }).success).toBe(false);
});
});
});
+18 -25
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
assignTicketSchema,
createTicketSchema,
replyTicketSchema,
updateTicketStatusSchema,
assignTicketSchema,
updateTicketPrioritySchema,
updateTicketStatusSchema,
} from "./ticket";
describe("createTicketSchema", () => {
@@ -20,36 +20,30 @@ describe("createTicketSchema", () => {
it("rejects short subject (less than 5 chars)", () => {
expect(
createTicketSchema
.safeParse({
subject: "Hi",
category: "technical",
message: "Help",
})
.success,
createTicketSchema.safeParse({
subject: "Hi",
category: "technical",
message: "Help",
}).success,
).toBe(false);
});
it("rejects short message (less than 10 chars)", () => {
expect(
createTicketSchema
.safeParse({
subject: "Subject",
category: "technical",
message: "Hi",
})
.success,
createTicketSchema.safeParse({
subject: "Subject",
category: "technical",
message: "Hi",
}).success,
).toBe(false);
});
it("rejects missing category", () => {
expect(
createTicketSchema
.safeParse({
subject: "Subject",
message: "Some message",
})
.success,
createTicketSchema.safeParse({
subject: "Subject",
message: "Some message",
}).success,
).toBe(false);
});
});
@@ -87,8 +81,7 @@ describe("updateTicketStatusSchema", () => {
it("rejects invalid status", () => {
expect(
updateTicketStatusSchema
.safeParse({ ticketId: 1, status: "unknown" })
updateTicketStatusSchema.safeParse({ ticketId: 1, status: "unknown" })
.success,
).toBe(false);
});
@@ -116,4 +109,4 @@ describe("updateTicketPrioritySchema", () => {
});
expect(result.success).toBe(true);
});
});
});
+31 -46
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import {
createUserSchema,
updateUserSchema,
banUserSchema,
createUserSchema,
giveBadgeSchema,
updateUserSchema,
} from "./user";
describe("createUserSchema", () => {
@@ -23,61 +23,51 @@ describe("createUserSchema", () => {
it("rejects short username (< 3 chars)", () => {
expect(
createUserSchema
.safeParse({
username: "ab",
mail: "[email protected]",
password: "secret123",
})
.success,
createUserSchema.safeParse({
username: "ab",
mail: "[email protected]",
password: "secret123",
}).success,
).toBe(false);
});
it("rejects long username (> 20 chars)", () => {
expect(
createUserSchema
.safeParse({
username: "a".repeat(21),
mail: "[email protected]",
password: "secret123",
})
.success,
createUserSchema.safeParse({
username: "a".repeat(21),
mail: "[email protected]",
password: "secret123",
}).success,
).toBe(false);
});
it("rejects invalid username characters", () => {
expect(
createUserSchema
.safeParse({
username: "user@name",
mail: "[email protected]",
password: "secret123",
})
.success,
createUserSchema.safeParse({
username: "user@name",
mail: "[email protected]",
password: "secret123",
}).success,
).toBe(false);
});
it("rejects invalid email", () => {
expect(
createUserSchema
.safeParse({
username: "TestUser",
mail: "not-an-email",
password: "secret123",
})
.success,
createUserSchema.safeParse({
username: "TestUser",
mail: "not-an-email",
password: "secret123",
}).success,
).toBe(false);
});
it("rejects short password (< 6 chars)", () => {
expect(
createUserSchema
.safeParse({
username: "TestUser",
mail: "[email protected]",
password: "12345",
})
.success,
createUserSchema.safeParse({
username: "TestUser",
mail: "test@example.com",
password: "12345",
}).success,
).toBe(false);
});
});
@@ -95,9 +85,7 @@ describe("updateUserSchema", () => {
});
it("rejects rank out of range", () => {
expect(
updateUserSchema.safeParse({ rank: 8 }).success,
).toBe(false);
expect(updateUserSchema.safeParse({ rank: 8 }).success).toBe(false);
});
});
@@ -124,16 +112,13 @@ describe("banUserSchema", () => {
it("rejects empty reason", () => {
expect(
banUserSchema
.safeParse({ userId: 1, reason: "", duration: 1 })
.success,
banUserSchema.safeParse({ userId: 1, reason: "", duration: 1 }).success,
).toBe(false);
});
it("rejects negative duration", () => {
expect(
banUserSchema
.safeParse({ userId: 1, reason: "Bad", duration: -1 })
banUserSchema.safeParse({ userId: 1, reason: "Bad", duration: -1 })
.success,
).toBe(false);
});
@@ -153,4 +138,4 @@ describe("giveBadgeSchema", () => {
giveBadgeSchema.safeParse({ userId: 1, badgeCode: "" }).success,
).toBe(false);
});
});
});