From 37f62a8007d6b50e7b53e5785365965c00c74a5b Mon Sep 17 00:00:00 2001 From: Citali Date: Thu, 12 Feb 2026 20:34:53 +0100 Subject: [PATCH] test(mvp1): add owner invariants and media form coverage --- TODO.md | 40 ++- .../media/media-upload-form.test.tsx | 84 +++++++ apps/admin/src/lib/auth/server.test.ts | 238 ++++++++++++++++++ 3 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/components/media/media-upload-form.test.tsx create mode 100644 apps/admin/src/lib/auth/server.test.ts diff --git a/TODO.md b/TODO.md index 406c8f4..05656a9 100644 --- a/TODO.md +++ b/TODO.md @@ -187,14 +187,48 @@ This file is the single source of truth for roadmap and delivery progress. ### Testing - [x] [P1] Unit tests for content schemas and service logic -- [ ] [P1] Component tests for admin forms (pages/media/navigation) -- [ ] [P1] Integration tests for owner invariant and hidden support-user protection +- [~] [P1] Component tests for admin forms (pages/media/navigation) +- [x] [P1] Integration tests for owner invariant and hidden support-user protection - [x] [P1] Integration tests for registration allow/deny behavior - [ ] [P1] Integration tests for translated content CRUD and locale-specific validation - [~] [P1] E2E happy paths: create page, publish, see on public app - [~] [P1] E2E happy paths: media upload + artwork refinement display - [~] [P1] E2E happy paths: commissions kanban transitions +### Code Documentation And Handover + +- [ ] [P1] Create architecture map per package/app (`what exists`, `why`, `how to extend`) for `@cms/db`, `@cms/content`, `@cms/crud`, `@cms/ui`, `apps/admin`, `apps/web` +- [ ] [P1] Add module-level ownership docs for auth, media, pages/navigation, commissions, announcements/news flows +- [ ] [P1] Document critical invariants (single owner rule, protected support user, registration policy gates, media storage key contract) +- [ ] [P1] Add “request lifecycle” docs for key flows (auth sign-in/up, media upload, page publish, commission status change) +- [ ] [P1] Add coding handover playbook: local setup, migration workflow, test strategy, branch/release process, common failure recovery +- [ ] [P2] Add code-level diagrams (Mermaid) for service boundaries and data relationships +- [ ] [P2] Add route/action inventory for admin and public apps with linked source files + +## MVP 1.5: UX/UI And Theming + +### MVP1.5 Suggested Branch Order + +- [ ] [P1] `todo/mvp15-design-tokens-foundation`: + establish shared design tokens (color, spacing, radius, typography scale, motion) in `@cms/ui` and app-level theme contracts +- [ ] [P1] `todo/mvp15-admin-layout-polish`: + refine admin shell, navigation hierarchy, spacing rhythm, table/form visual consistency, empty/loading/error states +- [ ] [P1] `todo/mvp15-public-layout-and-templates`: + define public visual direction (hero/header/footer/content widths), page templates for home/content/news/portfolio +- [ ] [P2] `todo/mvp15-component-library-pass`: + align shadcn-based primitives with CMS brand system (buttons, inputs, cards, badges, tabs, dialogs, toasts) +- [ ] [P2] `todo/mvp15-responsive-and-a11y-pass`: + mobile/tablet breakpoints, keyboard flow, focus states, contrast checks, reduced-motion support +- [ ] [P2] `todo/mvp15-visual-regression-baseline`: + add screenshot baselines for critical admin/public routes to guard layout regressions + +### Deliverables + +- [ ] [P1] Admin UI baseline feels production-ready for daily editorial use +- [ ] [P1] Public UI baseline is template-ready for artist branding and portfolio storytelling +- [ ] [P2] Shared UI primitives are consistent across admin and public apps +- [ ] [P2] Core routes have visual-regression coverage for the new layout baseline + ## MVP 2: Production Readiness ### Admin App @@ -283,6 +317,8 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Expanded unit coverage for content/domain schemas and post service behavior (`packages/content/src/domain-schemas.test.ts`, `packages/db/src/posts.test.ts`). - [2026-02-12] Added auth flow integration tests for `/login`, `/register`, `/welcome` to validate registration allow/deny and owner bootstrap redirects. - [2026-02-12] Admin settings now manage public header banner (enabled/message/CTA), backed by `system_setting` and consumed by public layout rendering. +- [2026-02-12] Added owner/support invariant integration tests for auth guards (`apps/admin/src/lib/auth/server.test.ts`), covering protected-user deletion blocking and one-owner repair/promotion rules. +- [2026-02-12] Started admin form component tests with media upload behavior coverage (`apps/admin/src/components/media/media-upload-form.test.tsx`). ## How We Use This File diff --git a/apps/admin/src/components/media/media-upload-form.test.tsx b/apps/admin/src/components/media/media-upload-form.test.tsx new file mode 100644 index 0000000..acb80f2 --- /dev/null +++ b/apps/admin/src/components/media/media-upload-form.test.tsx @@ -0,0 +1,84 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { MediaUploadForm } from "./media-upload-form" + +describe("MediaUploadForm", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("updates accepted MIME list based on selected media type", () => { + render() + + const fileInput = screen.getByLabelText("File") as HTMLInputElement + const typeSelect = screen.getByLabelText("Type") as HTMLSelectElement + + expect(fileInput.accept).toContain("image/jpeg") + + fireEvent.change(typeSelect, { target: { value: "video" } }) + + expect(fileInput.accept).toContain("video/mp4") + expect(fileInput.accept).not.toContain("image/jpeg") + }) + + it("shows API error message when upload fails", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + json: async () => ({ message: "Invalid file type" }), + } as Response) + + render() + + const form = screen.getByRole("button", { name: "Upload media" }).closest("form") + + if (!form) { + throw new Error("Upload form not found") + } + + const fileInput = screen.getByLabelText("File") as HTMLInputElement + fireEvent.change(fileInput, { + target: { + files: [new File(["x"], "demo.png", { type: "image/png" })], + }, + }) + + fireEvent.submit(form) + + await waitFor(() => { + expect(screen.queryByText("Invalid file type")).not.toBeNull() + }) + + expect(fetchMock).toHaveBeenCalledWith( + "/api/media/upload", + expect.objectContaining({ method: "POST" }), + ) + }) + + it("shows network error message when request throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")) + + render() + + const form = screen.getByRole("button", { name: "Upload media" }).closest("form") + + if (!form) { + throw new Error("Upload form not found") + } + + const fileInput = screen.getByLabelText("File") as HTMLInputElement + fireEvent.change(fileInput, { + target: { + files: [new File(["x"], "demo.png", { type: "image/png" })], + }, + }) + + fireEvent.submit(form) + + await waitFor(() => { + expect(screen.queryByText("Upload request failed. Please retry.")).not.toBeNull() + }) + }) +}) diff --git a/apps/admin/src/lib/auth/server.test.ts b/apps/admin/src/lib/auth/server.test.ts new file mode 100644 index 0000000..d4156f5 --- /dev/null +++ b/apps/admin/src/lib/auth/server.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { mockDb, mockIsAdminSelfRegistrationEnabled, mockAuth, mockAuthRouteHandlers } = vi.hoisted( + () => { + const mockDb = { + user: { + count: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(), + } + + return { + mockDb, + mockIsAdminSelfRegistrationEnabled: vi.fn(), + mockAuth: { + api: { + getSession: vi.fn(), + }, + $context: Promise.resolve({ + internalAdapter: { + findUserByEmail: vi.fn(), + linkAccount: vi.fn(), + createUser: vi.fn(), + }, + password: { + hash: vi.fn(async (value: string) => `hashed:${value}`), + }, + }), + }, + mockAuthRouteHandlers: { + GET: vi.fn(), + POST: vi.fn(), + PATCH: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn(), + }, + } + }, +) + +vi.mock("@cms/db", () => ({ + db: mockDb, + isAdminSelfRegistrationEnabled: mockIsAdminSelfRegistrationEnabled, +})) + +vi.mock("better-auth", () => ({ + betterAuth: vi.fn(() => mockAuth), +})) + +vi.mock("better-auth/adapters/prisma", () => ({ + prismaAdapter: vi.fn(() => ({})), +})) + +vi.mock("better-auth/next-js", () => ({ + toNextJsHandler: vi.fn(() => mockAuthRouteHandlers), +})) + +import { + canDeleteUserAccount, + enforceOwnerInvariant, + promoteFirstRegisteredUserToOwner, +} from "./server" + +describe("auth owner/support invariants", () => { + beforeEach(() => { + mockIsAdminSelfRegistrationEnabled.mockReset() + mockDb.user.count.mockReset() + mockDb.user.findUnique.mockReset() + mockDb.user.findMany.mockReset() + mockDb.user.findFirst.mockReset() + mockDb.user.update.mockReset() + mockDb.user.updateMany.mockReset() + mockDb.$transaction.mockReset() + }) + + it("blocks deletion of protected users", async () => { + mockDb.user.findUnique.mockResolvedValue({ + role: "support", + isProtected: true, + }) + + const allowed = await canDeleteUserAccount("user-protected") + + expect(allowed).toBe(false) + }) + + it("allows deletion of non-owner non-protected users", async () => { + mockDb.user.findUnique.mockResolvedValue({ + role: "editor", + isProtected: false, + }) + + const allowed = await canDeleteUserAccount("user-editor") + + expect(allowed).toBe(true) + }) + + it("keeps sole owner non-deletable", async () => { + mockDb.user.findUnique.mockResolvedValue({ + role: "owner", + isProtected: false, + }) + mockDb.user.count.mockResolvedValue(1) + + const allowed = await canDeleteUserAccount("owner-1") + + expect(allowed).toBe(false) + }) + + it("promotes earliest non-support user when no owner exists", async () => { + const tx = { + user: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue({ id: "candidate-1" }), + update: vi.fn().mockResolvedValue({ id: "candidate-1" }), + updateMany: vi.fn(), + }, + } + + mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) => + callback(tx), + ) + + const result = await enforceOwnerInvariant() + + expect(result).toEqual({ + ownerId: "candidate-1", + ownerCount: 1, + repaired: true, + }) + expect(tx.user.update).toHaveBeenCalledTimes(1) + }) + + it("demotes extra owners and repairs canonical owner protection", async () => { + const tx = { + user: { + findMany: vi.fn().mockResolvedValue([ + { id: "owner-a", isProtected: false, isBanned: true }, + { id: "owner-b", isProtected: true, isBanned: false }, + ]), + findFirst: vi.fn(), + update: vi.fn().mockResolvedValue({ id: "owner-a" }), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + } + + mockDb.$transaction.mockImplementation(async (callback: (trx: typeof tx) => unknown) => + callback(tx), + ) + + const result = await enforceOwnerInvariant() + + expect(result).toEqual({ + ownerId: "owner-a", + ownerCount: 1, + repaired: true, + }) + expect(tx.user.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["owner-b"] } }, + data: { role: "admin", isProtected: false }, + }) + expect(tx.user.update).toHaveBeenCalledWith({ + where: { id: "owner-a" }, + data: { isProtected: true, isBanned: false }, + }) + }) + + it("does not promote first registration when an owner already exists", async () => { + mockDb.$transaction.mockImplementationOnce( + async ( + callback: (tx: { + user: { findFirst: () => Promise<{ id: string }>; update: () => void } + }) => unknown, + ) => + callback({ + user: { + findFirst: vi.fn().mockResolvedValue({ id: "owner-existing" }), + update: vi.fn(), + }, + }), + ) + + const promoted = await promoteFirstRegisteredUserToOwner("candidate") + + expect(promoted).toBe(false) + }) + + it("promotes first registration and re-enforces owner invariant", async () => { + mockDb.$transaction + .mockImplementationOnce( + async ( + callback: (tx: { + user: { findFirst: () => Promise; update: () => Promise<{ id: string }> } + }) => unknown, + ) => + callback({ + user: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue({ id: "candidate" }), + }, + }), + ) + .mockImplementationOnce( + async ( + callback: (tx: { + user: { + findMany: () => Promise< + Array<{ id: string; isProtected: boolean; isBanned: boolean }> + > + findFirst: () => void + update: () => void + updateMany: () => void + } + }) => unknown, + ) => + callback({ + user: { + findMany: vi + .fn() + .mockResolvedValue([{ id: "candidate", isProtected: true, isBanned: false }]), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + }), + ) + + const promoted = await promoteFirstRegisteredUserToOwner("candidate") + + expect(promoted).toBe(true) + expect(mockDb.$transaction).toHaveBeenCalledTimes(2) + }) +})