Files
familysync/docs/TESTING.md
T
2026-06-18 21:06:32 -04:00

262 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# Testing
## Test framework and setup
Both apps use **Vitest** (`^4.1.8`).
| App | Environment | Global setup | Per-file setup |
| ---------- | ----------- | ------------------------------- | ---------------------------- |
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` |
**apps/api global setup** (`test/global-setup.ts`) runs once before any test file. Locally it provisions an isolated `familysync_test` database (root connection → `CREATE DATABASE IF NOT EXISTS familysync_test` → GRANT → `drizzle migrate`) and then truncates every table to give each run a clean slate. Under CI (`process.env.CI` truthy) it returns immediately — the CI `api` job provisions its own `familysync` service container via `db:migrate`.
**apps/api per-file setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, `lists`, and `local_credentials` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. The `users` table is intentionally left intact across tests within a single run — many tests seed user id=1 once and reuse it. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB.
**apps/pwa setup** (`src/test-setup.ts`) imports `@testing-library/jest-dom` for extended matchers and polyfills `window.matchMedia` for jsdom (required because Zustand's `calendarStore` calls `window.matchMedia` at module initialisation time). The timezone is pinned to `UTC` via `env: { TZ: 'UTC' }` so date-extraction assertions are deterministic across developer machines and CI.
No additional install step is needed beyond the normal `pnpm install` at the repo root.
## Running tests
### Unit and integration tests
**All API tests (from repo root):**
```bash
pnpm --filter @familysync/api test
```
This is also the command run by `pnpm test` at the root.
**All PWA unit tests:**
```bash
pnpm --filter @familysync/pwa test
```
**Watch mode (API):**
```bash
pnpm --filter @familysync/api test:watch
```
**Single test file:**
```bash
pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
```
### End-to-end tests (Playwright)
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with three device profiles:
| Profile | Viewport | Engine | User-Agent |
| --------- | -------- | -------- | ------------------------- |
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
| `desktop` | 1280×720 | Chromium | Desktop Chrome |
All profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state.
**Run all e2e tests (all profiles):**
```bash
pnpm test:e2e
# or
pnpm --filter @familysync/pwa test:e2e
```
**Run a single profile:**
```bash
pnpm --filter @familysync/pwa exec playwright test --project=pixel
pnpm --filter @familysync/pwa exec playwright test --project=iphone
pnpm --filter @familysync/pwa exec playwright test --project=desktop
```
**Interactive UI mode:**
```bash
pnpm --filter @familysync/pwa test:e2e:ui
```
**Headed mode (for local debugging):**
```bash
pnpm --filter @familysync/pwa test:e2e:headed
```
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API and MariaDB must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
### Type checking (separate from tests — required)
Vitest uses esbuild, which strips TypeScript types at runtime. A test run can pass while `tsc` reports errors. Always run type checks separately:
```bash
pnpm typecheck # runs tsc --noEmit across both apps
pnpm --filter @familysync/api typecheck
pnpm --filter @familysync/pwa typecheck
```
The PWA typecheck also covers the e2e spec files: `tsc --project tsconfig.e2e.json --noEmit`.
## Quality gate
The full local quality gate before opening a PR:
```bash
pnpm lint && pnpm format:check && pnpm typecheck && pnpm test
```
Add e2e when changing PWA behaviour:
```bash
pnpm test:e2e
```
| Step | Command | What it checks |
| ---------------- | ------------------------------------ | -------------------------------------------------------- |
| Lint | `pnpm lint` | ESLint `--max-warnings 0` across both apps (type-aware) |
| Format check | `pnpm format:check` | Prettier — fails on any unformatted file |
| Markdown lint | `pnpm md:lint` | markdownlint-cli2 across all `.md` files |
| Typecheck | `pnpm typecheck` | `tsc --noEmit` across both apps (including e2e tsconfig) |
| Unit / API tests | `pnpm test` | API integration tests via Vitest |
| PWA unit tests | `pnpm --filter @familysync/pwa test` | Component and logic tests in jsdom |
| E2E | `pnpm test:e2e` | Playwright iphone + pixel + desktop profiles |
A deliberate ESLint violation makes `pnpm lint` exit non-zero; a formatting deviation makes `pnpm format:check` exit non-zero. Both block the PR in CI.
To auto-fix formatting locally:
```bash
pnpm format # prettier --write .
```
## Integration tests requiring a real database
Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to a real MariaDB instance. Locally, the Vitest global setup (`test/global-setup.ts`) auto-provisions and migrates the `familysync_test` database — there is no need to manually set `DB_NAME`. The dev `familysync` database is never touched by the test suite.
**Start the dev stack:**
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
```
**Set environment variables, then run:**
```bash
set -a; . ./.env; set +a
export DB_HOST=127.0.0.1 DB_PORT=3306
pnpm --filter @familysync/api test
```
The global setup requires root access to create and grant the test database. By default it reads `DB_ROOT_PASSWORD` from the environment (defaults to `root` to match the dev Docker Compose). The app user (`DB_USER`) is validated against `/^[A-Za-z0-9_]+$/` before the GRANT statement is interpolated.
DB-backed tests that require this setup include:
- `apps/api/tests/lib/listAccess.test.ts``getAccessibleListIds` access-scope queries
- `apps/api/tests/lib/listChangeDispatcher.test.ts` — list change dispatcher with real DB rows
- `apps/api/tests/routes/lists.test.ts` — full lists API router (creates/deletes real rows)
Pure-logic tests (e.g. `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/lib/rank.test.ts`) do not require DB — the `afterEach` cleanup is a no-op when tables are empty or no DB connection is available.
## Writing new tests
### File naming and location
| App | Convention | Example |
| ---------- | ------------------------------------- | ------------------------------------ |
| `apps/api` | `apps/api/tests/{category}/*.test.ts` | `apps/api/tests/routes/push.test.ts` |
| `apps/pwa` | co-located `*.test.ts` / `*.test.tsx` | `src/components/AppNav.test.tsx` |
| `apps/pwa` | e2e specs | `e2e/*.spec.ts` |
Test categories for `apps/api`:
- `apps/api/tests/auth/` — authentication middleware, session handling, local auth, admin guards, and bypass behaviour
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch, crypto utilities, and VEVENT parsing
- `apps/api/tests/lib/` — pure library functions and service logic (list access, rank, push coalescer/dispatcher, SSE emitter, timezone, boot guards)
- `apps/api/tests/routes/` — HTTP route integration tests (events, lists, push, admin, login, me, local auth, setup)
- `apps/api/tests/health.test.ts` — health check endpoint
- `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers
### Test helpers
- `apps/api/tests/helpers/db.ts``createMockDb()` returns a Vitest mock of the Drizzle `db` singleton; also exports sample VEVENT strings (`SAMPLE_VEVENT_TIMED`, `SAMPLE_VEVENT_ALLDAY`, `SAMPLE_VEVENT_RECURRING_TIMED`, `SAMPLE_VEVENT_RECURRING_ALLDAY`) for broker tests.
- `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`absolute-alarm.ics`, `allday-birthday.ics`, `exdate-series.ics`, `multi-alarm.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
- `apps/api/tests/fixtures/vapid.ts` — VAPID key fixture for push tests.
- `apps/pwa/src/test-setup.ts` — Provides `matchMedia` polyfill and jest-dom matchers for all PWA tests automatically via `setupFiles`.
For PWA component tests, use `@testing-library/react` (`^16.3.0`) render helpers. Import from `vitest` for assertions — `@testing-library/jest-dom` matchers are available globally via the setup file.
## Coverage requirements
No coverage thresholds are configured in either `vitest.config.ts`. There is no minimum coverage enforcement in CI.
## CI integration
CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). A `changes` job using `dorny/paths-filter@v4` determines whether the PR touches code (as opposed to docs or planning files only). The `api` and `harness` jobs are skipped for doc-only PRs.
Five jobs run in total — `fast-checks` and `security` always run; `api`, `harness`, and `changes` run conditionally.
### `fast-checks`
Runs lint, format check, markdown lint, typecheck, and PWA unit tests — no external services required. Always runs regardless of the `changes` filter.
| Step | Command |
| -------------- | ------------------------------------ |
| Lint | `pnpm lint` |
| Format check | `pnpm format:check` |
| Markdown lint | `pnpm md:lint` |
| Typecheck | `pnpm typecheck` |
| PWA unit tests | `pnpm --filter @familysync/pwa test` |
### `api`
Runs the full API test suite against a `mariadb:11` service container. Skipped for doc-only PRs.
| Step | Detail |
| ----------------- | ---------------------------------------------------------- |
| MariaDB service | `mariadb:11` container; `DB_HOST=mariadb`, `DB_PORT=3306` |
| Readiness poll | Node script via `mysql2` driver (no `mysql` CLI in runner) |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| Tests | `pnpm --filter @familysync/api test` |
The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are scoped to the ephemeral CI container and are never production secrets.
`actions/cache@v4` is intentionally omitted — the cache server times out on this runner (socket hang-up). `pnpm install` without cache takes ~30 s and is acceptable.
### `harness`
Runs the Playwright mobile and desktop e2e harness (iphone + pixel + desktop) against a runner-hosted dev stack. Skipped for doc-only PRs.
| Step | Detail |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MariaDB service | Same `mariadb:11` setup as the `api` job |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` |
| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement |
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` |
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) |
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) |
The API process is started and the Playwright suite invoked within a single CI step. Starting the API in an earlier step causes it to be reaped at the step boundary before Playwright runs.
### `security`
Runs secret scanning and dependency audits. Always runs regardless of the `changes` filter (secrets can appear in doc-only commits). Dependency audit and outdated checks run only when code changes are detected.
| Step | Tool/Command | Detail |
| ---------------- | --------------------------------- | ------------------------------------------------------ |
| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding |
| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities |
| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates |
### `gate`
A required final job that checks all other jobs passed or were legitimately skipped. `fast-checks` and `security` must succeed; `api` and `harness` may be skipped (doc-only PRs) but not failed.