chore: improve tooling, linting, testing, and CI
CI / check (push) Failing after 14s
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m38s

- Add LICENSE file (CC BY-NC-SA 4.0)
- Add .nvmrc pinning Node 22
- Add Renovate config with daily schedule and Gitea Actions workflow
- Reduce ESLint max-warnings from 1000 to 50
- Re-enable Biome a11y/security recommended rules
- Fix Biome lint issues (a11y, hook deps, SVG labels, checkbox semantics)
- Improve CI: run on pushes to feat/fix branches, add pnpm audit
- Add Vitest coverage with v8 provider and thresholds
- Add E2E tests (auth, admin, navigation specs)
- Add admin-maintenance server action test
- Install @vitest/coverage-v8
- Ignore coverage/ directory
This commit is contained in:
openhands committed 2026-07-27 17:03:09 +02:00
1 parent 0f5c190ab1
commit 423a33200e
28 files changed
+1016 -80

No files matched your search

+182
View File
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockUpsert, mockRequirePermission, mockReload, mockRevalidatePath } =
vi.hoisted(() => ({
mockUpsert: vi.fn(),
mockRequirePermission: vi.fn(),
mockReload: vi.fn(),
mockRevalidatePath: vi.fn(),
}));
vi.mock("@/lib/permissions", () => ({
PERMS: {
ADMIN_DASHBOARD: "admin.dashboard",
SETTINGS_VIEW: "admin.settings.view",
SETTINGS_EDIT: "admin.settings.edit",
},
}));
vi.mock("@/lib/prisma", () => ({
prisma: {
websiteSetting: { upsert: mockUpsert },
},
}));
vi.mock("@/lib/admin/guard", () => ({
requirePermission: mockRequirePermission,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { reload: mockReload },
}));
vi.mock("next/cache", () => ({
revalidatePath: mockRevalidatePath,
}));
import { saveMaintenance } from "./admin-maintenance";
beforeEach(() => {
vi.clearAllMocks();
});
describe("saveMaintenance", () => {
it("enables maintenance mode with message and min rank", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("enabled", "on");
fd.set("message", "We will be back soon!");
fd.set("min_rank", "3");
await saveMaintenance(fd);
expect(mockRequirePermission).toHaveBeenCalled();
expect(mockUpsert).toHaveBeenCalledTimes(3);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "1" },
create: expect.objectContaining({
key: "maintenance_enabled",
value: "1",
}),
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_message" },
update: { value: "We will be back soon!" },
create: expect.objectContaining({
key: "maintenance_message",
value: "We will be back soon!",
}),
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "3" },
create: expect.objectContaining({
key: "min_maintenance_login_rank",
value: "3",
}),
}),
);
expect(mockReload).toHaveBeenCalledOnce();
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/maintenance");
});
it("disables maintenance mode", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("message", "");
fd.set("min_rank", "");
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "maintenance_enabled" },
update: { value: "0" },
}),
);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
it("defaults min_rank to 5 when input is empty", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("enabled", "on");
fd.set("message", "");
fd.set("min_rank", "");
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
it("defaults min_rank to 5 when input is negative", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("enabled", "on");
fd.set("message", "");
fd.set("min_rank", "-1");
await saveMaintenance(fd);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { key: "min_maintenance_login_rank" },
update: { value: "5" },
}),
);
});
it("calls requirePermission with SETTINGS_EDIT", async () => {
mockRequirePermission.mockResolvedValue({
id: 1,
rank: 7,
username: "admin",
});
const fd = new FormData();
fd.set("message", "");
fd.set("min_rank", "");
await saveMaintenance(fd);
expect(mockRequirePermission).toHaveBeenCalledWith("admin.settings.edit");
});
});
@@ -262,6 +262,7 @@ export function ImportFurniClient() {
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable
}, [toggleSelectAll]);
// Derived: unique categories from loaded items
+3 -1
View File
@@ -220,11 +220,13 @@ export function ClientView({
window.removeEventListener("touchmove", onTouchMove);
window.removeEventListener("touchend", onEnd);
};
}, [dragging]);
// biome-ignore lint/correctness/useExhaustiveDependencies: snapPos is a useCallback used intentionally here
}, [dragging, snapPos]);
return (
<>
{pos ? (
// biome-ignore lint/a11y/noStaticElementInteractions: draggable toolbar with explicit mouse/touch handlers
<div
ref={toolbarRef}
className={`fixed z-[9999] backdrop-blur-xl bg-black/30 rounded-xl border border-white/10 shadow-2xl px-1.5 py-1 select-none ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
+1 -1
View File
@@ -1745,7 +1745,7 @@ details[open] > summary .details-open\:rotate-180 {
/* Input focus glow */
.input-glow:focus {
outline: none;
border-color: var(--color-primary) !important;
border-color: var(--color-primary);
box-shadow:
0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent),
0 0 20px color-mix(in srgb, var(--color-primary) 10%, transparent);
@@ -144,6 +144,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
// biome-ignore lint/correctness/useExhaustiveDependencies: handleSave is a stable callback from parent
}, [canEdit, isDirty, saving, page, handleSave]);
const loadPage = useCallback(
@@ -187,7 +187,7 @@ export function SortableTree({
setFilter("all");
dispatch({ type: "COLLAPSE_ALL" });
void loadTabTree();
}, [activeTabId, loadTabTree]);
}, [activeTabId, loadTabTree, dispatch]);
// Debounced search
useEffect(() => {
@@ -325,7 +325,6 @@ export function SortableTree({
// ── Keyboard navigation ─────────────────────────────────────
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
-1
View File
@@ -200,7 +200,6 @@ export function CatalogTree({
}, []);
// Debounced search
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null);
+2 -1
View File
@@ -155,7 +155,6 @@ export function HomeLoginForm({
placeholder="Enter your 2FA code"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
className="w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
style={{
backgroundColor: "var(--color-background)",
@@ -195,6 +194,8 @@ export function HomeLoginForm({
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-label="Loading"
>
<circle
className="opacity-25"
+2 -1
View File
@@ -133,7 +133,6 @@ export function LoginForm({
placeholder={t("codePlaceholder")}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
className="input-glow w-full rounded-xl border-2 px-4 py-3 text-sm font-medium transition-all focus:outline-none"
style={{
backgroundColor: "var(--color-background)",
@@ -173,6 +172,8 @@ export function LoginForm({
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-label="Loading"
>
<circle
className="opacity-25"
+18 -50
View File
@@ -291,62 +291,28 @@ export function RegisterForm({
}}
>
<div className="flex items-center gap-3 text-sm">
<button
type="button"
role="checkbox"
aria-checked={termsAccepted}
onClick={() => setTermsAccepted(!termsAccepted)}
className="w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all duration-200"
style={{
borderColor: termsAccepted
? "var(--color-primary)"
: "color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
backgroundColor: termsAccepted
? "var(--color-primary)"
: "transparent",
}}
>
{termsAccepted && (
<svg
className="w-3 h-3"
style={{ color: "var(--color-primary-foreground)" }}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
role="img"
aria-label="Terms accepted"
>
<title>Terms accepted</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
<input
type="hidden"
type="checkbox"
id="terms"
name="terms"
value={termsAccepted ? "1" : ""}
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="w-5 h-5 rounded border-2 accent-(--color-primary) shrink-0 cursor-pointer"
/>
<span
role="checkbox"
aria-checked={termsAccepted}
tabIndex={0}
<label
htmlFor="terms"
className="font-semibold cursor-pointer select-none"
style={{ color: "var(--color-text-readable)" }}
onClick={() => setTermsAccepted(!termsAccepted)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setTermsAccepted(!termsAccepted);
}
}}
className="font-semibold cursor-pointer select-none"
style={{ color: "var(--color-text-readable)" }}
onClick={() => setTermsAccepted(!termsAccepted)}
>
{t("termsAccept", { hotel: hotelName })}
</span>
</label>
</div>
</div>
@@ -372,11 +338,13 @@ export function RegisterForm({
>
{isPending ? (
<span className="inline-flex items-center justify-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
role="img"
aria-label="Loading"
>
<circle
className="opacity-25"
cx="12"
+1 -1
View File
@@ -15,7 +15,7 @@ export function PwaRegister() {
}
});
navigator.serviceWorker.register("/sw.js?" + SW_VERSION).catch(() => {});
navigator.serviceWorker.register(`/sw.js?${SW_VERSION}`).catch(() => {});
}, []);
return null;
}
+2
View File
@@ -220,6 +220,8 @@ export async function TopHeader({ session }: { session: Session | null }) {
strokeLinecap="round"
strokeLinejoin="round"
style={{ color: textColor }}
role="img"
aria-label="Notifications"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
+3 -1
View File
@@ -4,10 +4,12 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
function Label({ className, htmlFor, ...props }: React.ComponentProps<"label">) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: reusable component, input is associated via htmlFor at usage site
<label
data-slot="label"
htmlFor={htmlFor}
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
+4 -1
View File
@@ -60,7 +60,10 @@ export const { handlers, signOut, auth } = NextAuth({
pages: { signIn: "/login", error: "/login" },
logger: {
error(error) {
logger.error("NextAuth error", { error: error.message, stack: error.stack });
logger.error("NextAuth error", {
error: error.message,
stack: error.stack,
});
},
},
providers: [