Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
20 changes: 16 additions & 4 deletions GUIDELINES_NEST_REFERENCE_APP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
86 changes: 86 additions & 0 deletions test/integration/env.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

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/);
});
});