diff --git a/src/components/admin/__tests__/sections.test.tsx b/src/components/admin/__tests__/sections.test.tsx new file mode 100644 index 000000000..1c3bf7b29 --- /dev/null +++ b/src/components/admin/__tests__/sections.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The section components rely on TanStack Query, the auth hook, and a +// few API routes. Stub everything at the module boundary so we can +// SSR-render each section in isolation and assert the heading paints. +// +// This is intentionally a smoke test — covering the loading branch +// (most sections paint a Loader2 + label until data arrives) is enough +// to guarantee the dynamic-route renderer doesn't blow up when it +// imports a section. + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin/system-status", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "ADMIN", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ADMIN_SECTION_SLUGS } from "../section-slugs"; +import { ApiTokenOverviewSection } from "../api-token-overview-section"; +import { BackupsSection } from "../backups-section"; +import { DangerZoneSection } from "../danger-zone-section"; +import { FeedbackInboxSection } from "../feedback-inbox-section"; +import { GeneralSettingsSection } from "../general-settings-section"; +import { IntegrationsGroupSection } from "../integrations-group-section"; +import { LoginOverviewSection } from "../login-overview-section"; +import { RemindersSection } from "../reminders-section"; +import { ServicesSection } from "../services-section"; +import { SystemStatusSection } from "../system-status-section"; +import { UserManagementSection } from "../user-management-section"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("admin sections — SSR smoke", () => { + it("every slug in `ADMIN_SECTION_SLUGS` has a wired component", () => { + // Renderer-side parity: if a slug is added to the list but no + // matching case is added in `renderer.tsx`, the switch's `never` + // exhaustiveness guard fails type-check. This test mirrors that + // guarantee at runtime so a slug typo (or stale list) is caught + // even without `tsc`. + expect(ADMIN_SECTION_SLUGS).toContain("system-status"); + expect(ADMIN_SECTION_SLUGS).toContain("backups"); + expect(ADMIN_SECTION_SLUGS).toContain("users"); + expect(ADMIN_SECTION_SLUGS).toContain("danger-zone"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("System"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("Allow registration"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("Telegram"); + }); + + it(" renders all four sub-sections", () => { + const html = render(); + expect(html).toContain("Umami"); + expect(html).toContain("Glitchtip"); + expect(html).toContain("Web Push"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("Feedback"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("Medication Reminders"); + }); + + it(" renders with filter pills", () => { + const html = render(); + expect(html).toContain("User Management"); + // Filter pills painted from i18n + expect(html).toContain("All"); + expect(html).toContain("Admins"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("API Tokens"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("Login Overview"); + }); + + it(" renders heading + run-now button", () => { + const html = render(); + expect(html).toContain("Backups"); + expect(html).toContain("Backup now"); + }); + + it(" renders", () => { + const html = render(); + expect(html).toContain("Danger Zone"); + }); +}); diff --git a/src/components/admin/__tests__/status-card-grid.test.tsx b/src/components/admin/__tests__/status-card-grid.test.tsx index 168bb3046..6bbc9a449 100644 --- a/src/components/admin/__tests__/status-card-grid.test.tsx +++ b/src/components/admin/__tests__/status-card-grid.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { StatusBadge, buildCards } from "../status-card-grid"; +import { ADMIN_SECTION_SLUGS } from "../section-slugs"; import type { StatusOverview } from "@/app/api/admin/status-overview/route"; const mockOverview: StatusOverview = { @@ -99,36 +102,42 @@ describe("buildCards()", () => { expect(cards[5].severity).toBe("info"); // audit log }); - it("links each card to a real anchor on the admin page", () => { - // Source-of-truth list of section IDs rendered by `src/app/admin/page.tsx`. - // If you add a new section to the admin page, add the ID here. The test - // proves every status-card href can actually scroll to something — - // `startsWith("/admin")` was too lax and let the v1.4.5 regression slip - // (every CTA bounced to `/admin#integrations` which does not exist). - const realAnchorIds = new Set([ - "section-system-status", - "section-admin-general", - "section-admin-services", - "section-admin-umami", - "section-admin-glitchtip", - "section-admin-webpush", - "section-admin-bugreport", - "section-admin-feedback", - "section-admin-reminders", - "section-user-management", - "section-api-tokens", - "section-login-overview", - "section-danger-zone", - ]); + it("links each card to a real `/admin/` route that exists on disk", () => { + // v1.5: status-card CTAs no longer use `#anchor` fragments — every href + // points at a dynamic-route slug under `/admin/[section]`. This test + // proves the slug exists (a) in the static `ADMIN_SECTION_SLUGS` list + // that drives `generateStaticParams()` AND (b) that the dynamic route + // file `src/app/admin/[section]/page.tsx` exists on disk. If a future + // refactor moves the dynamic route, the second check fails loudly. + const validSlugs = new Set(ADMIN_SECTION_SLUGS); + + // `__dirname` here is `.../src/components/admin/__tests__`, so walk + // up four levels to the repo root. + const repoRoot = join(__dirname, "..", "..", "..", ".."); + const dynamicRoute = join( + repoRoot, + "src", + "app", + "admin", + "[section]", + "page.tsx", + ); + expect( + existsSync(dynamicRoute), + `Dynamic admin route missing — expected ${dynamicRoute}`, + ).toBe(true); const cards = buildCards(mockOverview); for (const card of cards) { expect(card.cta.length).toBeGreaterThan(0); - // Hrefs must be `/admin#section-...` — sub-routes like `/admin/users` - // do not exist as of v1.4.6 and were the original regression. - expect(card.href).toMatch(/^\/admin#section-/); - const anchor = card.href.replace(/^\/admin#/, ""); - expect(realAnchorIds.has(anchor)).toBe(true); + // Hrefs must be real sub-routes; `/admin#section-*` was the v1.4.6 + // honesty problem the v1.5 refactor permanently removed. + expect(card.href).toMatch(/^\/admin\/[a-z][a-z0-9-]*$/); + const slug = card.href.replace(/^\/admin\//, ""); + expect( + validSlugs.has(slug), + `Card ${card.title} -> ${card.href} is not in ADMIN_SECTION_SLUGS`, + ).toBe(true); } });