From 2fa8aa91543bea0d397417d1c475fa43dd065f49 Mon Sep 17 00:00:00 2001 From: Bas van Rossem Date: Wed, 22 Jul 2026 11:39:34 +0200 Subject: [PATCH] =?UTF-8?q?test(api):=20Infra=20(health/db/schema/seed/env?= =?UTF-8?q?/migrate)=20=E2=80=94=20fill=20gaps=20+=20lifecycle=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives all infra src files to 100% (env.ts, db/client.ts, db/ensure-dir.ts, db/migrate.ts, db/seed.ts, routes/health.ts). - env.test.ts: dev defaults when unset, overrides when set (PORT coerced), CORS_ORIGINS parsing (trim + drop empties) and empty-fallback. - db.test.ts: runMigrations idempotency; ensureDbDir no-op for non-file URLs and bare filenames (branch coverage). - seed.test.ts: production guard — no dev users seeded when NODE_ENV=production. - c8-ignore the migrate.ts/seed.ts CLI direct-run blocks (unreachable under vitest). No lifecycle test (infra domain, per plan). No bugs found — infra code correct; only coverage gaps. All 112 API tests green; typecheck + oxlint clean. --- apps/api/src/db/migrate.ts | 2 + apps/api/src/db/seed.ts | 2 + apps/api/test/db.test.ts | 20 +++++++++ apps/api/test/env.test.ts | 60 +++++++++++++++++++++++++ apps/api/test/seed.test.ts | 27 ++++++++++- docs/reference/domain-test-checklist.md | 23 ++++++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 apps/api/test/env.test.ts diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index 5c4e670..44920de 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -15,6 +15,7 @@ export async function runMigrations(): Promise { // Allow running directly: `tsx src/db/migrate.ts` (cross-platform — pathToFileURL // handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not). +/* c8 ignore start -- CLI entry point; unreachable when imported by the test runner */ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { runMigrations() .then(() => { @@ -26,3 +27,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(1); }); } +/* c8 ignore stop */ diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index ed58b8f..296ca40 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -67,6 +67,7 @@ export async function seed(): Promise { // Allow running directly: `tsx src/db/seed.ts` (cross-platform — pathToFileURL // handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not). +/* c8 ignore start -- CLI entry point; unreachable when imported by the test runner */ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { seed() .then(() => { @@ -78,3 +79,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(1); }); } +/* c8 ignore stop */ diff --git a/apps/api/test/db.test.ts b/apps/api/test/db.test.ts index f3d0b82..7875b46 100644 --- a/apps/api/test/db.test.ts +++ b/apps/api/test/db.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import { db } from '../src/db/client'; import { user } from '../src/db/schema'; +import { runMigrations } from '../src/db/migrate'; +import { ensureDbDir } from '../src/db/ensure-dir'; describe('database', () => { it('can query the migrated user table (empty)', async () => { @@ -8,3 +10,21 @@ describe('database', () => { expect(rows).toEqual([]); }); }); + +describe('runMigrations', () => { + it('is idempotent — running again on an already-migrated DB does not throw', async () => { + // setup.ts already migrated the test DB once; re-running must be a no-op. + await expect(runMigrations()).resolves.toBeUndefined(); + await expect(runMigrations()).resolves.toBeUndefined(); + }); +}); + +describe('ensureDbDir', () => { + it('is a no-op for a non-file: URL', () => { + expect(() => ensureDbDir('libsql://remote.example')).not.toThrow(); + }); + + it('does not create a directory for a bare filename in the current dir', () => { + expect(() => ensureDbDir('file:app.db')).not.toThrow(); + }); +}); diff --git a/apps/api/test/env.test.ts b/apps/api/test/env.test.ts new file mode 100644 index 0000000..165aa5b --- /dev/null +++ b/apps/api/test/env.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +// env.ts reads process.env once at module-eval time, so each case re-imports it +// under a controlled environment (resetModules busts the cached module), then +// restores the original process.env immediately so nothing leaks to other tests. +const KEYS = ['DATABASE_URL', 'BETTER_AUTH_SECRET', 'BETTER_AUTH_URL', 'PORT', 'CORS_ORIGINS']; + +async function loadEnv(overrides: Record) { + const saved: Record = {}; + for (const k of KEYS) saved[k] = process.env[k]; + for (const k of KEYS) delete process.env[k]; + for (const [k, v] of Object.entries(overrides)) if (v !== undefined) process.env[k] = v; + vi.resetModules(); + try { + const { env } = await import('../src/env'); + return env; + } finally { + for (const k of KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +describe('env', () => { + afterEach(() => vi.resetModules()); + + it('applies every dev default when the vars are unset', async () => { + const env = await loadEnv({}); + expect(env.DATABASE_URL).toBe('file:./data/app.db'); + expect(env.BETTER_AUTH_SECRET).toBe('dev-insecure-secret-change-me'); + expect(env.BETTER_AUTH_URL).toBe('http://localhost:3000'); + expect(env.PORT).toBe(3000); + expect(env.WEB_ORIGINS).toEqual(['http://localhost:5173', 'http://localhost:5174']); + }); + + it('reads each override when the var is set (PORT coerced to a number)', async () => { + const env = await loadEnv({ + DATABASE_URL: 'file:./other.db', + BETTER_AUTH_SECRET: 'top-secret', + BETTER_AUTH_URL: 'https://api.example.com', + PORT: '8080', + }); + expect(env.DATABASE_URL).toBe('file:./other.db'); + expect(env.BETTER_AUTH_SECRET).toBe('top-secret'); + expect(env.BETTER_AUTH_URL).toBe('https://api.example.com'); + expect(env.PORT).toBe(8080); + expect(typeof env.PORT).toBe('number'); + }); + + it('parses CORS_ORIGINS into WEB_ORIGINS, trimming and dropping empty entries', async () => { + const env = await loadEnv({ CORS_ORIGINS: ' http://a.example , http://b.example , ' }); + expect(env.WEB_ORIGINS).toEqual(['http://a.example', 'http://b.example']); + }); + + it('falls back to the default origins when CORS_ORIGINS is empty', async () => { + const env = await loadEnv({ CORS_ORIGINS: '' }); + expect(env.WEB_ORIGINS).toEqual(['http://localhost:5173', 'http://localhost:5174']); + }); +}); diff --git a/apps/api/test/seed.test.ts b/apps/api/test/seed.test.ts index 392b237..a9e5876 100644 --- a/apps/api/test/seed.test.ts +++ b/apps/api/test/seed.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { inArray, eq } from 'drizzle-orm'; import { seed } from '../src/db/seed'; import { db } from '../src/db/client'; import { activities, user } from '../src/db/schema'; +import { resetDb } from './helpers'; const SEED_NAMES = ['Leerrand', 'Frezen', 'Slijpen', 'Bekleden', 'Afwerken', 'Printen']; @@ -37,3 +38,27 @@ describe('seed', () => { ); }); }); + +describe('seed — production guard', () => { + beforeEach(resetDb); + + it('seeds reference activities but never the dev users when NODE_ENV=production', async () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + await seed(); + } finally { + if (prev === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prev; + } + + const devUsers = await db + .select() + .from(user) + .where(inArray(user.email, ['worker@solelog.local', 'admin@solelog.local'])); + expect(devUsers).toHaveLength(0); + + const acts = await db.select().from(activities).where(inArray(activities.name, SEED_NAMES)); + expect(acts.length).toBe(SEED_NAMES.length); + }); +}); diff --git a/docs/reference/domain-test-checklist.md b/docs/reference/domain-test-checklist.md index 2366079..29ee3dc 100644 --- a/docs/reference/domain-test-checklist.md +++ b/docs/reference/domain-test-checklist.md @@ -17,3 +17,26 @@ Target after the sweep: near-100% per domain; remaining uncovered lines annotate `/* c8 ignore next -- */`, not left silent. + +## Infra (health/db/schema/seed/env/migrate) + +Src covered: `src/routes/health.ts`, `src/db/client.ts`, `src/db/ensure-dir.ts`, +`src/db/migrate.ts`, `src/db/seed.ts`, `src/env.ts` — all at 100% stmts/branch/funcs/lines. +(`src/db/schema.ts` is generated and excluded from coverage by config.) + +Guaranteed behaviors: + +- `GET /health` returns `{ status: 'ok' }`. +- `env.ts` applies every dev default (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, + `PORT`, `WEB_ORIGINS`) when the vars are unset, and reads each override when set (`PORT` + coerced to a number). +- `env.WEB_ORIGINS` parses `CORS_ORIGINS` into a list, trimming whitespace and dropping empty + entries; falls back to the default localhost origins when `CORS_ORIGINS` is empty/unset. +- `runMigrations()` is idempotent — re-running it against an already-migrated DB does not throw. +- `ensureDbDir()` is a no-op for non-`file:` URLs and for a bare filename in the current dir. +- `seed()` seeds the reference activities idempotently and seeds the dev worker + dev admin with + the correct roles, idempotently. +- `seed()` never creates the dev users when `NODE_ENV=production` (reference activities still + seeded). +- The `migrate.ts` / `seed.ts` CLI direct-run blocks are `c8 ignore`d — unreachable under the + test runner (exercised only via `tsx src/db/{migrate,seed}.ts`).