chore: improve tooling, linting, testing, and CI
- 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:
1 parent
0f5c190ab1
commit
423a33200e
28 files changed
+1016
-80
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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: [
|
||||
|
||||
Reference in new issue
Block a user