--- phase: quick-260613-ndv plan: "01" subsystem: api/testing tags: [test-isolation, mariadb, drizzle, vitest, globalSetup] dependency_graph: requires: [] provides: [familysync_test DB provisioning, local test isolation] affects: [apps/api test suite, CI api job (unaffected — CI-gated)] tech_stack: added: [] patterns: [vitest globalSetup, drizzle-orm/mysql2 two-arg form] key_files: created: - apps/api/test/global-setup.ts modified: - apps/api/vitest.config.ts - apps/api/test/setup.ts - apps/api/README.md decisions: - "D-ndv-pool-form: use drizzle(pool, { mode }) not drizzle({ client: pool, mode }) — drizzle-orm@0.45.2 isConfig() has a tautological OR in the mode branch that always returns false; combined-config form falls through to construct(configObj, undefined) making the config object itself the session client" - "D-ndv-users-intact: do not delete users in afterEach — tests seed user id=1 once and reuse across test files; users starts empty in familysync_test at run start; per-test user leaks scoped to those tests' own setup" metrics: duration: "~15 min" completed: "2026-06-13" tasks_completed: 2 files_changed: 4 --- # Quick Task 260613-ndv: Test DB Isolation Summary **One-liner:** vitest globalSetup provisions + migrates `familysync_test` via root MariaDB connection (CI-gated no-op); `test.env` forces workers to `DB_NAME=familysync_test`; 244 tests pass without touching the dev DB. ## Tasks Completed | Task | Name | Commit | Key Files | |------|------|--------|-----------| | 1 | Add CI-gated globalSetup (provision + migrate familysync_test) | 8453b97 | apps/api/test/global-setup.ts (new), apps/api/vitest.config.ts | | 2 | Clean-slate comment in setup.ts + README local-test docs | 4740d86 | apps/api/test/setup.ts, apps/api/README.md, apps/api/test/global-setup.ts (bug fix) | ## What Was Built ### apps/api/test/global-setup.ts (new, 108 lines) Vitest `globalSetup` that runs once in the main process before any test file: - **CI gate:** `if (process.env.CI) return` — CI provisions its own `familysync` service DB via `db:migrate`, completely unaffected. - **Local flow:** 1. Root connection (`DB_ROOT_USER`/`DB_ROOT_PASSWORD` with dev defaults `root`/`root`) → `CREATE DATABASE IF NOT EXISTS familysync_test` 2. `GRANT ALL PRIVILEGES ON familysync_test.* TO ''@'%'` with `appUser` validated against `/^[A-Za-z0-9_]+$/` (T-ndv-03); falls back to `@'localhost'` grant if `@'%'` fails. 3. `FLUSH PRIVILEGES`, close root connection. 4. App-user pool → `drizzle(pool, { mode: 'default' })` → `migrate(db, { migrationsFolder })` applies committed SQL from `apps/api/src/db/migrations/`. 5. Logs `[global-setup] provisioned + migrated familysync_test`. ### apps/api/vitest.config.ts (modified) - Added `globalSetup: ['./test/global-setup.ts']` - Added CI-gated `test.env`: locally sets `DB_NAME=familysync_test` and `DB_HOST=127.0.0.1`; under `CI` the env override is `{}` so job-level `DB_NAME=familysync` / `DB_HOST=mariadb` are preserved. - Retained `fileParallelism: false` and `setupFiles: ['./test/setup.ts']`. ### apps/api/test/setup.ts (modified) Updated file header to document: - Tests now run against `familysync_test` (not dev `familysync`) - `afterEach` truncates list/push tables only; `users` is intentionally left intact within a run - Rationale for the `users` decision (no per-test deletion — tests seed id=1 once and reuse it) ### apps/api/README.md (modified) Added `## Running API tests locally` section documenting: - Dev MariaDB prerequisite + run command (`set -a; source .env; set +a; DB_HOST=127.0.0.1 pnpm --filter @familysync/api test`) - `DB_ROOT_PASSWORD` requirement in `.env` for one-time provisioning - CI unaffected note ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] drizzle-orm@0.45.2 isConfig() mode branch tautology** - **Found during:** Task 1 verification (first test run) - **Issue:** `drizzle({ client: pool, mode: 'default' })` — the `{ client, mode }` combined config form — triggers a bug in `drizzle-orm@0.45.2/utils.js` `isConfig()`. The `mode` branch has: `if (data["mode"] !== "default" || data["mode"] !== "planetscale" || ...)` which is a tautological OR (always true for any mode value), so `isConfig` returns `false`. The call falls through to `construct(configObj, undefined)`, making the config object itself the session client. `client.query is not a function`. - **Fix:** Switched to `drizzle(pool, { mode: 'default' })` (two-arg form, which hits the `construct(params[0], params[1])` branch directly — correct behavior). Added comment in the code explaining the drizzle-orm bug. - **Files modified:** `apps/api/test/global-setup.ts` - **Commit:** 4740d86 ## Verification Results | Check | Result | |-------|--------| | `grep -q "globalSetup" apps/api/vitest.config.ts` | PASS | | `grep -q "familysync_test" apps/api/vitest.config.ts` | PASS | | `grep -q "process.env.CI" apps/api/test/global-setup.ts` | PASS | | `grep -q "migrate(" apps/api/test/global-setup.ts` | PASS | | `pnpm --filter @familysync/api typecheck` | PASS (exit 0) | | Full local test run (244 tests, 25 files) | PASS | | Dev `familysync` users count before=3, after=3 | PASS | | `familysync_test` tables after run | PASS (10 tables: all schema tables + __drizzle_migrations) | | CI code inspection: globalSetup early-returns, env override is {} | PASS (confirmed by code) | ## Known Stubs None. ## Threat Flags None — no new network endpoints, auth paths, or file access patterns introduced. The root DB credential usage is scoped exclusively to the local non-CI branch of globalSetup and reads from env (T-ndv-01 mitigated as designed). ## Self-Check: PASSED - `apps/api/test/global-setup.ts` exists: confirmed - `apps/api/vitest.config.ts` updated: confirmed - `apps/api/test/setup.ts` updated: confirmed - `apps/api/README.md` updated: confirmed - Commit 8453b97 exists: confirmed (Task 1) - Commit 4740d86 exists: confirmed (Task 2) - 244/244 tests pass against familysync_test: confirmed - Dev DB users count unchanged (3 before, 3 after): confirmed