- Vitest multi-project setup (unit / integration / component) - Real-Postgres integration harness via in-process PGlite (TEST_DATABASE_URL override), migrations applied per worker; production code untouched - Unit: routing, locale, validation/Zod schemas, metadata, mail, site-theme, marquee, media, portfolio helpers, plus architecture-rule tests - Integration: Prisma data layer, API routes, and all server actions - Component: UI primitives and form components (jsdom + Testing Library) - 371 tests passing
11 KiB
Test Plan — mohfarawati.de
Comprehensive automated test coverage for the multilingual Next.js portfolio + admin workspace. Focus: code correctness. Scope excludes Playwright / browser E2E (per request).
1. Goals & principles
- Cover every application module: pure helpers, validation/schemas, data layer (Prisma), server actions, API routes, middleware, forms, components, and architecture rules.
- Do not change production behaviour. Tests observe the code as-is. If a test reveals a real bug or an architecture-rule violation, it is flagged for the owner — production code is not changed without approval.
- Deterministic and self-contained: no external network, no reliance on a running app server.
- Real Postgres for the data layer (not mocks). See §3.
2. Test taxonomy & runner layout
Vitest with three projects (isolated environments), selected by file location:
| Project | Env | Location | Parallel | Purpose |
|---|---|---|---|---|
unit |
node | tests/unit/** |
yes | Pure functions, schemas, formatting, mapping logic |
integration |
node | tests/integration/** |
serial | Prisma data layer, server actions, API routes, middleware |
component |
jsdom | tests/component/** |
yes | React components & forms (RTL) |
Legacy flat tests/*.test.ts files are folded into the new structure (kept passing).
Tooling added (dev-only)
@testing-library/react,@testing-library/dom,@testing-library/jest-dom,@testing-library/user-event,jsdom,@vitejs/plugin-react— component tests.@electric-sql/pglite,pglite-prisma-adapter— an embedded real Postgres (Postgres compiled to WASM) that runs the project's actual migrations and Prisma queries in-process, via a Prisma driver adapter.
Database strategy (real Postgres)
tests/helpers/integration-setup.ts + tests/helpers/global-db-setup.ts:
- If
TEST_DATABASE_URLis set → the reallib/prismasingleton is used unchanged, pointed at that Postgres (e.g. the Docker instance). The global setup resets the schema and applies everyprisma/migrations/*/migration.sqlonce before the run. - Otherwise →
lib/prismais mocked (test-only) with a Prisma client backed by an in-process PGlite database. Each worker gets its own isolated database with the migrations applied — real Postgres semantics, no external server, no shared-state races. Production code is never modified. resetDb()(TRUNCATE all tables, restart identities) runs inbeforeEach.- The integration project runs serially (
fileParallelism: false); each file gets a fresh database connection.
Next.js runtime mocks (integration)
Server actions/middleware depend on the Next runtime. tests/helpers/next-mocks.ts provides:
next/navigation→redirect()throws a catchableNEXT_REDIRECTcarrying the URL.next/dist/client/components/redirect-error→isRedirectError()recognises the above.next/cache→revalidatePath()spy (no-op, asserted).next/headers→ controllablecookies()/headers()stores.@/lib/admin-authisAdminAuthenticated→ toggled per test (auth guard tests).nodemailer→ captured transport (no real SMTP).
3. Coverage matrix
3.1 Unit — pure lib
| Module | Cases |
|---|---|
admin-routing |
host resolution (forwarded/comma/port), isAdminHost, hasDedicatedAdminHost, legacy/dev/internal path predicates, toInternalAdminPath, fromDevelopmentAdminPath, getAdminAppPath dev vs prod, buildAdminUrl/buildSiteUrl, env overrides, normalization edge cases |
admin-feedback |
withFlash (success/error/both/none, encoding), readFlash |
admin-navigation |
tree shape, active/expanded flags for each section, portfolio child mapping, href de-dup filter |
form-data |
isCheckedFormValue truthy/falsey set |
locale |
isSupportedLocale, resolveLocale, getDirection (rtl for ar), stripLocalePrefix, getLocalizedPath(WithDefault) incl. prefix stripping/rebuilding |
utils |
cn merge/dedupe/conditional |
site-theme |
buildSiteThemeTokens (hex→hsl, derive dark/secondary, clamps), buildSiteThemeStyleText structure, invalid hex fallback |
marquee-settings |
defaults, parseMarqueeSettingsValue (invalid json, partial, trims), syncMarqueeSettingsToGermanSource, splitMarqueeRowItems (newline/comma/blank) |
site-settings |
normalizeSiteDefaultLocale, normalizeSitePrimaryColor, buildDefaultSiteSettings, parseSiteSettingsValue (merge, invalid json, legacy title, invalid locale/color) |
site-icons |
buildSiteIconUrls (version, relative vs absolute favicon url, name fallback), buildSiteIconResponse transparent fallback for non-managed paths |
media-storage (pure) |
sanitizeBaseName, getExtensionForMimeType (all mimes + unknown), isManagedMediaFilePath, resolveMediaUploadPath (root confinement + traversal guard) |
media-validation |
mediaFieldInputSchema: library needs assetId, external needs url, url format rule, upload mode, kind enum, trimming |
media-service (pure) |
inferMediaKindFromMimeType, inferMediaKindFromFileName, getKindFromUploadFile |
portfolio (pure) |
resolvePortfolioProjectViewMode, getLocalizedValue (direct/fallback/any) |
portfolio-form-progress |
slug/year/sortOrder validators, section readiness per type, asset readiness, wizard progress, first incomplete step |
portfolio-validation |
category/section/asset/project schemas: required fields, slug regex, coercions, view modes, section superRefine per type, media refinements, url rules |
metadata |
applyTitleTemplateFn, buildLocaleAlternates, buildAppMetadataFromConfig, buildLocalizedMetadataFromConfig (title template skip, description fallback, og/twitter, icons) |
mail |
createSmtpTransport (required host/user/pass errors, port/secure), sendMail (from with/without name), sendContactMessage (recipient + fallback, body fields), sendTestEmail (recipient fallback, transport reject) |
3.2 Integration — data layer (real DB)
| Module | Cases |
|---|---|
app-config |
maintenance get/set, site settings get (fallback name from siteName key) / update roundtrip, mail settings get/update, marquee get/update (german sync), getSiteSettingsMediaBindings (mediaUsage → bindings per field) |
media |
create asset, get by id (+usages), list, getMediaOptions kind filter, replaceEntityMediaUsages (transactional replace, unique constraint), deleteEntityMediaUsages, getPortfolioMediaBindings routing by usageType, countMediaUsageReferences |
portfolio (queries) |
admin categories (+project counts), active categories/by-slug, admin projects (status/category filters + ordering), published projects/by-slug, by-id with media bindings, localized mapping, onDelete Restrict/Cascade behaviour |
media-service.resolveMediaSelection |
library (found/missing), external (creates asset, filename from url), missing+required error, not-required empty |
admin-auth (lockout) |
registerFailedAdminAttempt increments & locks at threshold, getAdminLockState, resetAdminFailedAttempts, IP hashing via mocked headers; token isPasswordValid/verify with env |
3.3 Integration — API routes & middleware
| Target | Cases |
|---|---|
GET /api/health |
200 + database: up; 503 + database: down when query throws |
GET /api/site/default-locale |
returns runtime defaultLocale + maintenanceEnabled, no-store header |
proxy (middleware) |
runtime default-locale passthrough, safe fallback on fetch failure, maintenance redirect, SITE_RUNTIME_ORIGIN, admin host rewrite → internal, dev /root handling, legacy 404 in prod, internal path 404 for non-admin in prod, basic-auth challenge/valid |
3.4 Integration — server actions
| Action file | Cases |
|---|---|
contact/actions |
valid → sends mail + redirect /success; invalid (short name/bad email/short message) → redirect with error; locale resolution |
maintenance/actions |
unauth → redirect to admin root; enable/disable toggles config + revalidates + success flash |
marquee/actions |
unauth guard; empty german rows throw per-row error → error flash; valid → saves (german-synced) + success flash |
smtp/actions |
parseMailSettingsFormData (port parse error, password retention when blank), save → success; sendTestEmailAction success + failure |
site-settings/actions |
brand save (primary color normalize, media selection + usage wiring, cleanup on error), localization save (siteName required, title template must contain {pageTitle}), parseJsonObject guard |
media/actions |
create (kind image/document, missing file error), delete (not found, in-use guard, managed-file removal) |
portfolio/actions |
upsertCategoryAction create/update + P2002 unique message; deleteCategoryAction blocks when projects exist; saveProjectAction create + update, sections/assets replace, publishedAt first-publish logic, media usage wiring, validation + error cleanup of created media; deleteProjectAction not-found + cascade + usage cleanup |
3.5 Component (jsdom + RTL)
Global mocks: framer-motion, gsap, next/link, next/image, next-intl, next/navigation.
| Component | Cases |
|---|---|
ui/badge |
variant classes, custom className merge, passthrough props |
ui/input |
renders, forwardRef, type/placeholder/disabled, className merge |
ui/textarea, ui/label, ui/card, ui/app-card, ui/separator, ui/table |
render, props, ref, composition |
admin/admin-flash |
null when empty, success role=status, error role=alert, both, className |
admin/marquee-settings-form |
renders 4 rows, default values, submit wiring to action |
site/portfolio-category-filter |
"all" link + per-category links, active state, localized labels, hrefs |
dashboard/dashboard-card, dashboard/stats-card |
presentational render, props |
layout/container, layout/hero-badge, home/section-heading, home/bento-card |
presentational render, children, className |
(*multi-part primitives tested for sub-component composition.)
3.6 Architecture-rule tests (tests/integration/architecture)
Enforced from CLAUDE.md:
- No Prisma in client components — no file containing
"use client"importslib/prisma. - Business logic out of UI — components don't import server-only data modules directly
(allow-list of pure
lib/*view/format helpers). - Server actions are guarded — every exported action in
app/**/actions.tscalls an auth guard (ensureAdmin/requireAdminAuth) except the public contact action. "use server"directive — everyactions.tsbegins with"use server".lib/*does not import fromapp/— dependency direction.- Admin mirror parity — every
page.tsxunderapp/_admin/**has matching re-export stubs underapp/root/**andapp/admin-internal/**pointing back to_admin. - No browser storage in components — no
localStorage/sessionStorageusage.
4. Deliverables & running
npm test # all projects
npx vitest run --project unit
npx vitest run --project integration
npx vitest run --project component
TEST_DATABASE_URL=postgres://… # optional: run integration against real Postgres
Findings (real bugs / rule violations) are reported to the owner; production code is only changed after approval.