diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1b4a957b..0236439b1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -280,6 +280,8 @@ jobs: # A source file may hold no more lines than scripts/size-baseline.json # allows (1000 for a file without an entry, long lines counted # wrapped), so a big service is split by concern instead of growing. + # Files under tests/ are held the same way at 2000 lines against + # scripts/test-size-baseline.json. run: cd server && npm run lint:size - name: Shared ratchet code @@ -311,6 +313,18 @@ jobs: # through tests/helpers/factories (MikroORM) instead. run: cd server && npm run lint:test-sql + - name: Module mocks of config, realtime and db in tests may only shrink + # Counts vi.mock/vi.doMock of src/config, src/nest/realtime and src/db + # per file under tests/ against scripts/test-mocks-baseline.json. The + # setup file fixes the config, a FakeRealtimeService records broadcasts. + run: cd server && npm run lint:test-mocks + + - name: Hand-built services in tests may only shrink + # Counts new XService(/new XRepository( per file under tests/ against + # scripts/test-new-service-baseline.json (classes whose constructor + # takes nothing are allowed). New suites use createTestModule. + run: cd server && npm run lint:test-new-service + - name: QueryBuilder calls in the repositories may only shrink # Counts this.qb(...)/createQueryBuilder(...) per file under # src/db/repositories/ against scripts/query-api-baseline.json. New diff --git a/CLAUDE.md b/CLAUDE.md index b92e3476e..74b7bef18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,8 @@ cd server && npm run lint:dialect # SQLite-only SQL spellings m cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL) cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate) cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate) +cd server && npm run lint:test-mocks # vi.mock of config, realtime and db modules in tests/ may only shrink (CI gate) +cd server && npm run lint:test-new-service # hand-built services and repositories in tests/ may only shrink (CI gate) cd server && npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate) cd server && npm run lint:mcp-zod # inline zod in the MCP tool files may only shrink; fields derive from @trek/shared (CI gate) cd server && npm run lint:service-http # { error, status } results and their caller-side branches may only shrink; services throw DomainError (CI gate) @@ -130,7 +132,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client and server through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:strict`, `lint:boundaries`, `lint:tx`, `lint:dialect`, `lint:test-sql`, `lint:test-mocks`, `lint:test-new-service`, `lint:query-api`, `lint:mcp-zod`, `lint:service-http` and `lint:response-contracts`, the client's and server's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `lint:format` on `shared/`, both check-only. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. - **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`. - **Guides name paths that exist**: the `changes` job runs `scripts/ci/doc-paths.mjs`, which resolves every backtick path in the CLAUDE.md files, `README.md`, `server/src/nest/README.md` and the wiki against the tracked files; the missing ones are listed in `scripts/ci/doc-paths-baseline.json`, which only shrinks (`--update` after a fix). diff --git a/server/CLAUDE.md b/server/CLAUDE.md index f6707e3f1..2025e4a92 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -22,12 +22,14 @@ npm run gen:db-types # regenerate the Kysely table types in src/db/kysely/ npm run check:db-types # CI gate: fails when src/db/kysely/ no longer matches the migrations node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratchet block for vitest.config.ts npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any) -npm run lint:size # no file in src/ or scripts/ past 1000 lines, longer ones only shrink (CI gate) +npm run lint:size # no file in src/ or scripts/ past 1000 lines, none in tests/ past 2000, longer ones only shrink (CI gate) npm run lint:boundaries # import cycles and cross-domain reach-ins may only shrink (CI gate) npm run lint:dialect # SQLite-only SQL spellings under src/ may only shrink (CI gate) npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job postgres-probe; needs TREK_PG_PROBE_URL) npm run lint:tx # methods writing more than once outside one transaction may only shrink (CI gate) npm run lint:test-sql # raw better-sqlite3 statements in tests/ may only shrink (CI gate) +npm run lint:test-mocks # vi.mock of src/config, src/nest/realtime and src/db in tests/ may only shrink (CI gate) +npm run lint:test-new-service # new XService(/new XRepository( in tests/ may only shrink; zero-argument classes are allowed (CI gate) npm run lint:query-api # MikroORM QueryBuilder calls in the repositories may only shrink (CI gate) npm run lint:mcp-zod # inline z. calls per *.mcp.ts may only shrink; tool fields derive from @trek/shared (CI gate) npm run lint:service-http # error-envelope idioms (status: 4xx in services, if (result.error) in callers) may only shrink (CI gate) @@ -41,12 +43,14 @@ npm run contracts:dto-open # open shapes in the createZodDto schemas may only sh **`lint:tx`** (`scripts/tx-writes.mjs`) reads every class under `src/` and counts, per method, the writes it issues outside a `.transactional(...)` callback: a repository call that builds an INSERT/UPDATE/DELETE, a call to a method that writes (of its own class or of a constructor-injected one), a transaction holding writes as one. A write in a loop counts twice, of two branches only the larger counts, and a helper whose every call sits inside a transaction is not reported. A method at two or more is held to its entry in `scripts/tx-writes-baseline.json`; a new one fails, and so does an entry above what its method holds now, so a fix lands with `npm run lint:tx -- --update` (it never raises or adds an entry). `--explain` lists the line of every call that counted. Two JSDoc tags mark the deliberate cases, each with its reason in the tag text: `@txStandalone` on a method whose write stands on its own whatever its caller does (an audit row, a notification, a non-fatal derived refresh, an idempotent lazy provision) makes it count for nothing at its callers, and `@txIndependent` on a method whose writes are complete one by one on purpose (network I/O between them, a retention sweep in passes, one transaction per item of a batch) keeps it out of the report. -**`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. An entry above what its file holds now, or for a file that is gone, fails as well, so after a split `npm run lint:size -- --update` lowers the baseline in the same change (it never raises or adds an entry). +**`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files under `tests/` are held the same way at 2000 lines against `scripts/test-size-baseline.json`. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. An entry above what its file holds now, or for a file that is gone, fails as well, so after a split `npm run lint:size -- --update` lowers the baseline in the same change (it never raises or adds an entry). **`lint:strict`** (`scripts/strict-lint.mjs`) runs tsc over `tsconfig.strict.json`, the build config with `strictNullChecks` and `noImplicitAny` on, and counts the errors per file against `scripts/strict-baseline.json`. A file may hold at most its entry and a file without one, a new file in particular, none; an entry above what its file holds now, or for a file that is gone, fails as well, so the change that fixes errors runs `npm run lint:strict -- --update` (it never raises or adds an entry). It is a ratchet only: the build stays on `strict: false` until the baseline is empty. **`lint:test-sql`** (`scripts/test-sql-ratchet.mjs`) counts every `.prepare(` under `tests/` per file against `scripts/test-sql-baseline.json`: a file may hold at most its entry, a file without one none. Growth fails, and so does an entry above what its file holds now, so a conversion lands with `npm run lint:test-sql -- --update` (it only lowers and drops entries). Files whose subject is raw SQL (the dialect functions, PRAGMA introspection, the legacy upgrade fixtures) sit in the baseline's `exempt` list with a reason; a single statement that must stay raw carries a `test-sql-allow: ` comment on its line or the line above. `tests/unit/scripts/test-sql-ratchet.test.ts` also runs it on the tree. +**`lint:test-mocks`** (`scripts/test-mocks-ratchet.mjs`) and **`lint:test-new-service`** (`scripts/test-new-service-ratchet.mjs`) share the count rules of `scripts/lib/count-ratchet.mjs` (`scripts/test-mocks-baseline.json`, `scripts/test-new-service-baseline.json`). The first counts every `vi.mock`/`vi.doMock` of `src/config`, `src/nest/realtime/**` and `src/db/**` per file under `tests/`: the setup file fixes the config (`tests/helpers/test-config.ts`, `overrideTestConfig`), broadcasts go to a `FakeRealtimeService` or `spyOnRealtime(app)`. The second counts `new Service(` and `new Repository(` under `tests/`, leaving out classes whose constructor takes nothing (derived from `src/` and `tests/helpers/`); a suite takes its subject from `createTestModule` (`tests/helpers/test-module.ts`) or from a booted container (`tests/helpers/test-app.ts`). + **`lint:query-api`** (`scripts/query-api-ratchet.mjs`) counts every `.qb(` and `createQueryBuilder(` under `src/db/repositories/` per file (the `TrekRepository` base that defines `qb` is left out) against `scripts/query-api-baseline.json`: a file may hold at most its entry, a file without one none, and an entry above what its file holds now fails until `npm run lint:query-api -- --update` lowers it (it never raises or adds one). The rule it holds is "Which query API" under SQL dialect below. **`lint:mcp-zod`** (`scripts/mcp-inline-zod.mjs`) and **`lint:service-http`** (`scripts/service-http-ratchet.mjs`) share the per-file count rules of `scripts/lib/count-ratchet.mjs`: a file may hold at most its entry in its baseline (`scripts/mcp-inline-zod-baseline.json`, `scripts/service-http-baseline.json`), a file without one none, and an entry above what its file holds now fails until `-- --update` lowers it (never raises or adds). `lint:mcp-zod` counts every `z.` in a `*.mcp.ts`: a tool field derives from the request schema in `@trek/shared` or a shared primitive (`idSchema`); a bound REST deliberately lacks stays inline and counted. `lint:service-http` counts, in a `*.service.ts`, the `status: 4xx`/`5xx` literals of a returned `{ error, status }` and every `new HttpException(`, and in a `*.controller.ts` or `*.mcp.ts` every branch on a returned error (`if (result.error)`, `if ('error' in r)`). A service refuses by throwing a `DomainError` (`src/nest/common/domain-error.ts`) instead; `src/nest/README.md` ("Refusals") has the shape. diff --git a/server/package.json b/server/package.json index cf607c743..6f388327a 100644 --- a/server/package.json +++ b/server/package.json @@ -23,6 +23,8 @@ "lint:dialect": "node scripts/dialect-lint.mjs", "lint:tx": "node scripts/tx-writes.mjs", "lint:test-sql": "node scripts/test-sql-ratchet.mjs", + "lint:test-mocks": "node scripts/test-mocks-ratchet.mjs", + "lint:test-new-service": "node scripts/test-new-service-ratchet.mjs", "lint:query-api": "node scripts/query-api-ratchet.mjs", "lint:mcp-zod": "node scripts/mcp-inline-zod.mjs", "lint:service-http": "node scripts/service-http-ratchet.mjs", diff --git a/server/scripts/lib/count-ratchet.mjs b/server/scripts/lib/count-ratchet.mjs index ad70565b0..ddcf68196 100644 --- a/server/scripts/lib/count-ratchet.mjs +++ b/server/scripts/lib/count-ratchet.mjs @@ -1,5 +1,6 @@ /* - * The per-file count ratchet shared by lint:mcp-zod and lint:service-http. + * The per-file count ratchet shared by lint:mcp-zod, lint:service-http, + * lint:test-mocks and lint:test-new-service. * * A check names the tree it walks, which files in it count and how to count one * file's text. Every file is held to its entry in the check's baseline JSON @@ -31,11 +32,14 @@ export function scan(serverDir, check) { `${check.root}/ does not exist under ${serverDir}: the check would pass without looking at anything`, ); } + // A check that needs to read the tree first (a list of class names, say) + // builds that context once here; count() gets it with every file. + const context = check.prepare ? check.prepare(serverDir) : undefined; const counts = {}; for (const path of walk(dir)) { const file = relative(serverDir, path).split('\\').join('/'); if (!check.include(file)) continue; - const n = check.count(readFileSync(path, 'utf8'), file); + const n = check.count(readFileSync(path, 'utf8'), file, context); if (n > 0) counts[file] = n; } return counts; diff --git a/server/scripts/size-lint.mjs b/server/scripts/size-lint.mjs index 1df9f51e9..90775c65a 100644 --- a/server/scripts/size-lint.mjs +++ b/server/scripts/size-lint.mjs @@ -15,6 +15,11 @@ * --update lowers it: otherwise the file could grow back unseen, and a new * file at a deleted path would inherit its allowance. * + * Tests are held the same way under their own, wider limit: a file under + * tests/ (helpers and fixtures included) may hold TEST_LIMIT lines, the longer + * ones are listed in scripts/test-size-baseline.json, and a suite that needs + * to grow is split by the behaviour it covers. + * * Lines are counted the way they read, not the way they are stored: a line * longer than LINE_WIDTH (prettier's printWidth) counts once per LINE_WIDTH * columns it spans, so joining lines does not buy room under the limit. @@ -42,22 +47,42 @@ import { writeBaseline, } from '../../scripts/lib/ratchet.mjs'; -/** The most lines a file without a baseline entry may hold. */ +/** The most lines a source file without a baseline entry may hold. */ const LIMIT = 1000; +/** The most lines a test file without a baseline entry may hold. */ +const TEST_LIMIT = 2000; + /** prettier's printWidth for the server: a line past it counts once per width it spans. */ const LINE_WIDTH = 120; -/** The trees the check walks, relative to the server root. Each one must exist. */ -const ROOTS = ['src', 'scripts']; - const SOURCE = /\.(?:[cm]?[jt]s)$/; const TEST = /\.(?:test|spec)\./; -const accepts = (key) => { - const name = key.slice(key.lastIndexOf('/') + 1); - return SOURCE.test(name) && !TEST.test(name); -}; +const nameOf = (key) => key.slice(key.lastIndexOf('/') + 1); + +/** + * What the check holds. Each scope walks its trees (relative to the server + * root; each one must exist) against its own limit and baseline file. + */ +const SCOPES = [ + { + label: 'size', + roots: ['src', 'scripts'], + limit: LIMIT, + baseline: 'size-baseline.json', + accepts: (key) => SOURCE.test(nameOf(key)) && !TEST.test(nameOf(key)), + advice: 'Split a concern into a service, helper or module of its own instead of growing the file.', + }, + { + label: 'size (tests)', + roots: ['tests'], + limit: TEST_LIMIT, + baseline: 'test-size-baseline.json', + accepts: (key) => SOURCE.test(nameOf(key)), + advice: 'Split the suite by the behaviour it covers instead of growing the file.', + }, +]; /** * Lines as an editor numbers them (a final newline does not start another), @@ -73,48 +98,48 @@ function lineCount(text) { } /** Weighted line counts keyed by the server-relative POSIX path. A missing root is an error. */ -function scan(serverDir) { +function scan(serverDir, scope) { const counts = {}; - for (const path of listFiles(serverDir, ROOTS, accepts)) counts[toKey(serverDir, path)] = lineCount(readText(path)); + for (const path of listFiles(serverDir, scope.roots, scope.accepts)) { + counts[toKey(serverDir, path)] = lineCount(readText(path)); + } return counts; } /** Every entry must be a whole number above the limit: anything else is a hand edit gone wrong. */ -function sizeEntries(value) { +const sizeEntries = (limit) => (value) => { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return 'it must be an object of file paths to line counts'; } for (const [file, n] of Object.entries(value)) { - if (!Number.isInteger(n) || n <= LIMIT) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${LIMIT}`; + if (!Number.isInteger(n) || n <= limit) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${limit}`; } return null; -} +}; -function check(argv) { - const dirArg = argv.find((a) => a.startsWith('--dir=')); - const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url)); - const baselinePath = join(serverDir, 'scripts', 'size-baseline.json'); +/** One scope against its baseline; answers whether it failed. */ +function checkScope(serverDir, scope, update) { + const { limit } = scope; + const baselinePath = join(serverDir, 'scripts', scope.baseline); - const counts = scan(serverDir); - let baseline = readBaseline(baselinePath, sizeEntries); - if (argv.includes('--update')) { - baseline = lowerCounts(baseline, counts, LIMIT); + const counts = scan(serverDir, scope); + let baseline = readBaseline(baselinePath, sizeEntries(limit)); + if (update) { + baseline = lowerCounts(baseline, counts, limit); writeBaseline(baselinePath, baseline); } - const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, LIMIT)); - const stale = staleCounts(baseline, counts, LIMIT); + const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, limit)); + const stale = staleCounts(baseline, counts, limit); for (const [file, n] of grown) { const entry = baseline[file]; console.error( - `FAIL ${file}: ${n} lines, ` + - (entry ? `its baseline is ${entry}. ` : `the limit is ${LIMIT}. `) + - 'Split a concern into a service, helper or module of its own instead of growing the file.', + `FAIL ${file}: ${n} lines, ` + (entry ? `its baseline is ${entry}. ` : `the limit is ${limit}. `) + scope.advice, ); } for (const { key, entry } of stale) { console.error( - `FAIL ${key} is held at ${entry} in scripts/size-baseline.json, ` + + `FAIL ${key} is held at ${entry} in scripts/${scope.baseline}, ` + (key in counts ? `but it has ${counts[key]} lines now.` : 'but the file is gone.'), ); } @@ -125,9 +150,19 @@ function check(argv) { ); } console.log( - `size: ${Object.keys(counts).length} file(s), ${Object.keys(baseline).length} over ${LIMIT} lines held at their baseline`, + `${scope.label}: ${Object.keys(counts).length} file(s), ` + + `${Object.keys(baseline).length} over ${limit} lines held at their baseline`, ); - return grown.length || stale.length ? 1 : 0; + return grown.length > 0 || stale.length > 0; +} + +function check(argv) { + const dirArg = argv.find((a) => a.startsWith('--dir=')); + const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url)); + const update = argv.includes('--update'); + let failed = false; + for (const scope of SCOPES) failed = checkScope(serverDir, scope, update) || failed; + return failed ? 1 : 0; } await runCli('lint:size', (argv) => { diff --git a/server/scripts/test-mocks-baseline.json b/server/scripts/test-mocks-baseline.json new file mode 100644 index 000000000..387c434f4 --- /dev/null +++ b/server/scripts/test-mocks-baseline.json @@ -0,0 +1,283 @@ +{ + "counts": { + "tests/e2e/addons.e2e.test.ts": 1, + "tests/e2e/admin.e2e.test.ts": 1, + "tests/e2e/airports.e2e.test.ts": 1, + "tests/e2e/assignments.e2e.test.ts": 1, + "tests/e2e/atlas.e2e.test.ts": 1, + "tests/e2e/auth.e2e.test.ts": 1, + "tests/e2e/backup.e2e.test.ts": 1, + "tests/e2e/booking-import.e2e.test.ts": 1, + "tests/e2e/budget.e2e.test.ts": 1, + "tests/e2e/calendar-feed-visibility.e2e.test.ts": 1, + "tests/e2e/categories.e2e.test.ts": 1, + "tests/e2e/collab.e2e.test.ts": 1, + "tests/e2e/collections.e2e.test.ts": 1, + "tests/e2e/dawarich.e2e.test.ts": 1, + "tests/e2e/days.e2e.test.ts": 1, + "tests/e2e/doc-sync.e2e.test.ts": 1, + "tests/e2e/feeds.e2e.test.ts": 1, + "tests/e2e/files.e2e.test.ts": 1, + "tests/e2e/google-quota.e2e.test.ts": 1, + "tests/e2e/harness.ts": 1, + "tests/e2e/journey.e2e.test.ts": 1, + "tests/e2e/mailer.e2e.test.ts": 1, + "tests/e2e/maps.e2e.test.ts": 1, + "tests/e2e/memories.e2e.test.ts": 1, + "tests/e2e/notifications.e2e.test.ts": 1, + "tests/e2e/oauth.e2e.test.ts": 1, + "tests/e2e/oidc.e2e.test.ts": 1, + "tests/e2e/packing.e2e.test.ts": 1, + "tests/e2e/place-enrichment.e2e.test.ts": 1, + "tests/e2e/place-shadow.e2e.test.ts": 1, + "tests/e2e/places.e2e.test.ts": 1, + "tests/e2e/plugin-pois.e2e.test.ts": 1, + "tests/e2e/plugin-search.e2e.test.ts": 1, + "tests/e2e/public-api.e2e.test.ts": 1, + "tests/e2e/push.e2e.test.ts": 1, + "tests/e2e/receipt-scan.e2e.test.ts": 1, + "tests/e2e/reservations.e2e.test.ts": 1, + "tests/e2e/roadtrip.e2e.test.ts": 1, + "tests/e2e/school-holidays.e2e.test.ts": 1, + "tests/e2e/session-revocation.e2e.test.ts": 1, + "tests/e2e/sessions.e2e.test.ts": 1, + "tests/e2e/settings.e2e.test.ts": 1, + "tests/e2e/share.e2e.test.ts": 1, + "tests/e2e/storage-admin.e2e.test.ts": 1, + "tests/e2e/system-notices.e2e.test.ts": 1, + "tests/e2e/tags.e2e.test.ts": 1, + "tests/e2e/todo.e2e.test.ts": 1, + "tests/e2e/tours.e2e.test.ts": 1, + "tests/e2e/transit-provider.e2e.test.ts": 1, + "tests/e2e/transit.e2e.test.ts": 1, + "tests/e2e/trip-invite.e2e.test.ts": 1, + "tests/e2e/trips.e2e.test.ts": 1, + "tests/e2e/vacay.e2e.test.ts": 1, + "tests/e2e/weather.e2e.test.ts": 1, + "tests/helpers/db-mock.ts": 3, + "tests/helpers/test-db.ts": 1, + "tests/integration/admin.test.ts": 1, + "tests/integration/airtrail-sync-request-context.test.ts": 1, + "tests/integration/api-docs.test.ts": 1, + "tests/integration/app-config.test.ts": 1, + "tests/integration/assignments.test.ts": 1, + "tests/integration/async-closure-truthiness-guards.test.ts": 1, + "tests/integration/atlas-geocode-request-context.test.ts": 1, + "tests/integration/atlas.test.ts": 1, + "tests/integration/auth.test.ts": 1, + "tests/integration/backup.test.ts": 1, + "tests/integration/boot-sweeps-request-context.test.ts": 1, + "tests/integration/bootstrap.test.ts": 1, + "tests/integration/budget.test.ts": 1, + "tests/integration/categories.test.ts": 1, + "tests/integration/collab.test.ts": 1, + "tests/integration/collections.test.ts": 1, + "tests/integration/dayNotes.test.ts": 1, + "tests/integration/dayReorder.test.ts": 1, + "tests/integration/days.test.ts": 1, + "tests/integration/demo-seed-request-context.test.ts": 1, + "tests/integration/doc-sync-webhook-request-context.test.ts": 1, + "tests/integration/feeds-request-context.test.ts": 1, + "tests/integration/files.test.ts": 1, + "tests/integration/help.test.ts": 1, + "tests/integration/immich.test.ts": 1, + "tests/integration/journey-place-order.test.ts": 1, + "tests/integration/journey.test.ts": 1, + "tests/integration/leg-mode-incoming.test.ts": 1, + "tests/integration/legacy-upgrade-v066.test.ts": 1, + "tests/integration/legacy-upgrade-v082.test.ts": 1, + "tests/integration/legacy-upgrade-v205.test.ts": 1, + "tests/integration/legacy-upgrade-v242.test.ts": 1, + "tests/integration/legacy-upgrade-v244.test.ts": 1, + "tests/integration/maps.test.ts": 1, + "tests/integration/mcp.test.ts": 1, + "tests/integration/memories-immich.test.ts": 1, + "tests/integration/memories-synology.test.ts": 1, + "tests/integration/memories-unified.test.ts": 1, + "tests/integration/misc.test.ts": 1, + "tests/integration/notifications.test.ts": 1, + "tests/integration/oauth-authorize.test.ts": 1, + "tests/integration/oauth.test.ts": 1, + "tests/integration/oidc-local-provider.test.ts": 1, + "tests/integration/oidc.test.ts": 1, + "tests/integration/orm-request-context-seams.test.ts": 1, + "tests/integration/packing.test.ts": 1, + "tests/integration/passkey.test.ts": 1, + "tests/integration/places.test.ts": 1, + "tests/integration/plugins/boot-registry-order.test.ts": 1, + "tests/integration/plugins/plugin-runtime.test.ts": 1, + "tests/integration/profile.test.ts": 1, + "tests/integration/reservations.test.ts": 1, + "tests/integration/scheduler-gate.test.ts": 1, + "tests/integration/security.test.ts": 1, + "tests/integration/settings.test.ts": 1, + "tests/integration/share.test.ts": 1, + "tests/integration/storage-health-notifier-request-context.test.ts": 1, + "tests/integration/systemNotices.test.ts": 1, + "tests/integration/tags.test.ts": 1, + "tests/integration/todo.test.ts": 1, + "tests/integration/trip-access-primitives.test.ts": 1, + "tests/integration/trips.test.ts": 1, + "tests/integration/uploads-photo-request-context.test.ts": 1, + "tests/integration/uploads-static.test.ts": 1, + "tests/integration/vacay.test.ts": 1, + "tests/setup.ts": 1, + "tests/unit/auto-backup.test.ts": 1, + "tests/unit/db/database-lazy-open.test.ts": 1, + "tests/unit/mcp/demo-tool-gate.test.ts": 1, + "tests/unit/mcp/tools-addon-gating.test.ts": 1, + "tests/unit/mcp/tools-airtrail.test.ts": 1, + "tests/unit/mcp/tools-assignments-reservations-extra.test.ts": 1, + "tests/unit/mcp/tools-assignments.test.ts": 1, + "tests/unit/mcp/tools-atlas-expanded.test.ts": 1, + "tests/unit/mcp/tools-atlas.test.ts": 1, + "tests/unit/mcp/tools-budget-advanced.test.ts": 1, + "tests/unit/mcp/tools-budget.test.ts": 1, + "tests/unit/mcp/tools-categories.test.ts": 1, + "tests/unit/mcp/tools-collab-polls-chat.test.ts": 1, + "tests/unit/mcp/tools-collections.test.ts": 1, + "tests/unit/mcp/tools-days-accommodations.test.ts": 1, + "tests/unit/mcp/tools-days.test.ts": 1, + "tests/unit/mcp/tools-feeds-invite.test.ts": 1, + "tests/unit/mcp/tools-files.test.ts": 1, + "tests/unit/mcp/tools-help-addons.test.ts": 1, + "tests/unit/mcp/tools-journey.test.ts": 1, + "tests/unit/mcp/tools-leg-transport-mode.test.ts": 1, + "tests/unit/mcp/tools-memories.test.ts": 1, + "tests/unit/mcp/tools-notes.test.ts": 1, + "tests/unit/mcp/tools-notifications.test.ts": 1, + "tests/unit/mcp/tools-packing-advanced.test.ts": 1, + "tests/unit/mcp/tools-packing.test.ts": 1, + "tests/unit/mcp/tools-places.test.ts": 1, + "tests/unit/mcp/tools-plugin-pois.test.ts": 1, + "tests/unit/mcp/tools-plugin-search.test.ts": 1, + "tests/unit/mcp/tools-prompts.test.ts": 1, + "tests/unit/mcp/tools-reservations.test.ts": 1, + "tests/unit/mcp/tools-roadtrip.test.ts": 1, + "tests/unit/mcp/tools-settings.test.ts": 1, + "tests/unit/mcp/tools-share.test.ts": 1, + "tests/unit/mcp/tools-tags-maps-weather.test.ts": 1, + "tests/unit/mcp/tools-todos.test.ts": 1, + "tests/unit/mcp/tools-transit.test.ts": 1, + "tests/unit/mcp/tools-transports.test.ts": 1, + "tests/unit/mcp/tools-trip-members.test.ts": 1, + "tests/unit/mcp/tools-trip-warnings.test.ts": 1, + "tests/unit/mcp/tools-trips.test.ts": 1, + "tests/unit/mcp/tools-vacay.test.ts": 1, + "tests/unit/nest/accommodations.service.test.ts": 1, + "tests/unit/nest/admin.service.test.ts": 1, + "tests/unit/nest/admin.version-notification.test.ts": 1, + "tests/unit/nest/assignments.service.test.ts": 1, + "tests/unit/nest/atlas.service.test.ts": 1, + "tests/unit/nest/auth.service.test.ts": 1, + "tests/unit/nest/backup.impl.test.ts": 1, + "tests/unit/nest/booking-import/booking-import.service.test.ts": 1, + "tests/unit/nest/budget.service.calc.test.ts": 1, + "tests/unit/nest/budget.service.db.test.ts": 1, + "tests/unit/nest/budget.service.test.ts": 1, + "tests/unit/nest/calendar.service.test.ts": 1, + "tests/unit/nest/channel-override.test.ts": 1, + "tests/unit/nest/collab.service.test.ts": 1, + "tests/unit/nest/collections.service.test.ts": 1, + "tests/unit/nest/common/conflictUpdate.test.ts": 1, + "tests/unit/nest/database/database-lifecycle.service.test.ts": 2, + "tests/unit/nest/dawarich-sync.service.test.ts": 1, + "tests/unit/nest/day-notes.service.test.ts": 1, + "tests/unit/nest/day-removal.service.test.ts": 1, + "tests/unit/nest/days.service.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync-config.service.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync.controller.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync.service.test.ts": 1, + "tests/unit/nest/files.service.test.ts": 1, + "tests/unit/nest/google-quota.service.test.ts": 1, + "tests/unit/nest/immich.service.test.ts": 1, + "tests/unit/nest/journey-book.service.test.ts": 1, + "tests/unit/nest/journey-domain.service.test.ts": 1, + "tests/unit/nest/journey-share.service.test.ts": 1, + "tests/unit/nest/journey-stats.service.test.ts": 1, + "tests/unit/nest/mailer.service.test.ts": 1, + "tests/unit/nest/maps.amap.test.ts": 1, + "tests/unit/nest/maps.curated-images.test.ts": 1, + "tests/unit/nest/maps.google-quota.test.ts": 1, + "tests/unit/nest/maps.identity.test.ts": 1, + "tests/unit/nest/maps.service.test.ts": 1, + "tests/unit/nest/mcp-shared.service.test.ts": 1, + "tests/unit/nest/mcp-transport.service.test.ts": 1, + "tests/unit/nest/memories-access.service.test.ts": 1, + "tests/unit/nest/notification-preferences.service.test.ts": 1, + "tests/unit/nest/notification-transports.test.ts": 1, + "tests/unit/nest/notifications.inapp-prefs.test.ts": 1, + "tests/unit/nest/notifications.service.test.ts": 1, + "tests/unit/nest/notifications/push-subscriptions.service.test.ts": 1, + "tests/unit/nest/notifications/vapid-keys.service.test.ts": 1, + "tests/unit/nest/notifications/web-push-dispatch.test.ts": 1, + "tests/unit/nest/notifications/web-push.service.test.ts": 1, + "tests/unit/nest/oauth.service.test.ts": 1, + "tests/unit/nest/oidc.service.test.ts": 1, + "tests/unit/nest/packing.service.test.ts": 1, + "tests/unit/nest/passkey.service.test.ts": 1, + "tests/unit/nest/permissions.service.test.ts": 1, + "tests/unit/nest/photo-resolver.service.test.ts": 1, + "tests/unit/nest/photos/trekPhotoMedia.test.ts": 1, + "tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 1, + "tests/unit/nest/place-regions-repair.test.ts": 1, + "tests/unit/nest/places.controller.test.ts": 1, + "tests/unit/nest/places.directions.test.ts": 1, + "tests/unit/nest/places.helpers.test.ts": 1, + "tests/unit/nest/places.service.test.ts": 1, + "tests/unit/nest/platform.controller.test.ts": 1, + "tests/unit/nest/public-api-scopes.test.ts": 1, + "tests/unit/nest/registration-invites.service.test.ts": 1, + "tests/unit/nest/reminder-jobs.service.test.ts": 1, + "tests/unit/nest/reservations.service.test.ts": 1, + "tests/unit/nest/route-inventory.test.ts": 1, + "tests/unit/nest/settings.service.test.ts": 1, + "tests/unit/nest/share.service.test.ts": 1, + "tests/unit/nest/storage/storage-admin.service.test.ts": 1, + "tests/unit/nest/storage/storage-jobs.service.test.ts": 1, + "tests/unit/nest/storage/storage-registry.service.test.ts": 1, + "tests/unit/nest/storage/storage-stats.service.test.ts": 1, + "tests/unit/nest/synology.service.test.ts": 1, + "tests/unit/nest/todo.service.test.ts": 1, + "tests/unit/nest/token.service.test.ts": 1, + "tests/unit/nest/tours.controller.test.ts": 1, + "tests/unit/nest/tours.gpx.atomic.test.ts": 1, + "tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts": 1, + "tests/unit/nest/tours.permissions.test.ts": 1, + "tests/unit/nest/tours.service.permissions.test.ts": 1, + "tests/unit/nest/tours.service.test.ts": 1, + "tests/unit/nest/transit-itinerary.helpers.test.ts": 1, + "tests/unit/nest/travel-stats.service.test.ts": 1, + "tests/unit/nest/trip-invite.service.test.ts": 1, + "tests/unit/nest/trip-members.service.test.ts": 1, + "tests/unit/nest/trip-read-model.service.test.ts": 1, + "tests/unit/nest/trips.service.test.ts": 1, + "tests/unit/nest/unified-memories.service.test.ts": 1, + "tests/unit/nest/user-cleanup.service.test.ts": 1, + "tests/unit/nest/user-profile.service.test.ts": 1, + "tests/unit/nest/vacay.service.test.ts": 1, + "tests/unit/plugins/atlas-layers.controller.test.ts": 1, + "tests/unit/plugins/day-schedule.controller.test.ts": 1, + "tests/unit/plugins/day-tints.controller.test.ts": 1, + "tests/unit/plugins/host-rpc.test.ts": 1, + "tests/unit/plugins/map-layers.controller.test.ts": 1, + "tests/unit/plugins/map-markers.controller.test.ts": 1, + "tests/unit/plugins/operator-egress.test.ts": 1, + "tests/unit/plugins/pdf-sections.controller.test.ts": 1, + "tests/unit/plugins/place-details.controller.test.ts": 1, + "tests/unit/plugins/plugin-instance-settings.test.ts": 1, + "tests/unit/plugins/plugin-oauth.service.test.ts": 1, + "tests/unit/plugins/plugin-pois.test.ts": 1, + "tests/unit/plugins/plugin-retrust.controller.test.ts": 1, + "tests/unit/plugins/plugin-routes.controller.test.ts": 1, + "tests/unit/plugins/plugin-user-settings.test.ts": 1, + "tests/unit/plugins/plugins-service.test.ts": 1, + "tests/unit/plugins/settings-isolation.test.ts": 1, + "tests/unit/plugins/trip-card-contributions.controller.test.ts": 1, + "tests/unit/plugins/trip-warnings.controller.test.ts": 1, + "tests/unit/plugins/view-contributions.controller.test.ts": 1, + "tests/websocket/connection.test.ts": 1, + "tests/websocket/wire-envelope.test.ts": 1 + } +} diff --git a/server/scripts/test-mocks-ratchet.mjs b/server/scripts/test-mocks-ratchet.mjs new file mode 100644 index 000000000..75348b46a --- /dev/null +++ b/server/scripts/test-mocks-ratchet.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +/* + * lint:test-mocks: module mocks of the config, the realtime transport and the + * database modules in tests/ may only shrink. + * + * A `vi.mock` of one of those paths replaces a module every domain imports, + * so a suite stays green for reasons its cases do not show (a factory that + * happens to export the names the import order reaches first) and every + * constructor or import change ripples through hundreds of factories. The + * test infrastructure covers each of them instead: + * + * src/config the setup file fixes its values once + * (tests/helpers/test-config.ts, overrideTestConfig) + * src/nest/realtime/** a FakeRealtimeService through DI, or spyOnRealtime(app) + * src/db/** a snapshot test db (tests/helpers/db-mock.ts) handed + * to the code under test; the remaining `src/db/database` + * mocks are the legacy this counts down + * + * Every `vi.mock(` / `vi.doMock(` whose path ends in one of those modules + * counts, per file, against scripts/test-mocks-baseline.json; a file without + * an entry may hold none (scripts/lib/count-ratchet.mjs has the rules). + * + * npm run lint:test-mocks check against the baseline (CI) + * npm run lint:test-mocks -- --update lower the counts; never raises or adds an entry + */ +import { cliMain } from './lib/count-ratchet.mjs'; + +const MOCK_CALL = /\bvi\.(?:mock|doMock)\(\s*(['"`])([^'"`]+)\1/g; + +/** A mocked path that names one of the held modules, with or without an extension. */ +const HELD_TARGET = /(?:^|\/)src\/(?:config|websocket|db(?:\/[^'"`]*)?|nest\/realtime(?:\/[^'"`]*)?)$/; + +/** The module mocks of held targets in one file's text. */ +export function countHeldMocks(text) { + let n = 0; + for (const match of text.matchAll(MOCK_CALL)) { + const target = match[2].replace(/\.(?:[cm]?[jt]s)$/, ''); + if (HELD_TARGET.test(target)) n++; + } + return n; +} + +export const check = { + name: 'test-mocks', + script: 'lint:test-mocks', + root: 'tests', + baseline: 'scripts/test-mocks-baseline.json', + unit: 'module mock(s) of config, realtime or db', + advice: + 'Use the test config (overrideTestConfig), a FakeRealtimeService or spyOnRealtime(app), ' + + 'and a snapshot test db handed to the code under test instead of mocking the module.', + include: (file) => /\.[cm]?[jt]s$/.test(file), + count: (text) => countHeldMocks(text), +}; + +cliMain(check, import.meta.url); diff --git a/server/scripts/test-new-service-baseline.json b/server/scripts/test-new-service-baseline.json new file mode 100644 index 000000000..3f75679d0 --- /dev/null +++ b/server/scripts/test-new-service-baseline.json @@ -0,0 +1,209 @@ +{ + "counts": { + "tests/helpers/accommodations-service.ts": 8, + "tests/helpers/budget-repos.ts": 1, + "tests/helpers/fake-realtime.ts": 1, + "tests/helpers/maps-service.ts": 1, + "tests/helpers/notifications.ts": 10, + "tests/helpers/place-import.ts": 1, + "tests/helpers/plugin-host.ts": 45, + "tests/helpers/sessions.ts": 1, + "tests/helpers/share-repos.ts": 1, + "tests/helpers/storage-fixture.ts": 1, + "tests/helpers/test-addons.ts": 2, + "tests/helpers/test-app.ts": 1, + "tests/helpers/test-uow.ts": 3, + "tests/integration/dayReorder.test.ts": 3, + "tests/integration/mcp.test.ts": 2, + "tests/integration/oauth-authorize.test.ts": 2, + "tests/integration/oauth.test.ts": 2, + "tests/integration/plugins/boot-registry-order.test.ts": 7, + "tests/integration/plugins/plugin-runtime.task2-conversion.test.ts": 17, + "tests/integration/plugins/plugin-scheduler-sweep.test.ts": 5, + "tests/integration/plugins/registry.test.ts": 2, + "tests/integration/share.test.ts": 2, + "tests/integration/storage-restore-reload.test.ts": 2, + "tests/integration/storage-stream-abort.test.ts": 3, + "tests/unit/budget/costs.rpc.test.ts": 1, + "tests/unit/db/connection.test.ts": 1, + "tests/unit/db/repositories/DemoRepository.test.ts": 1, + "tests/unit/db/repositories/MaintenanceRepository.test.ts": 2, + "tests/unit/db/repositories/UserConnections.repository.test.ts": 3, + "tests/unit/demo/demo-reset-sessions.test.ts": 3, + "tests/unit/mcp/tools-airtrail.test.ts": 1, + "tests/unit/mcp/tools-budget-advanced.test.ts": 4, + "tests/unit/mcp/tools-categories.test.ts": 4, + "tests/unit/mcp/tools-prompts.test.ts": 7, + "tests/unit/mcp/tools-roadtrip.test.ts": 1, + "tests/unit/mcp/tools-transit.test.ts": 1, + "tests/unit/nest-rpc/plugin-guards.test.ts": 1, + "tests/unit/nest-rpc/rpc-kit/registry.service.test.ts": 3, + "tests/unit/nest/accommodations.event-parity.test.ts": 1, + "tests/unit/nest/accommodations.service.test.ts": 1, + "tests/unit/nest/addons.service.test.ts": 2, + "tests/unit/nest/admin.service.test.ts": 11, + "tests/unit/nest/admin.version-notification.test.ts": 11, + "tests/unit/nest/airports/airports.service.test.ts": 7, + "tests/unit/nest/airtrail.service.test.ts": 3, + "tests/unit/nest/assignments.service.test.ts": 6, + "tests/unit/nest/atlas.service.test.ts": 2, + "tests/unit/nest/audit.service.test.ts": 1, + "tests/unit/nest/auth-core/auth-guard.test.ts": 3, + "tests/unit/nest/auth.controller.test.ts": 1, + "tests/unit/nest/auth.service.test.ts": 9, + "tests/unit/nest/backup.controller.test.ts": 1, + "tests/unit/nest/booking-import/booking-import.service.test.ts": 3, + "tests/unit/nest/booking-import/import-jobs.service.test.ts": 1, + "tests/unit/nest/booking-import/kitinerary-extractor.service.test.ts": 1, + "tests/unit/nest/budget.service.calc.test.ts": 1, + "tests/unit/nest/budget.service.db.test.ts": 6, + "tests/unit/nest/budget.service.test.ts": 1, + "tests/unit/nest/calendar.service.test.ts": 6, + "tests/unit/nest/categories.service.test.ts": 2, + "tests/unit/nest/channel-override.test.ts": 2, + "tests/unit/nest/charging.test.ts": 6, + "tests/unit/nest/collab.service.test.ts": 6, + "tests/unit/nest/collections.service.test.ts": 3, + "tests/unit/nest/common/conflictUpdate.test.ts": 9, + "tests/unit/nest/cron-registrar.service.test.ts": 18, + "tests/unit/nest/dawarich-suggestions.service.test.ts": 1, + "tests/unit/nest/dawarich-sync.service.test.ts": 1, + "tests/unit/nest/dawarich-tracks.service.test.ts": 1, + "tests/unit/nest/dawarich.service.test.ts": 1, + "tests/unit/nest/day-boundaries.test.ts": 1, + "tests/unit/nest/demo.service.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync-config.service.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync.controller.test.ts": 1, + "tests/unit/nest/doc-sync/doc-sync.job.test.ts": 3, + "tests/unit/nest/doc-sync/doc-sync.service.test.ts": 3, + "tests/unit/nest/features.controller.test.ts": 2, + "tests/unit/nest/feeds.service.test.ts": 1, + "tests/unit/nest/files.service.test.ts": 7, + "tests/unit/nest/google-quota.service.test.ts": 1, + "tests/unit/nest/google-route.test.ts": 1, + "tests/unit/nest/google-transit.provider.test.ts": 7, + "tests/unit/nest/immich.service.test.ts": 1, + "tests/unit/nest/integrations/airtrailImport.test.ts": 5, + "tests/unit/nest/integrations/airtrailTestConnection.test.ts": 3, + "tests/unit/nest/integrations/airtrailWriteback.test.ts": 3, + "tests/unit/nest/integrations/airtrailWriteGate.test.ts": 2, + "tests/unit/nest/journey-book.service.test.ts": 3, + "tests/unit/nest/journey-domain.service.test.ts": 2, + "tests/unit/nest/journey-photo-capture.service.test.ts": 3, + "tests/unit/nest/journey-share.service.test.ts": 4, + "tests/unit/nest/journey-stats.service.test.ts": 2, + "tests/unit/nest/llm-parse/llm-parse.service.test.ts": 1, + "tests/unit/nest/mailer.service.test.ts": 1, + "tests/unit/nest/mcp-shared.service.test.ts": 2, + "tests/unit/nest/mcp-transport.service.test.ts": 1, + "tests/unit/nest/memories-access.service.test.ts": 1, + "tests/unit/nest/memories.service.test.ts": 1, + "tests/unit/nest/memories/journey-thumbs.job.test.ts": 2, + "tests/unit/nest/memories/trek-photo-cache.job.test.ts": 1, + "tests/unit/nest/notification-preferences.service.test.ts": 4, + "tests/unit/nest/notification-transports.test.ts": 3, + "tests/unit/nest/notifications/storage-health-notifier.service.test.ts": 2, + "tests/unit/nest/notifications/vapid-keys.service.test.ts": 2, + "tests/unit/nest/notifications/web-push.service.test.ts": 1, + "tests/unit/nest/oauth.controller.test.ts": 1, + "tests/unit/nest/oauth.service.test.ts": 4, + "tests/unit/nest/oidc.service.test.ts": 13, + "tests/unit/nest/packing-writes.service.test.ts": 2, + "tests/unit/nest/packing.controller.test.ts": 1, + "tests/unit/nest/packing.service.test.ts": 1, + "tests/unit/nest/passkey.service.test.ts": 10, + "tests/unit/nest/permissions.service.test.ts": 2, + "tests/unit/nest/photo-capture-backfill.exif.test.ts": 1, + "tests/unit/nest/photo-capture-backfill.service.test.ts": 5, + "tests/unit/nest/photo-resolver.service.test.ts": 2, + "tests/unit/nest/photos/trekPhotoMedia.test.ts": 1, + "tests/unit/nest/place-enrichment.brand.test.ts": 1, + "tests/unit/nest/place-enrichment.controller.test.ts": 2, + "tests/unit/nest/place-enrichment.description.test.ts": 1, + "tests/unit/nest/place-enrichment.service.test.ts": 1, + "tests/unit/nest/place-photos/place-photo-cache.job.test.ts": 2, + "tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 2, + "tests/unit/nest/place-regions-repair.test.ts": 1, + "tests/unit/nest/place-shadow.service.test.ts": 1, + "tests/unit/nest/places.directions.test.ts": 6, + "tests/unit/nest/places.service.test.ts": 6, + "tests/unit/nest/process-state-ports.test.ts": 9, + "tests/unit/nest/public-api-scopes.test.ts": 4, + "tests/unit/nest/public-api.service.test.ts": 1, + "tests/unit/nest/public-stats.controller.test.ts": 2, + "tests/unit/nest/query-helpers.service.test.ts": 1, + "tests/unit/nest/rate-limit.service.test.ts": 6, + "tests/unit/nest/realtime.gateway.test.ts": 16, + "tests/unit/nest/receipt-scan.controller.test.ts": 1, + "tests/unit/nest/registration-invites.service.test.ts": 1, + "tests/unit/nest/reminder-jobs.service.test.ts": 2, + "tests/unit/nest/reservations.service.test.ts": 2, + "tests/unit/nest/roadtrip-plan.service.test.ts": 5, + "tests/unit/nest/roadtrip-planning.test.ts": 2, + "tests/unit/nest/roadtrip-router.test.ts": 1, + "tests/unit/nest/roadtrip-search.test.ts": 3, + "tests/unit/nest/roadtrip.service.test.ts": 1, + "tests/unit/nest/route-usage.service.test.ts": 1, + "tests/unit/nest/school-holidays.service.test.ts": 2, + "tests/unit/nest/sessions/sessions.service.test.ts": 1, + "tests/unit/nest/settings.service.test.ts": 1, + "tests/unit/nest/share.service.test.ts": 3, + "tests/unit/nest/sqlite-database-backup.test.ts": 2, + "tests/unit/nest/storage/storage-admin.service.test.ts": 5, + "tests/unit/nest/storage/storage-jobs.service.test.ts": 15, + "tests/unit/nest/storage/storage-registry.service.test.ts": 3, + "tests/unit/nest/storage/storage-stats.service.test.ts": 3, + "tests/unit/nest/storage/storage.service.test.ts": 5, + "tests/unit/nest/synology.service.test.ts": 1, + "tests/unit/nest/system-notices.branch-coverage.test.ts": 1, + "tests/unit/nest/system-notices.service.test.ts": 1, + "tests/unit/nest/tags.service.test.ts": 2, + "tests/unit/nest/thumbnail.service.test.ts": 1, + "tests/unit/nest/todo.service.test.ts": 5, + "tests/unit/nest/token.service.test.ts": 1, + "tests/unit/nest/tours.gpx.atomic.test.ts": 7, + "tests/unit/nest/tours.permissions.boundaries.mock-only.test.ts": 1, + "tests/unit/nest/tours.service.permissions.test.ts": 1, + "tests/unit/nest/tours.service.test.ts": 1, + "tests/unit/nest/transit.service.test.ts": 2, + "tests/unit/nest/travel-stats.service.test.ts": 1, + "tests/unit/nest/trek-photo-cache.service.test.ts": 2, + "tests/unit/nest/trek-photo-registration.service.test.ts": 1, + "tests/unit/nest/trip-invite.service.test.ts": 4, + "tests/unit/nest/trip-members.service.test.ts": 6, + "tests/unit/nest/trip-membership/trip-membership.service.test.ts": 1, + "tests/unit/nest/trip-read-model.service.test.ts": 31, + "tests/unit/nest/trips.service.test.ts": 38, + "tests/unit/nest/unified-memories.service.test.ts": 3, + "tests/unit/nest/unsplash/unsplash.service.test.ts": 1, + "tests/unit/nest/user-cleanup.service.test.ts": 8, + "tests/unit/nest/user-profile.service.test.ts": 1, + "tests/unit/nest/vacay.service.test.ts": 2, + "tests/unit/nest/weather/weather-cache-lifecycle.test.ts": 1, + "tests/unit/nest/webauthn-config.service.test.ts": 1, + "tests/unit/packing/packing.rpc.test.ts": 1, + "tests/unit/plugins/addon-gates.rpc.test.ts": 1, + "tests/unit/plugins/day-schedule.controller.test.ts": 1, + "tests/unit/plugins/day-tints.controller.test.ts": 1, + "tests/unit/plugins/map-layers.controller.test.ts": 1, + "tests/unit/plugins/map-markers.controller.test.ts": 1, + "tests/unit/plugins/operator-egress.test.ts": 1, + "tests/unit/plugins/pdf-sections.controller.test.ts": 1, + "tests/unit/plugins/place-details.controller.test.ts": 1, + "tests/unit/plugins/plugin-instance-settings.test.ts": 1, + "tests/unit/plugins/plugin-mcp-tools.test.ts": 1, + "tests/unit/plugins/plugin-oauth.service.test.ts": 1, + "tests/unit/plugins/plugin-pois.test.ts": 1, + "tests/unit/plugins/plugin-routes.controller.test.ts": 1, + "tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts": 3, + "tests/unit/plugins/plugin-user-settings.test.ts": 2, + "tests/unit/plugins/plugins-service.test.ts": 1, + "tests/unit/plugins/request-context.test.ts": 1, + "tests/unit/plugins/rpc-coverage.test.ts": 1, + "tests/unit/plugins/settings-isolation.test.ts": 3, + "tests/unit/plugins/trip-card-contributions.controller.test.ts": 1, + "tests/unit/plugins/trip-warnings.controller.test.ts": 1, + "tests/unit/plugins/view-contributions.controller.test.ts": 1, + "tests/websocket/connection.test.ts": 1 + } +} diff --git a/server/scripts/test-new-service-ratchet.mjs b/server/scripts/test-new-service-ratchet.mjs new file mode 100644 index 000000000..780a07141 --- /dev/null +++ b/server/scripts/test-new-service-ratchet.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +/* + * lint:test-new-service: hand-built services and repositories in tests/ may + * only shrink. + * + * `new XService(a, b, c, ...)` in a test pins the constructor's parameter + * order: every dependency a service gains is a change to every suite that + * builds it (and to every suite that builds one of its consumers), which is + * why the harness files are among the most churned in the repository. A suite + * takes its subject from a Nest testing module instead (tests/helpers/ + * test-module.ts: createTestModule with overrides), and a booted app hands + * the rest out through `app.get(...)`. + * + * Every `new Service(` and `new Repository(` under tests/ counts, + * per file, against scripts/test-new-service-baseline.json; a file without an + * entry may hold none (scripts/lib/count-ratchet.mjs has the rules). The + * allowlist is derived, not kept by hand: a class whose constructor takes no + * parameters (no constructor of its own, or an empty one, down its chain of + * `extends` inside src/ and tests/helpers/) has no wiring to pin, so building + * it with `new` does not count. RealtimeService and FakeRealtimeService are + * the common case. + * + * npm run lint:test-new-service check against the baseline (CI) + * npm run lint:test-new-service -- --update lower the counts; never raises or adds an entry + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { cliMain } from './lib/count-ratchet.mjs'; + +const NEW_CALL = /\bnew\s+([A-Z][A-Za-z0-9_]*(?:Service|Repository))\s*[(<]/g; +const CLASS_DECL = /\bclass\s+([A-Za-z_$][\w$]*)(?:\s*<[^{]*?>)?(?:\s+extends\s+([A-Za-z_$][\w$.]*))?[^{]*\{/g; + +/** The trees whose classes the allowlist is derived from, relative to the server root. */ +const CLASS_ROOTS = ['src', 'tests/helpers']; + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules') continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, files); + else if (/\.[cm]?ts$/.test(name)) files.push(path); + } + return files; +} + +/** The index just past the bracket that closes the one at `open`, skipping strings and comments. */ +function closeOf(text, open) { + const pairs = { '(': ')', '{': '}', '[': ']' }; + const stack = []; + for (let i = open; i < text.length; i++) { + const c = text[i]; + if (c === '/' && text[i + 1] === '/') { + i = text.indexOf('\n', i); + if (i < 0) return text.length; + continue; + } + if (c === '/' && text[i + 1] === '*') { + i = text.indexOf('*/', i + 2) + 1; + if (i <= 0) return text.length; + continue; + } + if (c === '"' || c === "'" || c === '`') { + for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++; + continue; + } + if (pairs[c]) stack.push(pairs[c]); + else if (c === stack[stack.length - 1]) { + stack.pop(); + if (!stack.length) return i + 1; + } + } + return text.length; +} + +/** + * Every class declared in `text`: its parent, and whether its own constructor + * takes parameters (`null` when it declares none). + */ +export function parseClasses(text) { + const classes = []; + for (const match of text.matchAll(CLASS_DECL)) { + const bodyOpen = match.index + match[0].length - 1; + const body = text.slice(bodyOpen, closeOf(text, bodyOpen)); + const ctor = /\bconstructor\s*\(/.exec(body); + let ctorHasParams = null; + if (ctor) { + const open = ctor.index + ctor[0].length - 1; + const params = body + .slice(open + 1, closeOf(body, open) - 1) + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, '') + .trim(); + ctorHasParams = params.length > 0; + } + classes.push({ name: match[1], parent: match[2] ?? null, ctorHasParams }); + } + return classes; +} + +/** + * The names whose constructor takes no parameters, resolved down the extends + * chain. A parent declared outside the scanned trees (a library class) is + * taken to need arguments, so a subclass without a constructor of its own counts. + */ +export function zeroArgClasses(classes) { + const byName = new Map(); + for (const c of classes) if (!byName.has(c.name)) byName.set(c.name, c); + const memo = new Map(); + const resolve = (name, seen = new Set()) => { + if (memo.has(name)) return memo.get(name); + const c = byName.get(name); + let zero; + if (!c || seen.has(name)) zero = false; + else if (c.ctorHasParams !== null) zero = !c.ctorHasParams; + else zero = c.parent === null ? true : resolve(c.parent, new Set([...seen, name])); + memo.set(name, zero); + return zero; + }; + return new Set([...byName.keys()].filter((name) => resolve(name))); +} + +/** The hand-built services and repositories in one file's text, `allowed` left out. */ +export function countNewServices(text, allowed = new Set()) { + let n = 0; + for (const match of text.matchAll(NEW_CALL)) if (!allowed.has(match[1])) n++; + return n; +} + +export const check = { + name: 'test-new-service', + script: 'lint:test-new-service', + root: 'tests', + baseline: 'scripts/test-new-service-baseline.json', + unit: 'hand-built service(s) or repository(ies)', + advice: + 'Take the subject from createTestModule (tests/helpers/test-module.ts) with overrides for its ' + + 'collaborators, or from a booted app with app.get(), instead of calling its constructor.', + prepare: (serverDir) => { + const classes = []; + for (const root of CLASS_ROOTS) { + const dir = join(serverDir, root); + if (!existsSync(dir)) continue; + for (const path of walk(dir)) classes.push(...parseClasses(readFileSync(path, 'utf8'))); + } + return zeroArgClasses(classes); + }, + include: (file) => /\.[cm]?[jt]s$/.test(file), + count: (text, _file, allowed) => countNewServices(text, allowed), +}; + +cliMain(check, import.meta.url); diff --git a/server/scripts/test-size-baseline.json b/server/scripts/test-size-baseline.json new file mode 100644 index 000000000..16d8aa61c --- /dev/null +++ b/server/scripts/test-size-baseline.json @@ -0,0 +1,9 @@ +{ + "tests/unit/nest/atlas.service.test.ts": 2193, + "tests/unit/nest/budget.service.db.test.ts": 2220, + "tests/unit/nest/doc-sync/doc-sync.service.test.ts": 2511, + "tests/unit/nest/journey-domain.service.test.ts": 3876, + "tests/unit/nest/maps.service.test.ts": 3751, + "tests/unit/nest/places.service.test.ts": 2561, + "tests/unit/nest/trips.service.test.ts": 2404 +} diff --git a/server/src/nest/README.md b/server/src/nest/README.md index b78735767..3c3f08847 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -100,7 +100,7 @@ over an injected `AddonsService`. took the same three ids in different orders. `local` is not a provider; both dispatch sites branch on it. - `realtime/`: `/ws` as a Nest gateway. `ws-state.ts` is module state on purpose - (test harnesses build `RealtimeService` outside the container). The socket id + (a `RealtimeService` built outside the container delivers to the same rooms). The socket id stays a monotonic integer, because `broadcast` excludes the originator with `Number(excludeSid)`. `trek-ws.adapter.ts` builds the ws server itself so `verifyClient` keeps the origin check, and attaches the per-socket `error` @@ -196,9 +196,13 @@ events whichever surface made the change. Every module ships two kinds of tests. 1. **Unit**: `tests/unit/nest/.controller.test.ts` and - `.service.test.ts`. Build the class directly with its collaborators - (no `overrideProvider`); assert status codes, the exact `{ error }` bodies and - how inputs are forwarded. Parity assertions live here; there is no separate + `.service.test.ts`. A controller test builds the controller over stub + services; a service test takes the service out of its module with + `createTestModule` (`tests/helpers/test-module.ts`) and replaces the + collaborators it controls through `overrides`, instead of calling the + constructor with positional arguments (`lint:test-new-service` holds the + hand-built rest). Assert status codes, the exact `{ error }` bodies and how + inputs are forwarded. Parity assertions live here; there is no separate parity directory. See `tests/unit/nest/weather.controller.test.ts`. 2. **e2e**: `tests/e2e/.e2e.test.ts` boots the module against a temp SQLite through `tests/e2e/harness.ts` (`createTempDb`, `seedUser`, @@ -209,6 +213,14 @@ Every module ships two kinds of tests. Seed and read rows through the ORM factories in `tests/helpers/factories/`; raw `.prepare()` fixtures are legacy that `lint:test-sql` only lets shrink. +The shared modules are not mocked by path (`lint:test-mocks` counts what is +left): `src/config` answers the fixed values in `tests/helpers/test-config.ts` +in every suite (`overrideTestConfig` for a case that needs others), broadcasts +are recorded on a `FakeRealtimeService` (`tests/helpers/fake-realtime.ts`) +handed in through DI or put over a booted app with `spyOnRealtime(app)`, and +suites that drive a cross-domain surface (the MCP tools, the plugin RPC host) +take AppModule itself from `bootTestApp` (`tests/helpers/test-app.ts`). + `tests/` is outside the build's `include`, so a constructor that grows a parameter does not break a hand-wired `new` in a test at build time; `npm run typecheck:tests` is the gate that catches it. diff --git a/server/tests/unit/scripts/size-lint.test.ts b/server/tests/unit/scripts/size-lint.test.ts index 08358ff4a..85d6fb170 100644 --- a/server/tests/unit/scripts/size-lint.test.ts +++ b/server/tests/unit/scripts/size-lint.test.ts @@ -22,12 +22,18 @@ interface ExecError { const roots: string[] = []; -function serverRoot(files: Record, baseline: unknown = {}): string { +function serverRoot(files: Record, baseline: unknown = {}, testBaseline: unknown = {}): string { // null writes no baseline file at all. const dir = mkdtempSync(path.join(tmpdir(), 'trek-size-lint-')); roots.push(dir); mkdirSync(path.join(dir, 'src'), { recursive: true }); mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + // The tests scope needs a tree with a file in it; cases about it add their own. + mkdirSync(path.join(dir, 'tests'), { recursive: true }); + writeFileSync(path.join(dir, 'tests/setup.ts'), lines(1)); + if (testBaseline !== null) { + writeFileSync(path.join(dir, 'scripts/test-size-baseline.json'), JSON.stringify(testBaseline)); + } for (const [file, text] of Object.entries(files)) { mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); writeFileSync(path.join(dir, file), text); @@ -146,6 +152,43 @@ describe('size-lint.mjs', () => { expect(out).toContain('src/ does not exist'); }); + it('SIZE-011: holds files under tests/ to 2000 lines, test suites included, against their own baseline', () => { + const ok = run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2000) })); + expect(ok.status).toBe(0); + expect(ok.out).toContain('size (tests): 2 file(s), 0 over 2000 lines'); + + const grown = run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2001) })); + expect(grown.status).toBe(1); + expect(grown.out).toContain('FAIL tests/unit/a.test.ts: 2001 lines, the limit is 2000.'); + + const held = { 'tests/unit/a.test.ts': 2500 }; + expect(run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2500) }, {}, held)).status).toBe(0); + expect(run(serverRoot({ 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2501) }, {}, held)).status).toBe(1); + // A test file under src/ is still not part of the source scope. + expect(run(serverRoot({ 'src/a.ts': lines(1), 'src/a.test.ts': lines(3000) })).status).toBe(0); + }); + + it('SIZE-012: the tests baseline lowers with --update and fails closed when it is missing', () => { + const dir = serverRoot( + { 'src/a.ts': lines(1), 'tests/unit/a.test.ts': lines(2100) }, + {}, + { 'tests/unit/a.test.ts': 2400 }, + ); + const stale = run(dir); + expect(stale.status).toBe(1); + expect(stale.out).toContain( + 'FAIL tests/unit/a.test.ts is held at 2400 in scripts/test-size-baseline.json, but it has 2100 lines now.', + ); + expect(run(dir, '--update').status).toBe(0); + expect(JSON.parse(readFileSync(path.join(dir, 'scripts/test-size-baseline.json'), 'utf8'))).toEqual({ + 'tests/unit/a.test.ts': 2100, + }); + + const missing = run(serverRoot({ 'src/a.ts': lines(1) }, {}, null)); + expect(missing.status).toBe(1); + expect(missing.out).toContain('test-size-baseline.json cannot be read'); + }); + it('SIZE-009: --update only lowers, drops files back under the limit, and never adds one', () => { const dir = serverRoot( { diff --git a/server/tests/unit/scripts/test-architecture-ratchets.test.ts b/server/tests/unit/scripts/test-architecture-ratchets.test.ts new file mode 100644 index 000000000..1bf451551 --- /dev/null +++ b/server/tests/unit/scripts/test-architecture-ratchets.test.ts @@ -0,0 +1,193 @@ +/** + * scripts/test-mocks-ratchet.mjs (lint:test-mocks) and + * scripts/test-new-service-ratchet.mjs (lint:test-new-service) on the shared + * rules in scripts/lib/count-ratchet.mjs: the counters, the real scripts + * against throwaway server roots (--dir), and once on this repository, so the + * committed baselines are checked by the unit suite as well. + */ +import { countHeldMocks } from '../../../scripts/test-mocks-ratchet.mjs'; +import { countNewServices, parseClasses, zeroArgClasses } from '../../../scripts/test-new-service-ratchet.mjs'; + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPTS = path.join(__dirname, '../../../scripts'); +const SERVER_ROOT = path.join(__dirname, '../../..'); + +// Spelled in pieces so this file does not count against the checks it tests. +const MOCK = ['vi', '.mock('].join(''); +const DO_MOCK = ['vi', '.doMock('].join(''); +const NEW = ['ne', 'w '].join(''); + +const roots: string[] = []; + +function serverRoot(files: Record, baselineName: string, baseline: unknown = { counts: {} }): string { + const dir = mkdtempSync(path.join(tmpdir(), 'trek-test-ratchet-')); + roots.push(dir); + for (const sub of ['src', 'tests', 'scripts']) mkdirSync(path.join(dir, sub), { recursive: true }); + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), text); + } + if (baseline !== null) writeFileSync(path.join(dir, 'scripts', baselineName), JSON.stringify(baseline)); + return dir; +} + +function run(script: string, dir: string | null, ...args: string[]): { status: number | null; out: string } { + const dirArgs = dir ? [`--dir=${dir}`] : []; + try { + const stdout = execFileSync(process.execPath, [path.join(SCRIPTS, script), ...dirArgs, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { status: 0, out: stdout }; + } catch (err) { + const { status, stdout, stderr } = err as { status: number | null; stdout: string; stderr: string }; + return { status, out: `${stdout}${stderr}` }; + } +} + +afterEach(() => { + for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('lint:test-mocks', () => { + it('TEST-RATCHET-001: counts mocks of the config, realtime and db modules in any spelling', () => { + const text = [ + `${MOCK}'../../src/config', () => ({}));`, + `${MOCK}"../../../src/websocket", () => ({}));`, + `${MOCK}'../../src/db/database', async () => ({}));`, + `${DO_MOCK}'../../src/db/orm.ts', () => ({}));`, + `${MOCK}'../../../src/nest/realtime/ws-state', () => ({}));`, + ].join('\n'); + expect(countHeldMocks(text)).toBe(5); + }); + + it('TEST-RATCHET-002: leaves other module mocks alone, including look-alike paths', () => { + const text = [ + `${MOCK}'../../src/nest/audit/audit-log.logger', () => ({}));`, + `${MOCK}'../../src/app-config', () => ({}));`, + `${MOCK}'../../src/configuration', () => ({}));`, + `${MOCK}'nodemailer', () => ({}));`, + `${MOCK}'../../src/utils/db-helpers', () => ({}));`, + ].join('\n'); + expect(countHeldMocks(text)).toBe(0); + }); + + it('TEST-RATCHET-003: fails a new mock, passes at the baseline and fails a stale entry until --update', () => { + const mock = `${MOCK}'../../src/config', () => ({}));\n`; + const grown = run( + 'test-mocks-ratchet.mjs', + serverRoot({ 'tests/unit/a.test.ts': mock }, 'test-mocks-baseline.json'), + ); + expect(grown.status).toBe(1); + expect(grown.out).toContain('FAIL tests/unit/a.test.ts: 1 module mock(s) of config, realtime or db'); + + const held = { counts: { 'tests/unit/a.test.ts': 1 } }; + expect( + run('test-mocks-ratchet.mjs', serverRoot({ 'tests/unit/a.test.ts': mock }, 'test-mocks-baseline.json', held)) + .status, + ).toBe(0); + + const dir = serverRoot({ 'tests/unit/a.test.ts': 'nothing mocked here\n' }, 'test-mocks-baseline.json', held); + const stale = run('test-mocks-ratchet.mjs', dir); + expect(stale.status).toBe(1); + expect(stale.out).toContain('npm run lint:test-mocks -- --update'); + expect(run('test-mocks-ratchet.mjs', dir, '--update').status).toBe(0); + expect(JSON.parse(readFileSync(path.join(dir, 'scripts/test-mocks-baseline.json'), 'utf8'))).toEqual({ + counts: {}, + }); + }); + + it('TEST-RATCHET-004: the committed baseline holds this repository', () => { + const { status, out } = run('test-mocks-ratchet.mjs', null); + expect(out).toContain('held at their baseline'); + expect(status).toBe(0); + }); +}); + +describe('lint:test-new-service', () => { + it('TEST-RATCHET-010: reads each class, its parent and whether its constructor takes parameters', () => { + const text = [ + 'export class A { constructor(private readonly b: B) {} }', + 'class C extends A {}', + 'export class D { constructor(/* nothing */) {} }', + 'export class E { run() { return 1; } }', + 'export class F extends E { constructor() { super(); } }', + ].join('\n'); + expect(parseClasses(text)).toEqual([ + { name: 'A', parent: null, ctorHasParams: true }, + { name: 'C', parent: 'A', ctorHasParams: null }, + { name: 'D', parent: null, ctorHasParams: false }, + { name: 'E', parent: null, ctorHasParams: null }, + { name: 'F', parent: 'E', ctorHasParams: false }, + ]); + }); + + it('TEST-RATCHET-011: a class needs no wiring when nothing down its extends chain takes parameters', () => { + const allowed = zeroArgClasses([ + { name: 'PlainService', parent: null, ctorHasParams: null }, + { name: 'WiredService', parent: null, ctorHasParams: true }, + { name: 'EmptyCtorService', parent: 'WiredService', ctorHasParams: false }, + { name: 'InheritsWiringService', parent: 'WiredService', ctorHasParams: null }, + { name: 'InheritsPlainService', parent: 'PlainService', ctorHasParams: null }, + { name: 'LibraryRepository', parent: 'EntityRepository', ctorHasParams: null }, + ]); + expect([...allowed].sort()).toEqual(['EmptyCtorService', 'InheritsPlainService', 'PlainService']); + }); + + it('TEST-RATCHET-012: counts hand-built services and repositories, not allowed or other classes', () => { + const text = [ + `const a = ${NEW}TodoService(permissions, realtime);`, + `const b = ${NEW}TripsRepository(em);`, + `const c = ${NEW}Map();`, + `const d = ${NEW}RealtimeService();`, + `const e = ${NEW}Store(1);`, + `const f = ${NEW}CacheService(store);`, + ].join('\n'); + expect(countNewServices(text, new Set(['RealtimeService']))).toBe(3); + }); + + it('TEST-RATCHET-013: derives the allowlist from src/ and tests/helpers/ when it runs', () => { + const files = { + 'src/plain.service.ts': 'export class PlainService { ping() {} }', + 'src/wired.service.ts': 'export class WiredService { constructor(private readonly p: PlainService) {} }', + 'tests/helpers/fake.ts': + 'export class FakeWiredService extends WiredService { constructor() { super(null as never); } }', + 'tests/unit/a.test.ts': [ + `${NEW}PlainService();`, + `${NEW}FakeWiredService();`, + `${NEW}WiredService(${NEW}PlainService());`, + ].join('\n'), + }; + const fails = run('test-new-service-ratchet.mjs', serverRoot(files, 'test-new-service-baseline.json')); + expect(fails.status).toBe(1); + expect(fails.out).toContain('FAIL tests/unit/a.test.ts: 1 hand-built service(s) or repository(ies)'); + + const held = { counts: { 'tests/unit/a.test.ts': 1 } }; + expect(run('test-new-service-ratchet.mjs', serverRoot(files, 'test-new-service-baseline.json', held)).status).toBe( + 0, + ); + }); + + it('TEST-RATCHET-014: fails closed on a missing baseline', () => { + const { status, out } = run('test-new-service-ratchet.mjs', serverRoot({ 'tests/a.test.ts': '' }, 'x.json', null)); + expect(status).toBe(1); + expect(out).toContain('test-new-service-baseline.json cannot be read'); + }); + + it('TEST-RATCHET-015: the committed baseline holds this repository', () => { + const { status, out } = run('test-new-service-ratchet.mjs', null); + expect(out).toContain('held at their baseline'); + expect(status).toBe(0); + }); +}); + +describe('the repository root the scripts default to', () => { + it('TEST-RATCHET-020: is this server workspace', () => { + expect(readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8')).toContain('"lint:test-mocks"'); + }); +});