---
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 '<appUser>'@'%'` 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
