test(api): Infra (health/db/schema/seed/env/migrate) — fill gaps + lifecycle test
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.
This commit is contained in:
@@ -15,6 +15,7 @@ export async function runMigrations(): Promise<void> {
|
|||||||
|
|
||||||
// Allow running directly: `tsx src/db/migrate.ts` (cross-platform — pathToFileURL
|
// Allow running directly: `tsx src/db/migrate.ts` (cross-platform — pathToFileURL
|
||||||
// handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not).
|
// 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) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
runMigrations()
|
runMigrations()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -26,3 +27,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export async function seed(): Promise<void> {
|
|||||||
|
|
||||||
// Allow running directly: `tsx src/db/seed.ts` (cross-platform — pathToFileURL
|
// Allow running directly: `tsx src/db/seed.ts` (cross-platform — pathToFileURL
|
||||||
// handles Windows drive-letter/backslash paths, which a raw `file://` prefix does not).
|
// 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) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
seed()
|
seed()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -78,3 +79,4 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { db } from '../src/db/client';
|
import { db } from '../src/db/client';
|
||||||
import { user } from '../src/db/schema';
|
import { user } from '../src/db/schema';
|
||||||
|
import { runMigrations } from '../src/db/migrate';
|
||||||
|
import { ensureDbDir } from '../src/db/ensure-dir';
|
||||||
|
|
||||||
describe('database', () => {
|
describe('database', () => {
|
||||||
it('can query the migrated user table (empty)', async () => {
|
it('can query the migrated user table (empty)', async () => {
|
||||||
@@ -8,3 +10,21 @@ describe('database', () => {
|
|||||||
expect(rows).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
60
apps/api/test/env.test.ts
Normal file
60
apps/api/test/env.test.ts
Normal file
@@ -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<string, string | undefined>) {
|
||||||
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { inArray, eq } from 'drizzle-orm';
|
import { inArray, eq } from 'drizzle-orm';
|
||||||
import { seed } from '../src/db/seed';
|
import { seed } from '../src/db/seed';
|
||||||
import { db } from '../src/db/client';
|
import { db } from '../src/db/client';
|
||||||
import { activities, user } from '../src/db/schema';
|
import { activities, user } from '../src/db/schema';
|
||||||
|
import { resetDb } from './helpers';
|
||||||
|
|
||||||
const SEED_NAMES = ['Leerrand', 'Frezen', 'Slijpen', 'Bekleden', 'Afwerken', 'Printen'];
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,3 +17,26 @@ Target after the sweep: near-100% per domain; remaining uncovered lines annotate
|
|||||||
`/* c8 ignore next -- <reason> */`, not left silent.
|
`/* c8 ignore next -- <reason> */`, not left silent.
|
||||||
|
|
||||||
<!-- Phase B appends one "## <domain>" section per domain below. -->
|
<!-- Phase B appends one "## <domain>" section per domain below. -->
|
||||||
|
|
||||||
|
## 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`).
|
||||||
|
|||||||
Reference in New Issue
Block a user