diff --git a/CLAUDE.md b/CLAUDE.md index ca0f7ef..1a00f82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,4 +4,4 @@ The imported guidelines are binding. Two always-on rules: - Full-mode infra (`infra:up` + `test:full`) and Stryker mutation testing are local-only — never wire them into CI. -- Pre-PR ritual: `npm run test:mutation` (scope with `STRYKER_MUTATE` to changed files) and report surviving mutants in the PR body. +- Mutation testing is an **occasional, targeted audit — not a per-PR gate**. Run it deliberately when you've reworked a file's logic: scope with `STRYKER_MUTATE` to that one file, `--concurrency 2`, and verify a kill by hand-applying the mutation + running the plain suite (see the guidelines' Mutation testing section). diff --git a/GUIDELINES_NEST_REFERENCE_APP.md b/GUIDELINES_NEST_REFERENCE_APP.md index 424c309..71c8797 100644 --- a/GUIDELINES_NEST_REFERENCE_APP.md +++ b/GUIDELINES_NEST_REFERENCE_APP.md @@ -137,7 +137,11 @@ npm run infra:down # removes the broker container and volume in-process integration specs. `test:full` keeps the two halves isolated for you. -### Mutation testing (Stryker — local only, never in CI) +### Mutation testing (Stryker — occasional targeted audit, local only, never in CI) + +Run it **deliberately, scoped to a file whose logic you reworked** — not on +every PR (full-source mutation is a non-goal here, per Rule 3). The useful +signal is *which mutants survive* on the file you touched. ```bash npm run test:mutation # incremental run (cache: reports/stryker-incremental.json) @@ -155,14 +159,22 @@ npm run test:mutation:full # every mutant from scratch (--force) plus `--test-force-exit`) so a mutant that breaks teardown dies instead of hanging into a timeout. +**Verify a kill without re-running Stryker — the fast path.** Hand-apply the +surviving mutation to the source, run the plain suite (or just the one spec), +confirm your new test fails, then `git checkout --` to revert. This decouples +the slow "find survivors" step from a fast "prove the kill" step. If a run times +out, `kill -9` any leftover `stryker` processes before retrying — a killed run +can leave detached test processes that starve the next one. + ### AI agents working on this repo - When Docker is available, run `npm run infra:up && npm run test:full` before opening a PR that touches `src/`, and report the result (including the live-Kafka spec) in the PR body. When Docker is not available, run `npm test` and state that the live-Kafka spec was skipped. -- Pre-PR ritual for `src/` changes: `npm run test:mutation` (scope with - `STRYKER_MUTATE` when the change is small), look at surviving mutants, and - mention the outcome in the PR body. +- Mutation testing is an **occasional, targeted audit — not a per-PR gate**. + When you've reworked a file's logic, scope `STRYKER_MUTATE` to it and verify + kills by hand-applying the mutation + running the suite; note anything found + in the PR body. Do not run it routinely. - Never wire any of this into CI — CI stays fast and Docker-free, and forks are unaffected. diff --git a/test/integration/env.spec.ts b/test/integration/env.spec.ts new file mode 100644 index 0000000..06b6dd8 --- /dev/null +++ b/test/integration/env.spec.ts @@ -0,0 +1,86 @@ +import { strict as assert } from 'node:assert'; +import { afterEach, beforeEach, describe, test } from 'node:test'; +import { loadEnv } from '../../src/config/env'; + +// The env parsers (readPort / readIntFromEnv / readNonNegativeIntFromEnv) are +// the app's config-correctness logic: each has a fallback, a NaN reject, a +// range/sign reject, and a happy path. loadEnv reads process.env, so each case +// sets one variable and restores it afterward. (Scoped, on-demand mutation +// audit territory per the guidelines — these branches are worth pinning; the +// rest of src/ is deliberately pragmatic.) +const KEYS = [ + 'OUTBOX_POLL_MS', + 'TASK_REMINDER_DELAY_MS', + 'PORT', + 'AUTH_SECRET', + 'NODE_ENV', + 'KAFKA_BROKERS', +]; + +describe('loadEnv parsing', () => { + let saved: Record; + + beforeEach(() => { + saved = {}; + for (const key of KEYS) saved[key] = process.env[key]; + process.env.AUTH_SECRET = 'env-spec-secret-at-least-32-characters-xxxx'; + delete process.env.NODE_ENV; // resolves to 'development' + delete process.env.KAFKA_BROKERS; // stay in-process + }); + + afterEach(() => { + for (const key of KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + test('readIntFromEnv: fallback when unset, parses positive, rejects NaN and non-positive', () => { + delete process.env.OUTBOX_POLL_MS; + assert.equal(loadEnv().outbox.pollIntervalMs, 2_000); + + process.env.OUTBOX_POLL_MS = '500'; + assert.equal(loadEnv().outbox.pollIntervalMs, 500); + + process.env.OUTBOX_POLL_MS = 'not-a-number'; + assert.throws(() => loadEnv(), /Invalid OUTBOX_POLL_MS/); + + // Zero and negatives are invalid for a poll interval (readIntFromEnv rejects + // `<= 0`) — distinct from the reminder delay below, where zero is valid. + process.env.OUTBOX_POLL_MS = '0'; + assert.throws(() => loadEnv(), /Invalid OUTBOX_POLL_MS/); + process.env.OUTBOX_POLL_MS = '-5'; + assert.throws(() => loadEnv(), /Invalid OUTBOX_POLL_MS/); + }); + + test('readNonNegativeIntFromEnv: zero is valid (due immediately), negatives and NaN are not', () => { + delete process.env.TASK_REMINDER_DELAY_MS; + assert.equal(loadEnv().taskReminderDelayMs, 60_000); + + process.env.TASK_REMINDER_DELAY_MS = '0'; + assert.equal(loadEnv().taskReminderDelayMs, 0); + + process.env.TASK_REMINDER_DELAY_MS = '250'; + assert.equal(loadEnv().taskReminderDelayMs, 250); + + process.env.TASK_REMINDER_DELAY_MS = '-1'; + assert.throws(() => loadEnv(), /Invalid TASK_REMINDER_DELAY_MS/); + process.env.TASK_REMINDER_DELAY_MS = 'abc'; + assert.throws(() => loadEnv(), /Invalid TASK_REMINDER_DELAY_MS/); + }); + + test('readPort: defaults to 3000, parses a valid port, rejects NaN and out-of-range', () => { + delete process.env.PORT; + assert.equal(loadEnv().port, 3000); + + process.env.PORT = '8080'; + assert.equal(loadEnv().port, 8080); + + process.env.PORT = 'abc'; + assert.throws(() => loadEnv(), /Invalid PORT/); + process.env.PORT = '70000'; // > 65535 + assert.throws(() => loadEnv(), /Invalid PORT/); + process.env.PORT = '-1'; + assert.throws(() => loadEnv(), /Invalid PORT/); + }); +});