diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 789810a8d..a0b66a61f 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,20 +10,23 @@ permissions: contents: read jobs: - # The suite outgrew a single runner, then a half-suite outgrew a slow one, - # and then a third of it did too. Three shards of about 68 files cleared in - # 13 minutes on an ordinary runner, but a slow runner day ran every test at - # twice its usual duration and the first shard was still working through - # its list when the 25-minute guard cut it off: three of four pushes to main - # between 2026-08-31 and 2026-09-01 failed that way, with green tests logged - # up to the last second. Four shards keep each slice around 50 files, and - # the guard sits at three times the ordinary run rather than under two. A - # wedged test still surfaces through vitest's own 60-second budget, so the - # job timeout only needs to catch a container that never boots. Each shard - # boots its own Postgres testcontainer. + # Four shards of about equal duration, each running its files in parallel + # against one database per worker. The shards used to be four slices of + # equal file count, run serially: 954 s to 1 449 s, because a slice holding + # the sharing matrix and the backup files took half again as long as one + # holding small files. `--shard` now goes through the duration sequencer + # (`tests/durations/sequencer.ts`, timings in + # `tests/durations/integration.json`), and every file in a shard runs on + # one of three workers, each against its own clone of the migrated template + # (`tests/integration/worker-database.ts`). Three workers leave a core of + # the four-core runner to the Postgres container. The job guard stays well + # above an ordinary run; a wedged test still surfaces through vitest's own + # 60-second budget, so the guard only needs to catch a container that never + # boots. Each shard boots its own Postgres testcontainer and uploads its + # JSON report, which `scripts/test-durations.mjs` turns into the timing file. shard: runs-on: ubuntu-24.04 - timeout-minutes: 40 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -48,8 +51,13 @@ jobs: # vitest a stray positional argument and the shard flag never applies — # both "shards" then run the full suite. Proven by a paired local run # before this workflow shipped. - - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4 + - run: >- + pnpm exec vitest run --config vitest.integration.config.mts + --shard=${{ matrix.shard }}/4 + --reporter=default --reporter=json + --outputFile.json=integration-report-${{ matrix.shard }}.json env: + HEALTHLOG_INTEGRATION_WORKERS: "3" # The integration suite boots a Postgres testcontainer and points # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. # These env vars satisfy code paths that read them at module load. @@ -63,6 +71,15 @@ jobs: ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" SESSION_SECRET: "integration-tests" + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: integration-report-${{ matrix.shard }} + path: integration-report-${{ matrix.shard }}.json + if-no-files-found: ignore + retention-days: 14 + # Branch protection requires a check named exactly "integration". This job # carries that name and answers for every shard: it fails when any shard # failed or was cancelled, so a half-green matrix cannot satisfy the gate. diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 000000000..cc92b01ad --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,48 @@ +name: Mutation tests + +# Stryker on the core libraries (stryker.config.mjs): encryption at rest, +# medication compliance, rollups, source ranking, unit conversion and backup +# validation. Too slow for a pull request; it runs once a night and on +# demand, and fails when a module's score drops below its floor in +# stryker.floors.json. The HTML report is uploaded either way. +on: + schedule: + - cron: "23 1 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + mutation: + runs-on: ubuntu-24.04 + timeout-minutes: 120 + name: mutation (core libraries) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - name: Run Stryker + run: pnpm exec stryker run --concurrency 4 + + - name: Check the per-module floors + run: node scripts/mutation-floors.mjs | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: mutation-report + path: reports/mutation/ + if-no-files-found: ignore + retention-days: 30 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ed3843be4..406816ccc 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -15,11 +15,72 @@ permissions: contents: read jobs: - quality: - name: Lint, Typecheck & Test + # The static checks: everything in the old single quality job except the + # unit suite, which runs sharded below. + static: + name: Lint, Typecheck & OpenAPI runs-on: ubuntu-24.04 if: github.event_name != 'schedule' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + # Blocking since v1.15.20 — the settings-split refactor that kept + # this warn-only is long done and the tree lints clean. + - name: ESLint + run: pnpm lint + + # Prettier formatting gate. The format pass excludes generator-owned + # artifacts via .prettierignore (notably docs/api/openapi.yaml, which + # the OpenAPI drift check below enforces byte-for-byte). + - name: Prettier format check + run: pnpm format:check + + - name: TypeScript strict check + run: pnpm typecheck + + # OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25). + # + # The Zod registry is the source of truth for the public API contract. + # Any route signature change must be reflected in `docs/api/openapi.yaml` + # so downstream consumers (notably the v1.5 iOS Swift codegen) stay in + # lockstep. If this step fails, run `pnpm openapi:generate` locally and + # commit the regenerated spec. + - name: OpenAPI spec drift check + run: | + pnpm openapi:generate + if ! git diff --exit-code docs/api/openapi.yaml; then + echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract." + exit 1 + fi + echo "::notice::OpenAPI spec in sync with Zod source schemas." + + # The unit suite in three slices of about equal duration. `--shard` goes + # through the duration sequencer in `vitest.config.mts`, which deals files + # out by their measured time (`tests/durations/unit.json`) instead of by + # count. Each slice uploads its JSON report, which is what + # `scripts/test-durations.mjs` regenerates the timing file from. + unit: + runs-on: ubuntu-24.04 + if: github.event_name != 'schedule' + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + name: unit shard ${{ matrix.shard }}/3 + steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -38,7 +99,8 @@ jobs: # real browser and measures the boxes. Without a browser here it caught # its own launch failure and passed anyway, so from its first day until # v1.38 it measured nothing on any run of this job. The browser is part - # of the gate now; the test refuses to skip when CI is set. + # of the gate now; the test refuses to skip when CI is set. Every slice + # installs it, because the sequencer decides which slice the file lands in. # # Headless shell only: `chromium.launch()` resolves to # chromium_headless_shell, which is what the failing runs named, and the @@ -54,38 +116,40 @@ jobs: - run: pnpm exec playwright install --only-shell chromium - # Blocking since v1.15.20 — the settings-split refactor that kept - # this warn-only is long done and the tree lints clean. - - name: ESLint - run: pnpm lint - - # Prettier formatting gate. The format pass excludes generator-owned - # artifacts via .prettierignore (notably docs/api/openapi.yaml, which - # the OpenAPI drift check below enforces byte-for-byte). - - name: Prettier format check - run: pnpm format:check - - - name: TypeScript strict check - run: pnpm typecheck - + # vitest directly, not `pnpm test --`: pnpm hands the separator through + # literally and the shard flag would never reach vitest. - name: Unit tests - run: pnpm test + run: >- + pnpm exec vitest run --shard=${{ matrix.shard }}/3 + --reporter=default --reporter=json + --outputFile.json=unit-report-${{ matrix.shard }}.json - # OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25). - # - # The Zod registry is the source of truth for the public API contract. - # Any route signature change must be reflected in `docs/api/openapi.yaml` - # so downstream consumers (notably the v1.5 iOS Swift codegen) stay in - # lockstep. If this step fails, run `pnpm openapi:generate` locally and - # commit the regenerated spec. - - name: OpenAPI spec drift check + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: unit-report-${{ matrix.shard }} + path: unit-report-${{ matrix.shard }}.json + if-no-files-found: ignore + retention-days: 14 + + # Branch protection requires a check named exactly "Lint, Typecheck & Test". + # That used to be one job running everything in turn; it is now this one, + # which answers for the static checks and every unit slice, so a half-green + # set cannot satisfy the gate. + quality: + name: Lint, Typecheck & Test + runs-on: ubuntu-24.04 + needs: [static, unit] + if: always() && github.event_name != 'schedule' + timeout-minutes: 5 + steps: + - name: The static checks and every unit slice finished green run: | - pnpm openapi:generate - if ! git diff --exit-code docs/api/openapi.yaml; then - echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract." + if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then + echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}" exit 1 fi - echo "::notice::OpenAPI spec in sync with Zod source schemas." # Not on pull requests. `pnpm audit` answers a question about the published # advisory database, not about the diff: the same lockfile passes on Monday diff --git a/.github/workflows/test-nightly.yml b/.github/workflows/test-nightly.yml new file mode 100644 index 000000000..b33b9b5c8 --- /dev/null +++ b/.github/workflows/test-nightly.yml @@ -0,0 +1,169 @@ +name: Tests (nightly, shuffled) + +# Looks for what a pull request run cannot see by design. +# +# - Order dependence. Both suites run with files and tests in a random order +# (`--sequence.shuffle`), seeded from the clock and printed at the top of +# the log and in the job summary. A failure that does not reproduce on a +# pull request reproduces here with `--sequence.seed=`. +# - New property-test inputs. Pull requests run every fast-check property +# with a fixed seed; this run picks a fresh one (`FC_SEED`) and five times +# the cases, and fast-check prints the seed and the shrunk counterexample +# on a failure (see tests/property/fast-check.ts). +# - Stale shard timings. The integration reports of this run are merged into +# a regenerated `tests/durations/integration.json`, uploaded as an +# artifact for a maintainer to commit when the shards drift apart. +# +# Not a gate: nothing here blocks a merge. A red run is an issue to open. +on: + schedule: + - cron: "41 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + unit: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + name: unit (shuffled) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - name: Cache Playwright headless shell + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: playwright-shell-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-shell-${{ runner.os }}- + + - run: pnpm exec playwright install --only-shell chromium + + - name: Pick the seeds + run: | + echo "TEST_SEED=$(date +%s)" >> "$GITHUB_ENV" + echo "FC_SEED=$(( $(date +%s%N) % 2147483647 ))" >> "$GITHUB_ENV" + + - name: Unit tests, shuffled + env: + FC_NUM_RUNS: "500" + run: | + echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY" + pnpm exec vitest run --sequence.shuffle --sequence.seed="${TEST_SEED}" \ + --reporter=default --reporter=json --outputFile.json=unit-report.json + + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nightly-unit-report + path: unit-report.json + if-no-files-found: ignore + retention-days: 14 + + integration: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + name: integration shard ${{ matrix.shard }}/4 (shuffled) + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + # One seed per run, shared by the shards through the run id, so every + # shard shuffles the same way and a failure names one seed to replay. + - name: Pick the seeds + run: | + echo "TEST_SEED=${{ github.run_id }}" >> "$GITHUB_ENV" + echo "FC_SEED=$(( ${{ github.run_id }} % 2147483647 ))" >> "$GITHUB_ENV" + + - name: Integration tests, shuffled + env: + HEALTHLOG_INTEGRATION_WORKERS: "3" + FC_NUM_RUNS: "100" + # Quoted for the reason given in integration.yml. + API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" + SESSION_SECRET: "integration-tests" + run: | + echo "Order seed ${TEST_SEED}, property seed ${FC_SEED}" | tee -a "$GITHUB_STEP_SUMMARY" + pnpm exec vitest run --config vitest.integration.config.mts \ + --shard=${{ matrix.shard }}/4 \ + --sequence.shuffle --sequence.seed="${TEST_SEED}" \ + --reporter=default --reporter=json \ + --outputFile.json=integration-report-${{ matrix.shard }}.json + + - name: Upload the timing report + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: nightly-integration-report-${{ matrix.shard }} + path: integration-report-${{ matrix.shard }}.json + if-no-files-found: ignore + retention-days: 14 + + # Merge the timings of this run into fresh timing files. Uploaded, not + # committed: the workflow has read-only access, and a maintainer decides + # whether the drift is worth a commit. + durations: + runs-on: ubuntu-24.04 + needs: [unit, integration] + if: always() + timeout-minutes: 10 + name: regenerate shard timings + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: nightly-*-report* + path: reports + + - name: Regenerate + run: | + shopt -s nullglob + integration=(reports/nightly-integration-report-*/*.json) + unit=(reports/nightly-unit-report/*.json) + if [ ${#integration[@]} -gt 0 ]; then + node scripts/test-durations.mjs integration "${integration[@]}" + fi + if [ ${#unit[@]} -gt 0 ]; then + node scripts/test-durations.mjs unit "${unit[@]}" + fi + git diff --stat -- tests/durations | tee -a "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: test-durations + path: tests/durations/*.json + retention-days: 14 diff --git a/.gitignore b/.gitignore index 9cd46eebf..655bb269e 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,6 @@ next-env.d.ts # same reason in a different tool. /.wt-*/ /.worktrees/ +# Mutation testing (stryker.config.mjs): reports and the sandbox copy. +/reports/mutation +/.stryker-tmp diff --git a/Dockerfile b/Dockerfile index 15198e563..dde3d43ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# The dependency patches pnpm-workspace.yaml names; the frozen install +# refuses to run without them. +COPY patches patches COPY scripts/prepare.mjs scripts/prepare.mjs RUN pnpm install --frozen-lockfile --prod=false diff --git a/docs/README.md b/docs/README.md index 5ade54148..7b99ebc09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,6 +62,9 @@ notes, and machine-readable specs that ship alongside the source: - [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the large-account benchmark (latency and statements per request) and the statement budgets on the hot routes. +- [`contributing/testing.md`](./contributing/testing.md) — which suite a + test belongs in, the route test kit, the integration database, sharding, + property and mutation tests, and the flaky-test quarantine. - [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where it disagrees with the UI standards, the standards win. diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md new file mode 100644 index 000000000..fddd0c974 --- /dev/null +++ b/docs/contributing/testing.md @@ -0,0 +1,219 @@ +# Testing + +How the suites are organised, which one a new test belongs in, and the tools +that keep them fast and honest. Commands are in `CONTRIBUTING.md`; this page +is about choosing and writing tests. + +## Which suite + +| What you are testing | Where | Runs with | +| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------- | +| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` | +| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` | +| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` | +| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` | +| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` | +| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` | +| A journey through the real UI | `e2e/` | `pnpm e2e` | + +The rule behind the table: put a behavioural test at the level where the +risky collaborator is real. For a route that collaborator is Postgres and the +API kit (`apiHandler`, `requireAuth`, the envelope), so a route test runs +against both. A unit test that replaces Prisma with a mock and then asserts on +the calls it received checks the query text, not the behaviour; it passes when +the query is wrong and fails when it is rewritten correctly. Do not write new +ones: `src/__tests__/test-realism-ratchet.test.ts` counts route tests that mock +`@/lib/db` wholesale or stub `apiHandler`, and `as never` casts in tests, and +those numbers may only fall. Mock at the process boundary you do not own instead: provider HTTP +(`safeFetch`), APNs, SMTP, the model. + +## The route test kit + +`tests/kit/` holds two modules for tests under `tests/integration/`. + +`factories.ts` writes typed fixtures through the application's Prisma client +and returns the stored rows: `makeUser`, `makeAdmin`, `makeSession`, +`makeToken`, `makeMeasurement`, `makeMedication`, `makeWorkout`, `makeGrant`. +Overrides are the Prisma create input of that model, so the compiler checks +every fixture field; there is no reason for `as never` in a kit test. +Sessions and tokens are minted by the server's own `createSession` and +`issueApiToken`, so a fixture cannot drift into a shape authentication no +longer accepts. + +`call-route.ts` exports `callRoute(handler, options)`, which builds a real +`NextRequest`, fills the `next/headers` stand-in from `auth`, calls the +exported handler of a `route.ts` (and with it the real `apiHandler`), and +returns `{ status, data, error, meta, body, headers }`. + +```ts +import { GET } from "@/app/api/tokens/route"; +import { DELETE } from "@/app/api/tokens/[id]/route"; + +import { callRoute } from "../../kit/call-route"; +import { makeSession, makeToken, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +beforeEach(() => truncateAllTables(getPrismaClient())); + +it("revokes the caller's token and the token stops authenticating", async () => { + const owner = await makeUser(); + const phone = await makeToken(owner); + + const res = await callRoute<{ revoked: boolean }>(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: phone.tokenId }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + const row = await getPrismaClient().apiToken.findUniqueOrThrow({ + where: { id: phone.tokenId }, + }); + expect(row.revoked).toBe(true); + + const after = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + expect(after.status).toBe(401); +}); +``` + +`auth` is a `SessionFixture`, a `BearerFixture`, or `anonymous`. `route` is +the path as the OpenAPI registry names it, with `{…}` segments filled from +`params`. + +Every call is also a contract check. The status must be one the registry +(`src/lib/openapi/routes/`) declares for that path and method, a JSON body +must parse with the registered schema, and a key the schema does not declare +fails the call: a client generated from the contract cannot see that field, so +nobody promised to keep it. The one generic answer accepted without its own +entry is the scope gate's 403 `auth.scope.insufficient`, which the shared 401 +documents for every authenticated operation. A test that calls an unregistered +route on purpose passes `contract: { skip: "" }`. +`tests/integration/routes/kit-contract.test.ts` proves the check can fail. + +Assert what a person or a client would notice: the stored row, the response, +the next request's answer. Not the calls a mock received. + +The kit runs the handler, not the edge. Security headers, the public-path +allowlist and demo mode live in `src/proxy.ts` and are tested there. + +### The settings round trip + +`tests/integration/settings-round-trip.test.ts` holds one table with an entry +for every `User` column the backup classifies as `SETTING` +(`USER_COLUMN_BACKUP_CLASS` in `src/lib/export/backup-plan.ts`). For each one +it sets a non-default value through the API, reads it back through the API and +through `/api/auth/me` where that publishes it, exports a backup, restores it +into a fresh account and reads it back there. A new setting column without a +table entry fails the file. A column with no write route says why in its +entry. + +## The integration database + +Global setup boots one Postgres container and migrates one template database. +Each Vitest worker clones it on its first file (`CREATE DATABASE … TEMPLATE`, +see `tests/integration/worker-database.ts`), so files run in parallel without +seeing each other's rows, advisory locks or queue schema. The worker count +follows the host (half the cores, at most eight) unless +`HEALTHLOG_INTEGRATION_WORKERS` pins it; CI uses three. + +`truncateAllTables` empties every table the migrations created and puts back +the rows they seed (the mood-tag and symptom catalogues), so a test starts +from what a fresh install holds. The list is the catalogue, read from the +template, not something to maintain. `START_EMPTY_TABLES` names the one +seeded table a test starts without, and why. Schemas created at runtime +(pg-boss) are dropped when the next file starts in the worker. + +`next/headers` and the boot compatibility probe are mocked for every file in +`environment-setup.ts`. A file needs its own `vi.mock("next/headers")` only +when it wants different behaviour. + +## Speed and sharding + +CI splits both suites by measured duration, not by file count: the sequencer +in `tests/durations/sequencer.ts` reads `tests/durations/unit.json` and +`tests/durations/integration.json` and deals files out longest first to the +shard with the least work. A file without a measurement counts as the median. +Regenerate a timing file from Vitest JSON reports when the shards drift apart: + +```sh +pnpm exec vitest run --reporter=default --reporter=json --outputFile.json=unit.json +node scripts/test-durations.mjs unit unit.json +``` + +The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file +regenerated from CI timings, which is the better source. + +The unit suite is three projects, decided per file from its source +(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a +worker, `isolated` gives each file a fresh one. A file lands in `isolated` when +it mocks, spies, stubs env or globals, fakes timers or resets modules. If a +file needs isolation for a reason the classifier cannot see, put a +`// vitest-isolate` comment in it. + +## Property tests + +`fast-check` properties live in `*.property.test.ts` files and take their +parameters from `tests/property/fast-check.ts`. Pull requests run every +property with a fixed seed, so a run is reproducible on any machine. The +nightly run uses a fresh `FC_SEED` and more cases (`FC_NUM_RUNS`); a failure +prints the seed and the shrunk counterexample, and +`FC_SEED= pnpm test ` replays it. + +Write a property when the code has a law: an inverse (encrypt and decrypt, a +unit and its inverse, a wall clock and its instant), an invariant (a rate +between 0 and 100, a day that contains its own instants), or an oracle small +enough to be obviously right (a day-by-day walk of a weekly schedule). Do not +reimplement the function under test in the property. + +## Mutation testing + +Stryker runs on the core libraries (`stryker.config.mjs`) nightly and on +demand (`.github/workflows/mutation.yml`), not on pull requests. Each module's +score is checked against a floor in `stryker.floors.json` by +`node scripts/mutation-floors.mjs`. A floor sits just below the last measured +score; raise it when a change raises the score, never lower it to make a run +pass. The Vitest runner is patched for Vitest 5's test names +(`patches/@stryker-mutator__vitest-runner@10.0.0.patch`); without it every +test inside a `describe` block is filtered out of the mutant runs and almost +every mutant survives. Locally: + +```sh +pnpm exec stryker run +node scripts/mutation-floors.mjs +``` + +## Flaky tests + +Vitest runs without retries, and stays that way: a retry that turns red into +green hides the flake it was meant to report. e2e keeps two retries with +`failOnFlakyTests`, so a test that needed one still fails the run. + +A flaky unit or integration test may be taken out of the run only through +`tests/quarantine/quarantine.json`: + +```json +{ + "file": "tests/integration/example.test.ts", + "test": "outer describe > the test name", + "issue": "#1234", + "reason": "fails about one run in twenty when the queue worker is slow", + "added": "2026-10-22", + "expires": "2026-11-05" +} +``` + +The test is reported as skipped with its issue while the entry is live. An +entry expires at most 14 days after it was added; after its expiry date the +test runs again, and `tests/quarantine/quarantine-guard.test.ts` fails until +the entry is removed. The guard also fails on an entry that names a file or a +test that does not exist. Fix the flake, or delete the test if it no longer +earns its place; do not extend the date. + +Order dependence is looked for on purpose: the nightly workflow runs both +suites with `--sequence.shuffle` and prints the seed. Replay a failure with +`--sequence.shuffle --sequence.seed=`. diff --git a/package.json b/package.json index 15a5b2028..5d19a1764 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,8 @@ "@axe-core/playwright": "^4.13.0", "@next/bundle-analyzer": "^16.3.8", "@playwright/test": "^1.64.0", + "@stryker-mutator/core": "10.0.0", + "@stryker-mutator/vitest-runner": "10.0.0", "@tailwindcss/postcss": "^4", "@tesseract.js-data/deu": "1.0.0", "@tesseract.js-data/eng": "1.0.0", @@ -122,6 +124,7 @@ "ajv": "^8.20.0", "eslint": "^9", "eslint-config-next": "16.3.8", + "fast-check": "4.10.2", "knip": "^6.41.0", "lefthook": "^2.2.2", "pdf-parse": "^2.4.5", diff --git a/patches/@stryker-mutator__vitest-runner@10.0.0.patch b/patches/@stryker-mutator__vitest-runner@10.0.0.patch new file mode 100644 index 000000000..6b0bd2739 --- /dev/null +++ b/patches/@stryker-mutator__vitest-runner@10.0.0.patch @@ -0,0 +1,48 @@ +diff --git a/dist/src/stryker-setup.js b/dist/src/stryker-setup.js +index aecfacbe47b285c272c47e54b3d66a716adfdc41..a6864060e466a4dde49f6d32d9fa8bc8e416baaa 100644 +--- a/dist/src/stryker-setup.js ++++ b/dist/src/stryker-setup.js +@@ -57,13 +57,17 @@ else { + } + // Stryker disable all: this file is copied to the sandbox dir + function collectTestName({ name, suite, }) { ++ // Vitest 5 joins a test's full name with " > " and matches ++ // testNamePattern against that; a space-joined name matched nothing under ++ // a describe block, so every such test was skipped in mutant runs. The ++ // file task ends the chain and is not part of the name. + const nameParts = [name]; + let currentSuite = suite; +- while (currentSuite) { ++ while (currentSuite && currentSuite.file !== currentSuite) { + nameParts.unshift(currentSuite.name); + currentSuite = currentSuite.suite; + } +- return nameParts.join(' ').trim(); ++ return nameParts.filter(Boolean).join(' > ').trim(); + } + function toRawTestId(test) { + return `${test.file?.filepath ?? 'unknown.js'}#${collectTestName(test)}`; +diff --git a/dist/src/test-helpers.js b/dist/src/test-helpers.js +index 56cc786f30c524ae18ce87eef1a67fdf26b703c5..1ad0552d5764d6b8c1ff2215e2e6fb49d7f6bc83 100644 +--- a/dist/src/test-helpers.js ++++ b/dist/src/test-helpers.js +@@ -2,13 +2,17 @@ + // This file is used from the testing environment (via stryker-setup.js) and thus could be loaded into the browser (when using vitest with browser mode). + // Thus we should avoid unnecessary dependencies in this file. + export function collectTestName({ name, suite, }) { ++ // Vitest 5 joins a test's full name with " > " and matches ++ // testNamePattern against that; a space-joined name matched nothing under ++ // a describe block, so every such test was skipped in mutant runs. The ++ // file task ends the chain and is not part of the name. + const nameParts = [name]; + let currentSuite = suite; +- while (currentSuite) { ++ while (currentSuite && currentSuite.file !== currentSuite) { + nameParts.unshift(currentSuite.name); + currentSuite = currentSuite.suite; + } +- return nameParts.join(' ').trim(); ++ return nameParts.filter(Boolean).join(' > ').trim(); + } + export function toRawTestId(test) { + return `${test.file?.filepath ?? 'unknown.js'}#${collectTestName(test)}`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a51aab68..aee6822ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,9 @@ overrides: brace-expansion@>=4.0.0 <5.0.12: ^5.0.12 undici@>=7.0.0 <7.29.1: ^7.29.1 +patchedDependencies: + '@stryker-mutator/vitest-runner@10.0.0': 9ad3e9855fd9781fd36e872ed42c5f934f91b962d6830e6cc56d56bf16e1dabd + importers: .: @@ -201,6 +204,12 @@ importers: '@playwright/test': specifier: ^1.64.0 version: 1.64.0 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.5) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(patch_hash=9ad3e9855fd9781fd36e872ed42c5f934f91b962d6830e6cc56d56bf16e1dabd)(@stryker-mutator/core@10.0.0(@types/node@26.6.5))(vitest@5.0.4) '@tailwindcss/postcss': specifier: ^4 version: 4.3.3 @@ -243,6 +252,9 @@ importers: eslint-config-next: specifier: 16.3.8 version: 16.3.8(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + fast-check: + specifier: 4.10.2 + version: 4.10.2 knip: specifier: ^6.41.0 version: 6.41.0 @@ -373,119 +385,292 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.7': resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.7': resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} + '@babel/core@8.0.7': + resolution: {integrity: sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-annotate-as-pure@7.29.7': resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@8.0.0': + resolution: {integrity: sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.7': + resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-create-class-features-plugin@7.29.7': resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-create-class-features-plugin@8.0.7': + resolution: {integrity: sha512-7ug3KrOhNVPPxFFLTel0/pMzimKzU/vTlNJKVL/dKguCpmNJZjjibyFbWg+Qs8QHmiIZPNuZ2UNnDGdHWGEpOg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-member-expression-to-functions@7.29.7': resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} + '@babel/helper-member-expression-to-functions@8.0.5': + resolution: {integrity: sha512-GLe05QD98BkNFTkjaqeqF9QSRoHLKrrB4tpoplyQuuPrQJ72rtmkM4wvqJLX/sjZPqkbK5MUYSsuUTqdJoOVjA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@8.0.0': + resolution: {integrity: sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@8.0.6': + resolution: {integrity: sha512-YUi+3X0cb0v97xKhuqkLdrwQtP6XXv1eVbhLRCFyMwG481UskbMQk4mjH7K/Jg3oJ1KLskvFwwq4kF/QnABkWQ==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-optimise-call-expression@7.29.7': resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} engines: {node: '>=6.9.0'} + '@babel/helper-optimise-call-expression@8.0.0': + resolution: {integrity: sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-plugin-utils@7.29.7': resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@8.0.1': + resolution: {integrity: sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-replace-supers@7.29.7': resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-replace-supers@8.0.1': + resolution: {integrity: sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} engines: {node: '>=6.9.0'} + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + resolution: {integrity: sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helpers@7.29.7': resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.7': + resolution: {integrity: sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.9': resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.7': + resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/plugin-proposal-decorators@8.0.2': + resolution: {integrity: sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-syntax-decorators@8.0.1': + resolution: {integrity: sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-syntax-jsx@7.29.7': resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-jsx@8.0.1': + resolution: {integrity: sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-syntax-typescript@7.29.7': resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-typescript@8.0.3': + resolution: {integrity: sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-destructuring@8.0.5': + resolution: {integrity: sha512-evjxc5fGvpXG2WMGSalpX8IJZVumLIDgk5r5eRhvXRDqFX/GPWq1BeRfJalPCcwJfrR5A/wvegUhx9W5Nr7/mg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-explicit-resource-management@8.0.5': + resolution: {integrity: sha512-Vr20jk/ZxRGu30o0jYvcKl2Pf0tiyr8H9q2ZGxYD6yjOpv9DdP6//I85q0cSOdADZhsu/ZZoApjGVCfl4xnVJw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-modules-commonjs@7.29.7': resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-modules-commonjs@8.0.1': + resolution: {integrity: sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-display-name@8.0.1': + resolution: {integrity: sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx-development@8.0.1': + resolution: {integrity: sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-jsx@8.0.1': + resolution: {integrity: sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/plugin-transform-react-pure-annotations@8.0.1': + resolution: {integrity: sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/plugin-transform-typescript@7.29.9': resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@8.0.6': + resolution: {integrity: sha512-WF3yRlQtClzScvQbWct00ix63+w3DPiyU0WmCBU4YqK539n3VvJFElElpevoD8wfu6953CbiNoUZBi2B7/5jXw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + + '@babel/preset-react@8.0.1': + resolution: {integrity: sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/preset-typescript@7.29.7': resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/preset-typescript@8.0.7': + resolution: {integrity: sha512-sUFLFphAG6YNjCnJpSKpyoNxkfyeM2KNuUjJJN0R6dZusMcK2V3Fg11MVHPFvb37AZeDNocc+xM748vdh9z82Q==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@babel/core': ^8.0.0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -494,14 +679,26 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.10': resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==} engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.7': + resolution: {integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -853,12 +1050,12 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@2.0.7': - resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + '@inquirer/ansi@2.0.9': + resolution: {integrity: sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/confirm@6.1.1': - resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + '@inquirer/checkbox@5.2.7': + resolution: {integrity: sha512-/+QmGE/9bvE/EobaZDnzYe+T+QWoJrnmxwBQNtoi2OVSUR9d1znYsNyEoUrOZvFSxDYttrhzRIpXpaLkN+iIsg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -866,8 +1063,8 @@ packages: '@types/node': optional: true - '@inquirer/core@11.2.1': - resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + '@inquirer/confirm@6.3.4': + resolution: {integrity: sha512-ZwnaZZDa1zxsyVrN32s447ydMnKcw7e8WWNT+R+Qsu0zAn8Rs6yi9d1B80Kob1Ul2cfUi7t8JD2t4/K7uEZpqA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -875,12 +1072,111 @@ packages: '@types/node': optional: true - '@inquirer/figures@2.0.7': - resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + '@inquirer/core@12.0.5': + resolution: {integrity: sha512-c0ZSnO9oVk/IlEGoZK3u8b3IZQeu9I6jUkXJSQnQEVRXsjKvtAq3AGqFL2LgfJhlzMZne08pZHWRWasIcI3Rew==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.3.5': + resolution: {integrity: sha512-6lU3/pPx5z3lHWejFW061MV4qZooBQYv/+EeGhNOPiP/i+hrYsHfxxBnnDGsAfg8YdLUd69YckoeOjtr3QuAUg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.7': + resolution: {integrity: sha512-eiJ31YFCaB6fLoJCqLIn8S6eqUyME6vwr8KcP5h8cxmA+bV9RRVPO+kywoxb7G63HduDXV6sN/SM7o2R+ahEcQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.6': + resolution: {integrity: sha512-rWmw6WG8JZ7LMn/srXEylYGhL6EXXJeROE7/4x4GREh6NgzqJBDbIgpNGsYoLPfobeb4VZtEYzPGnT5EFNYhiQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/type@4.0.7': - resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + '@inquirer/input@5.1.8': + resolution: {integrity: sha512-psOSTgkL8y/OtgJJYbqJ0oISr2QBE0U1Ro/qQdA/EKjeJiwiUTQgQmq8o4ESNzVOWthIeh3YLD7BCUR9dKnR5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.2.5': + resolution: {integrity: sha512-lEy7LWsdF5qHx5QjHZqabUqM9P7i5uTMCt8GRzqxmwXXKbapKJDA+ucVflVVPAyj4snD5DTAD4L+sct85XOlOQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.2.4': + resolution: {integrity: sha512-T7ir3fflSSVCYEVqNyMBq+pIMsilQRtbZcD5gxn6Ewsin5TJWBxrGBdXveWtmeXVAeoInfhS+BhBUZaL4sFSDg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.7.4': + resolution: {integrity: sha512-SAJt+QhY5rOJAIgZBw9TrHWfgrp6wsb9cqRCyqax+Hl6EiFfRXmD9EsIWkST8yq/dQ8H46Is2Sr/JG7Ls/LQ+Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.7': + resolution: {integrity: sha512-XveEe8v3BHj9ec00n+9i8FHjWhXjS6+jCTizYvXCqKUKtgkrq9xid8S5j3mYGW96qfxG3mwNoud3m3v8GDk9Pw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.3.5': + resolution: {integrity: sha512-kIUecMugEN2YTvTmff+uYz0nAE0f9RFNDVZknfrh264vAQM9P8tnsXiUp7z8XbGuNIBEtFMJhvc9QquawpC46A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.7': + resolution: {integrity: sha512-pRDWO4dTqSAvFQdO8yclKMtXNDm9K9HmvWB5UZmT3clw2OlOBNXveHzhyFmO/0d0yJMqlPcNITus1Jtpm6t8VQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.2.0': + resolution: {integrity: sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' @@ -895,6 +1191,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -2592,6 +2891,29 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stryker-mutator/api@10.0.0': + resolution: {integrity: sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==} + engines: {node: '>=22.0.0'} + + '@stryker-mutator/core@10.0.0': + resolution: {integrity: sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==} + engines: {node: '>=22.0.0'} + hasBin: true + + '@stryker-mutator/instrumenter@10.0.0': + resolution: {integrity: sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==} + engines: {node: '>=22.0.0'} + + '@stryker-mutator/util@10.0.0': + resolution: {integrity: sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==} + + '@stryker-mutator/vitest-runner@10.0.0': + resolution: {integrity: sha512-SHK2/vfvRUpiz7jXPnQMBnr6zLdm69DK03Mo5mPhaZWcRSygrKUqYsPqWsXsK+5ySHzlMTfCyFK5NQ/X9sJFFw==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@stryker-mutator/core': 10.0.0 + vitest: '>=2.0.0' + '@swc/helpers@0.5.23': resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} @@ -2794,9 +3116,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3160,6 +3488,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + angular-html-parser@10.11.0: + resolution: {integrity: sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==} + engines: {node: '>= 14'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -3481,6 +3813,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -3798,6 +4133,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -3808,6 +4146,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -3885,6 +4226,10 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -4149,6 +4494,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.10.2: + resolution: {integrity: sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -4247,6 +4596,10 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -4495,6 +4848,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4742,6 +5098,9 @@ packages: jose@6.2.12: resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-md4@0.3.2: + resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4760,6 +5119,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-rpc-2.0@1.8.1: + resolution: {integrity: sha512-BmN42JKn6FI36pcMMzHCXQhpKNEfbeqgSIcUYHKnifq0/4Ey6PDinovwUOgajT7NsgUtpWdDXaCWlpI4LHXN3g==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -4988,6 +5350,9 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -5155,6 +5520,19 @@ packages: typescript: optional: true + mutation-server-protocol@0.4.1: + resolution: {integrity: sha512-SBGK0j8hLDne7bktgThKI8kGvGTx3rY3LAeQTmOKZ5bVnL/7TorLMvcVF7dIPJCu5RNUWhkkuF53kurygYVt3g==} + engines: {node: '>=18'} + + mutation-testing-elements@3.8.4: + resolution: {integrity: sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==} + + mutation-testing-metrics@3.8.4: + resolution: {integrity: sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==} + + mutation-testing-report-schema@3.8.4: + resolution: {integrity: sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -5675,6 +6053,10 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -5707,6 +6089,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -5949,6 +6334,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -6110,6 +6498,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -6364,6 +6756,10 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -6390,6 +6786,10 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -6428,6 +6828,14 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} + typed-inject@5.0.0: + resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} + engines: {node: '>=18'} + + typed-rest-client@2.3.1: + resolution: {integrity: sha512-k4kX5Up6qA68D0Cby2AK+6+vM5k3qTxe+/3FqhnHRExjY5cfbOnzjQZbP/LXleF8hVoDvDqxlgk9KK83HoBZlQ==} + engines: {node: '>= 16.0.0'} + typescript-eslint@8.71.0: resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6448,6 +6856,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -6536,6 +6947,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -6634,6 +7049,9 @@ packages: wasm-feature-detect@1.8.0: resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + weapon-regex@2.0.6: + resolution: {integrity: sha512-iJC2vqYp+bh3r4X6zjuP0h65XqXjgUX3nJgmhj0PQMrdhAbUwCfN76CSTEEQqV3G7468kjOJ69PmytTG8l/8Gg==} + web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} @@ -6996,8 +7414,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.6': + dependencies: + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 + '@babel/compat-data@7.29.7': {} + '@babel/compat-data@8.0.5': {} + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -7018,6 +7443,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.7 + '@babel/helpers': 8.0.7 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.1.0 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 3.0.0 + verkit: 0.5.0 + '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.9 @@ -7026,10 +7470,23 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.6': + dependencies: + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@jridgewell/gen-mapping': 0.4.0-beta.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.29.7': dependencies: '@babel/types': 7.29.8 + '@babel/helper-annotate-as-pure@8.0.0': + dependencies: + '@babel/types': 8.0.6 + '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -7038,6 +7495,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@8.0.7': + dependencies: + '@babel/compat-data': 8.0.5 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.29.3 + flru: 1.0.2 + verkit: 0.5.0 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -7051,8 +7516,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-create-class-features-plugin@8.0.7(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-member-expression-to-functions': 8.0.5 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/helper-replace-supers': 8.0.1(@babel/core@8.0.7) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/traverse': 8.0.7 + verkit: 0.5.0 + '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.6': {} + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.10(supports-color@7.2.0) @@ -7060,6 +7538,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-member-expression-to-functions@8.0.5': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.10(supports-color@7.2.0) @@ -7067,6 +7550,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@8.0.0': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -7076,12 +7564,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@8.0.6(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-validator-identifier': 8.0.6 + '@babel/traverse': 8.0.7 + '@babel/helper-optimise-call-expression@7.29.7': dependencies: '@babel/types': 7.29.8 + '@babel/helper-optimise-call-expression@8.0.0': + dependencies: + '@babel/types': 8.0.6 + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-plugin-utils@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -7091,6 +7594,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-replace-supers@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-member-expression-to-functions': 8.0.5 + '@babel/helper-optimise-call-expression': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.10(supports-color@7.2.0) @@ -7098,31 +7608,84 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-skip-transparent-expression-wrappers@8.0.0': + dependencies: + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.6': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.6': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 '@babel/types': 7.29.8 + '@babel/helpers@8.0.7': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + '@babel/parser@7.29.9': dependencies: '@babel/types': 7.29.8 + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 + + '@babel/plugin-proposal-decorators@8.0.2(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-create-class-features-plugin': 8.0.7(@babel/core@8.0.7) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-syntax-decorators': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-syntax-decorators@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-typescript@8.0.3(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-destructuring@8.0.5(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-explicit-resource-management@8.0.5(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-destructuring': 8.0.5(@babel/core@8.0.7) + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -7131,6 +7694,37 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-modules-commonjs@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-module-transforms': 8.0.6(@babel/core@8.0.7) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-react-display-name@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-react-jsx-development@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.7) + + '@babel/plugin-transform-react-jsx@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-module-imports': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-syntax-jsx': 8.0.1(@babel/core@8.0.7) + '@babel/types': 8.0.6 + + '@babel/plugin-transform-react-pure-annotations@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -7142,6 +7736,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-typescript@8.0.6(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-annotate-as-pure': 8.0.0 + '@babel/helper-create-class-features-plugin': 8.0.7(@babel/core@8.0.7) + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/helper-skip-transparent-expression-wrappers': 8.0.0 + '@babel/plugin-syntax-typescript': 8.0.3(@babel/core@8.0.7) + + '@babel/preset-react@8.0.1(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-react-display-name': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-react-jsx': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-react-jsx-development': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-react-pure-annotations': 8.0.1(@babel/core@8.0.7) + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -7153,6 +7766,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-typescript@8.0.7(@babel/core@8.0.7)': + dependencies: + '@babel/core': 8.0.7 + '@babel/helper-plugin-utils': 8.0.1(@babel/core@8.0.7) + '@babel/helper-validator-option': 8.0.0 + '@babel/plugin-transform-modules-commonjs': 8.0.1(@babel/core@8.0.7) + '@babel/plugin-transform-typescript': 8.0.6(@babel/core@8.0.7) + '@babel/runtime@7.29.2': {} '@babel/template@7.29.7': @@ -7161,6 +7782,12 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@babel/traverse@7.29.10(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -7173,11 +7800,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + obug: 3.0.0 + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} @@ -7509,37 +8151,124 @@ snapshots: '@img/sharp-win32-x64@0.35.5': optional: true - '@inquirer/ansi@2.0.7': - optional: true + '@inquirer/ansi@2.0.9': {} - '@inquirer/confirm@6.1.1(@types/node@26.6.5)': + '@inquirer/checkbox@5.2.7(@types/node@26.6.5)': dependencies: - '@inquirer/core': 11.2.1(@types/node@26.6.5) - '@inquirer/type': 4.0.7(@types/node@26.6.5) + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@26.6.5) optionalDependencies: '@types/node': 26.6.5 - optional: true - '@inquirer/core@11.2.1(@types/node@26.6.5)': + '@inquirer/confirm@6.3.4(@types/node@26.6.5)': dependencies: - '@inquirer/ansi': 2.0.7 - '@inquirer/figures': 2.0.7 - '@inquirer/type': 4.0.7(@types/node@26.6.5) + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/core@12.0.5(@types/node@26.6.5)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@26.6.5) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: '@types/node': 26.6.5 - optional: true - '@inquirer/figures@2.0.7': - optional: true + '@inquirer/editor@5.3.5(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/external-editor': 3.0.6(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 - '@inquirer/type@4.0.7(@types/node@26.6.5)': + '@inquirer/expand@5.1.7(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/external-editor@3.0.6(@types/node@26.6.5)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/figures@2.0.9': {} + + '@inquirer/input@5.1.8(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/number@4.2.5(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/password@5.2.4(@types/node@26.6.5)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/prompts@8.7.4(@types/node@26.6.5)': + dependencies: + '@inquirer/checkbox': 5.2.7(@types/node@26.6.5) + '@inquirer/confirm': 6.3.4(@types/node@26.6.5) + '@inquirer/editor': 5.3.5(@types/node@26.6.5) + '@inquirer/expand': 5.1.7(@types/node@26.6.5) + '@inquirer/input': 5.1.8(@types/node@26.6.5) + '@inquirer/number': 4.2.5(@types/node@26.6.5) + '@inquirer/password': 5.2.4(@types/node@26.6.5) + '@inquirer/rawlist': 5.3.7(@types/node@26.6.5) + '@inquirer/search': 4.3.5(@types/node@26.6.5) + '@inquirer/select': 5.2.7(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/rawlist@5.3.7(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/search@4.3.5(@types/node@26.6.5)': + dependencies: + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/select@5.2.7(@types/node@26.6.5)': + dependencies: + '@inquirer/ansi': 2.0.9 + '@inquirer/core': 12.0.5(@types/node@26.6.5) + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.2.0(@types/node@26.6.5) + optionalDependencies: + '@types/node': 26.6.5 + + '@inquirer/type@4.2.0(@types/node@26.6.5)': optionalDependencies: '@types/node': 26.6.5 - optional: true '@isaacs/cliui@8.0.2': dependencies: @@ -7555,6 +8284,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -9215,6 +9949,72 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@stryker-mutator/api@10.0.0': + dependencies: + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 + tslib: 2.8.1 + typed-inject: 5.0.0 + + '@stryker-mutator/core@10.0.0(@types/node@26.6.5)': + dependencies: + '@inquirer/prompts': 8.7.4(@types/node@26.6.5) + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/instrumenter': 10.0.0 + '@stryker-mutator/util': 10.0.0 + ajv: 8.20.0 + chalk: 5.6.2 + commander: 14.0.3 + diff-match-patch: 1.0.5 + emoji-regex: 10.6.0 + execa: 9.6.1 + json-rpc-2.0: 1.8.1 + lodash.groupby: 4.6.0 + minimatch: 10.2.6 + mutation-server-protocol: 0.4.1 + mutation-testing-elements: 3.8.4 + mutation-testing-metrics: 3.8.4 + mutation-testing-report-schema: 3.8.4 + npm-run-path: 6.0.0 + progress: 2.0.3 + rxjs: 7.8.2 + semver: 7.8.5 + source-map: 0.7.6 + tree-kill: 1.2.2 + tslib: 2.8.1 + typed-inject: 5.0.0 + typed-rest-client: 2.3.1 + transitivePeerDependencies: + - '@types/node' + + '@stryker-mutator/instrumenter@10.0.0': + dependencies: + '@babel/core': 8.0.7 + '@babel/generator': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/plugin-proposal-decorators': 8.0.2(@babel/core@8.0.7) + '@babel/plugin-transform-explicit-resource-management': 8.0.5(@babel/core@8.0.7) + '@babel/preset-react': 8.0.1(@babel/core@8.0.7) + '@babel/preset-typescript': 8.0.7(@babel/core@8.0.7) + '@babel/traverse': 8.0.7 + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/util': 10.0.0 + angular-html-parser: 10.11.0 + semver: 7.8.5 + tslib: 2.8.1 + weapon-regex: 2.0.6 + + '@stryker-mutator/util@10.0.0': {} + + '@stryker-mutator/vitest-runner@10.0.0(patch_hash=9ad3e9855fd9781fd36e872ed42c5f934f91b962d6830e6cc56d56bf16e1dabd)(@stryker-mutator/core@10.0.0(@types/node@26.6.5))(vitest@5.0.4)': + dependencies: + '@stryker-mutator/api': 10.0.0 + '@stryker-mutator/core': 10.0.0(@types/node@26.6.5) + '@stryker-mutator/util': 10.0.0 + semver: 7.8.5 + tslib: 2.8.1 + vitest: 5.0.4(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.4)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.5(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)) + '@swc/helpers@0.5.23': dependencies: tslib: 2.8.1 @@ -9401,8 +10201,12 @@ snapshots: '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} + '@types/geojson@7946.0.16': {} + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -9789,6 +10593,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + angular-html-parser@10.11.0: {} + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -10145,6 +10951,8 @@ snapshots: chalk@5.6.2: {} + chardet@2.2.0: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -10163,8 +10971,7 @@ snapshots: cli-spinners@2.9.2: {} - cli-width@4.1.0: - optional: true + cli-width@4.1.0: {} client-only@0.0.1: {} @@ -10419,12 +11226,19 @@ snapshots: depd@2.0.0: {} + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + destr@2.0.5: {} detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + diff-match-patch@1.0.5: {} + diff@8.0.4: {} dijkstrajs@1.0.3: {} @@ -10503,6 +11317,8 @@ snapshots: empathic@2.0.0: {} + empathic@2.1.0: {} + encodeurl@2.0.0: {} end-of-stream@1.4.5: @@ -10953,6 +11769,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.10.2: + dependencies: + pure-rand: 8.4.2 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -10989,20 +11809,17 @@ snapshots: dependencies: fast-decode-uri-component: 1.0.1 - fast-string-truncated-width@3.0.3: - optional: true + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 - optional: true fast-uri@3.1.8: {} fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 - optional: true fastq@1.20.3: dependencies: @@ -11068,6 +11885,8 @@ snapshots: flatted@3.4.4: {} + flru@1.0.2: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -11299,6 +12118,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -11511,6 +12332,8 @@ snapshots: jose@6.2.12: {} + js-md4@0.3.2: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -11523,6 +12346,8 @@ snapshots: json-buffer@3.0.1: {} + json-rpc-2.0@1.8.1: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -11740,6 +12565,8 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.groupby@4.6.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -11860,7 +12687,7 @@ snapshots: msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3): dependencies: - '@inquirer/confirm': 6.1.1(@types/node@26.6.5) + '@inquirer/confirm': 6.3.4(@types/node@26.6.5) '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 @@ -11884,8 +12711,19 @@ snapshots: - '@types/node' optional: true - mute-stream@3.0.0: - optional: true + mutation-server-protocol@0.4.1: + dependencies: + zod: 4.6.7 + + mutation-testing-elements@3.8.4: {} + + mutation-testing-metrics@3.8.4: + dependencies: + mutation-testing-report-schema: 3.8.4 + + mutation-testing-report-schema@3.8.4: {} + + mute-stream@3.0.0: {} mysql2@3.24.4(@types/node@26.6.5): dependencies: @@ -12366,6 +13204,8 @@ snapshots: process@0.11.10: {} + progress@2.0.3: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -12418,6 +13258,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 @@ -12749,6 +13591,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -12987,6 +13833,8 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.6: {} + split-ca@1.0.1: {} split2@4.2.0: {} @@ -13303,6 +14151,8 @@ snapshots: tr46@0.0.3: {} + tree-kill@1.2.2: {} + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -13333,6 +14183,8 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel@0.0.6: {} + tw-animate-css@1.4.0: {} tweetnacl@0.14.5: {} @@ -13385,6 +14237,16 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typed-inject@5.0.0: {} + + typed-rest-client@2.3.1: + dependencies: + des.js: 1.1.0 + js-md4: 0.3.2 + qs: 6.16.0 + tunnel: 0.0.6 + underscore: 1.13.8 + typescript-eslint@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) @@ -13407,6 +14269,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + underscore@1.13.8: {} + undici-types@5.26.5: {} undici-types@8.9.0: {} @@ -13495,6 +14359,8 @@ snapshots: vary@1.1.2: {} + verkit@0.5.0: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 @@ -13558,6 +14424,8 @@ snapshots: wasm-feature-detect@1.8.0: {} + weapon-regex@2.0.6: {} + web-push@3.6.7(supports-color@7.2.0): dependencies: asn1.js: 5.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a699c5cd7..5f13e535e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -131,3 +131,6 @@ auditConfig: # never runs. No patched release is published yet (latest is 1.4.0); # remove this entry and raise the floor once 1.4.1 is out. - GHSA-86w9-cpqp-85rv + +patchedDependencies: + "@stryker-mutator/vitest-runner@10.0.0": patches/@stryker-mutator__vitest-runner@10.0.0.patch diff --git a/scripts/__tests__/ci-determinism.test.ts b/scripts/__tests__/ci-determinism.test.ts index f9254aaa0..fbe1a1d59 100644 --- a/scripts/__tests__/ci-determinism.test.ts +++ b/scripts/__tests__/ci-determinism.test.ts @@ -157,6 +157,9 @@ describe("container dependency installation", () => { expect(dockerfile).toContain( "COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" + + "# The dependency patches pnpm-workspace.yaml names; the frozen install\n" + + "# refuses to run without them.\n" + + "COPY patches patches\n" + "COPY scripts/prepare.mjs scripts/prepare.mjs\n" + "RUN pnpm install --frozen-lockfile --prod=false", ); diff --git a/scripts/mutation-floors.mjs b/scripts/mutation-floors.mjs new file mode 100644 index 000000000..2824385cd --- /dev/null +++ b/scripts/mutation-floors.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Check each module's mutation score against its floor. + * + * node scripts/mutation-floors.mjs [reports/mutation/mutation.json] + * + * Stryker's own `thresholds.break` is one number for the whole run, which + * lets a well-tested module hide a weak one. `stryker.floors.json` names a + * floor per module instead (a module is a set of path prefixes), and this + * script reads the JSON report Stryker writes and fails when any module + * scores below its floor. The score is Stryker's: killed and timed-out + * mutants over every mutant that was valid and covered or not, so a mutant no + * test reaches counts against the module. + * + * Floors sit just below the last measured score. Raise one when a change + * raises the score; a floor that is lowered to let a run pass has stopped + * being a floor. + */ +import { existsSync, readFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const reportPath = resolve( + root, + process.argv[2] ?? "reports/mutation/mutation.json", +); +const floorsPath = resolve(root, "stryker.floors.json"); + +if (!existsSync(reportPath)) { + console.error( + `no mutation report at ${reportPath}; run pnpm exec stryker run`, + ); + process.exit(2); +} + +const report = JSON.parse(readFileSync(reportPath, "utf8")); +const { modules } = JSON.parse(readFileSync(floorsPath, "utf8")); + +const DETECTED = new Set(["Killed", "Timeout"]); +const UNDETECTED = new Set(["Survived", "NoCoverage"]); + +const files = Object.entries(report.files ?? {}).map(([path, file]) => [ + relative(root, resolve(root, path)).split("\\").join("/"), + file, +]); + +let failed = false; +const rows = []; +for (const [name, { paths, floor }] of Object.entries(modules)) { + let detected = 0; + let undetected = 0; + let matched = 0; + for (const [path, file] of files) { + if (!paths.some((prefix) => path.startsWith(prefix))) continue; + matched += 1; + for (const mutant of file.mutants ?? []) { + if (DETECTED.has(mutant.status)) detected += 1; + else if (UNDETECTED.has(mutant.status)) undetected += 1; + } + } + const total = detected + undetected; + const score = total === 0 ? null : (100 * detected) / total; + const ok = matched > 0 && score !== null && score >= floor; + if (!ok) failed = true; + rows.push( + `${ok ? "ok " : "FAIL"} ${name.padEnd(16)} ${ + score === null ? "no mutants" : `${score.toFixed(1)} %` + } (floor ${floor} %, ${detected}/${total} mutants, ${matched} files)`, + ); +} + +console.log(rows.join("\n")); +if (failed) { + console.error( + "\nA module scored below its floor, or the report covered none of its files.", + ); + process.exit(1); +} diff --git a/scripts/test-durations.mjs b/scripts/test-durations.mjs new file mode 100644 index 000000000..06b2c17a2 --- /dev/null +++ b/scripts/test-durations.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Regenerate a timing file for the duration-balanced sequencer + * (`tests/durations/sequencer.ts`) from Vitest JSON reports. + * + * node scripts/test-durations.mjs [ …] + * + * `` is `unit` or `integration` and names the file written, + * `tests/durations/.json`. Pass every shard's report of one run; a + * file measured twice keeps the longer reading. Files the reports do not + * mention keep their previous value, so a partial run refines the table + * rather than emptying it, and files that no longer exist are dropped. + * + * Produce a report with + * pnpm exec vitest run [--config …] --reporter=default \ + * --reporter=json --outputFile.json=report.json + * The nightly shuffled workflow uploads the reports of a CI run, which is + * the better source: CI is where the shards have to balance. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const [suite, ...reports] = process.argv.slice(2); + +if (!["unit", "integration"].includes(suite ?? "") || reports.length === 0) { + console.error( + "usage: node scripts/test-durations.mjs …", + ); + process.exit(2); +} + +const target = resolve(root, "tests/durations", `${suite}.json`); +const previous = existsSync(target) + ? JSON.parse(readFileSync(target, "utf8")).files + : {}; + +const measured = new Map(); +for (const report of reports) { + const parsed = JSON.parse(readFileSync(report, "utf8")); + for (const result of parsed.testResults ?? []) { + if (typeof result.startTime !== "number") continue; + if (typeof result.endTime !== "number") continue; + const file = relative(root, result.name).split("\\").join("/"); + const seconds = Math.max(0, (result.endTime - result.startTime) / 1000); + measured.set(file, Math.max(measured.get(file) ?? 0, seconds)); + } +} + +if (measured.size === 0) { + console.error("the reports carry no per-file timings; nothing written"); + process.exit(1); +} + +const files = {}; +const keys = new Set([...Object.keys(previous), ...measured.keys()]); +for (const file of [...keys].sort()) { + if (!existsSync(resolve(root, file))) continue; + const seconds = measured.get(file) ?? previous[file]; + files[file] = Math.round(seconds * 10) / 10; +} + +writeFileSync( + target, + JSON.stringify( + { + source: `vitest JSON report of ${measured.size} files, written ${new Date().toISOString().slice(0, 10)}`, + files, + }, + null, + 2, + ) + "\n", +); +const total = Object.values(files).reduce((a, b) => a + b, 0); +console.log( + `${target}: ${Object.keys(files).length} files, ${Math.round(total)} s in total`, +); diff --git a/src/__tests__/baselines/route-test-db-mocks.json b/src/__tests__/baselines/route-test-db-mocks.json index ff02e09a7..ee4c6d4c1 100644 --- a/src/__tests__/baselines/route-test-db-mocks.json +++ b/src/__tests__/baselines/route-test-db-mocks.json @@ -1,7 +1,7 @@ { "$comment": "route tests that mock @/lib/db wholesale, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 321, - "total": 321, + "count": 274, + "total": 274, "entries": { "app/api/account/grants/__tests__/invite-access-level.test.ts": 1, "app/api/account/grants/__tests__/invite-recent-proof.test.ts": 1, @@ -36,26 +36,8 @@ "app/api/auth/login/__tests__/native-token.test.ts": 1, "app/api/auth/login/__tests__/route.test.ts": 1, "app/api/auth/login/__tests__/trusted-device.test.ts": 1, - "app/api/auth/me/ai-usage/__tests__/route.test.ts": 1, - "app/api/auth/me/coach-prefs/__tests__/route.test.ts": 1, - "app/api/auth/me/devices/__tests__/route.test.ts": 1, - "app/api/auth/me/devices/[id]/__tests__/route.test.ts": 1, - "app/api/auth/me/diabetes/__tests__/route.test.ts": 1, - "app/api/auth/me/disable-coach/__tests__/route.test.ts": 1, - "app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts": 1, - "app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts": 1, - "app/api/auth/me/glucose-unit/__tests__/route.test.ts": 1, - "app/api/auth/me/health-score-config/__tests__/route.test.ts": 1, "app/api/auth/me/mfa/disable/__tests__/route.test.ts": 1, "app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 1, - "app/api/auth/me/modules/__tests__/route.test.ts": 1, - "app/api/auth/me/notification-prefs/__tests__/route.test.ts": 1, - "app/api/auth/me/security-activity/__tests__/route.test.ts": 1, - "app/api/auth/me/sessions/__tests__/route.test.ts": 1, - "app/api/auth/me/source-priority/__tests__/route.test.ts": 1, - "app/api/auth/me/timezone/__tests__/route.test.ts": 1, - "app/api/auth/me/unit-preference/__tests__/route.test.ts": 1, - "app/api/auth/me/workout-routes/__tests__/route.test.ts": 1, "app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 1, "app/api/auth/native/complete/__tests__/route.test.ts": 1, "app/api/auth/native/login/__tests__/route.test.ts": 1, @@ -122,8 +104,6 @@ "app/api/export/encrypted/__tests__/route.test.ts": 1, "app/api/export/health-record/__tests__/route.test.ts": 1, "app/api/feature-flags/__tests__/route.test.ts": 1, - "app/api/fhir/__tests__/capability-and-everything.test.ts": 1, - "app/api/fhir/__tests__/module-gate.test.ts": 1, "app/api/fitbit/connect/__tests__/route.test.ts": 1, "app/api/fitbit/credentials/__tests__/route.test.ts": 1, "app/api/gamification/achievements/__tests__/hidden-redaction.test.ts": 1, @@ -222,22 +202,14 @@ "app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 1, "app/api/medications/__tests__/route.test.ts": 1, "app/api/medications/[id]/__tests__/route.test.ts": 1, - "app/api/medications/[id]/cadence/__tests__/route.test.ts": 1, "app/api/medications/[id]/compliance/__tests__/route.test.ts": 1, "app/api/medications/[id]/dose-history/__tests__/route.test.ts": 1, "app/api/medications/[id]/glp1/__tests__/route.test.ts": 1, "app/api/medications/[id]/intake/__tests__/route.test.ts": 1, "app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1, "app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 1, - "app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts": 1, "app/api/medications/[id]/intake/import/__tests__/route.test.ts": 1, - "app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts": 1, - "app/api/medications/[id]/intake/purge/__tests__/route.test.ts": 1, - "app/api/medications/[id]/inventory/__tests__/route.test.ts": 1, - "app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts": 1, - "app/api/medications/[id]/phase-config/__tests__/route.test.ts": 1, "app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1, - "app/api/medications/[id]/side-effects/__tests__/route.test.ts": 1, "app/api/medications/compliance/__tests__/route.test.ts": 1, "app/api/medications/intake/__tests__/route.test.ts": 1, "app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 1, @@ -254,11 +226,6 @@ "app/api/mood-entries/bulk/__tests__/route.test.ts": 1, "app/api/mood-entries/restore/__tests__/route.test.ts": 1, "app/api/mood/analytics/__tests__/route.test.ts": 1, - "app/api/mood/tags/__tests__/custom-crud.test.ts": 1, - "app/api/mood/tags/__tests__/groups-crud.test.ts": 1, - "app/api/mood/tags/__tests__/layout.test.ts": 1, - "app/api/mood/tags/__tests__/route.test.ts": 1, - "app/api/mood/tags/layout/__tests__/route.test.ts": 1, "app/api/nightscout/connect/__tests__/route.test.ts": 1, "app/api/notifications/action/[token]/__tests__/route.test.ts": 1, "app/api/notifications/status/__tests__/route.test.ts": 1, @@ -280,24 +247,13 @@ "app/api/polar/disconnect/__tests__/route.test.ts": 1, "app/api/polar/status/__tests__/route.test.ts": 1, "app/api/search/__tests__/route.test.ts": 1, - "app/api/settings/__tests__/channel-enabled-routes.test.ts": 1, - "app/api/settings/__tests__/channel-global-switch-refusal.test.ts": 1, - "app/api/settings/__tests__/email-test-route.test.ts": 1, - "app/api/settings/__tests__/private-origin-routes.test.ts": 1, - "app/api/settings/__tests__/telegram-test-locale.test.ts": 1, - "app/api/settings/__tests__/webhook-format-route.test.ts": 1, - "app/api/settings/reminder-thresholds/__tests__/route.test.ts": 1, - "app/api/settings/webhook/test/__tests__/route.test.ts": 1, "app/api/share-links/__tests__/route.test.ts": 1, "app/api/sleep/night/__tests__/route.test.ts": 1, "app/api/sleep/rhythm/__tests__/route.test.ts": 1, "app/api/strava/callback/__tests__/route.test.ts": 1, - "app/api/strava/credentials/__tests__/route.test.ts": 1, - "app/api/strava/disconnect/__tests__/route.test.ts": 1, "app/api/sync/changes/__tests__/module-gate.test.ts": 1, "app/api/sync/state/__tests__/route.test.ts": 1, "app/api/telegram/webhook/__tests__/route.test.ts": 1, - "app/api/tokens/__tests__/route.test.ts": 1, "app/api/tokens/documents/__tests__/route.test.ts": 1, "app/api/tokens/measurements/__tests__/route.test.ts": 1, "app/api/tokens/workouts/__tests__/route.test.ts": 1, @@ -319,10 +275,7 @@ "app/api/withings/webhook/__tests__/route.test.ts": 1, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 1, "app/api/workouts/__tests__/batch-create.test.ts": 1, - "app/api/workouts/__tests__/batch-scope.test.ts": 1, "app/api/workouts/__tests__/canonical-dedup.test.ts": 1, - "app/api/workouts/__tests__/delete.test.ts": 1, - "app/api/workouts/__tests__/detail.test.ts": 1, "app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 1 } } diff --git a/src/__tests__/baselines/route-test-handler-stubs.json b/src/__tests__/baselines/route-test-handler-stubs.json index 23fe2efea..8e0042c96 100644 --- a/src/__tests__/baselines/route-test-handler-stubs.json +++ b/src/__tests__/baselines/route-test-handler-stubs.json @@ -1,7 +1,7 @@ { "$comment": "route tests that stub @/lib/api-handler, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 138, - "total": 138, + "count": 133, + "total": 133, "entries": { "app/api/account/grants/__tests__/invite-access-level.test.ts": 1, "app/api/account/grants/__tests__/invite-scope-and-manage.test.ts": 1, @@ -112,15 +112,10 @@ "app/api/polar/sync/__tests__/route.test.ts": 1, "app/api/record-settings/integrations/[provider]/__tests__/route.test.ts": 1, "app/api/search/__tests__/route.test.ts": 1, - "app/api/settings/__tests__/telegram-test-locale.test.ts": 1, "app/api/strava/callback/__tests__/route.test.ts": 1, "app/api/strava/connect/__tests__/route.test.ts": 1, - "app/api/strava/credentials/__tests__/route.test.ts": 1, - "app/api/strava/disconnect/__tests__/route.test.ts": 1, - "app/api/strava/sync/__tests__/route.test.ts": 1, "app/api/sync/state/__tests__/route.test.ts": 1, "app/api/telegram/webhook/__tests__/route.test.ts": 1, - "app/api/tokens/__tests__/route.test.ts": 1, "app/api/tokens/documents/__tests__/route.test.ts": 1, "app/api/tokens/measurements/__tests__/route.test.ts": 1, "app/api/tokens/workouts/__tests__/route.test.ts": 1, diff --git a/src/__tests__/baselines/test-as-never.json b/src/__tests__/baselines/test-as-never.json index 7db17ddc4..ede0dd3fb 100644 --- a/src/__tests__/baselines/test-as-never.json +++ b/src/__tests__/baselines/test-as-never.json @@ -1,7 +1,7 @@ { "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 570, - "total": 6014, + "count": 524, + "total": 5496, "entries": { "__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/mfa/verify.test.ts": 9, @@ -42,26 +42,8 @@ "app/api/auth/login/__tests__/native-token.test.ts": 8, "app/api/auth/login/__tests__/route.test.ts": 5, "app/api/auth/login/__tests__/trusted-device.test.ts": 2, - "app/api/auth/me/ai-usage/__tests__/route.test.ts": 3, - "app/api/auth/me/coach-prefs/__tests__/route.test.ts": 9, - "app/api/auth/me/devices/__tests__/route.test.ts": 8, - "app/api/auth/me/devices/[id]/__tests__/route.test.ts": 13, - "app/api/auth/me/diabetes/__tests__/route.test.ts": 13, - "app/api/auth/me/disable-coach/__tests__/route.test.ts": 19, - "app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts": 2, - "app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts": 5, - "app/api/auth/me/glucose-unit/__tests__/route.test.ts": 19, - "app/api/auth/me/health-score-config/__tests__/route.test.ts": 25, "app/api/auth/me/mfa/disable/__tests__/route.test.ts": 7, "app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 8, - "app/api/auth/me/modules/__tests__/route.test.ts": 29, - "app/api/auth/me/notification-prefs/__tests__/route.test.ts": 44, - "app/api/auth/me/security-activity/__tests__/route.test.ts": 2, - "app/api/auth/me/sessions/__tests__/route.test.ts": 13, - "app/api/auth/me/source-priority/__tests__/route.test.ts": 11, - "app/api/auth/me/timezone/__tests__/route.test.ts": 15, - "app/api/auth/me/unit-preference/__tests__/route.test.ts": 19, - "app/api/auth/me/workout-routes/__tests__/route.test.ts": 3, "app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 11, "app/api/auth/native/complete/__tests__/route.test.ts": 2, "app/api/auth/native/login/__tests__/route.test.ts": 2, @@ -122,8 +104,6 @@ "app/api/export/encrypted/__tests__/route.test.ts": 20, "app/api/export/health-record/__tests__/route.test.ts": 32, "app/api/feature-flags/__tests__/route.test.ts": 6, - "app/api/fhir/__tests__/capability-and-everything.test.ts": 4, - "app/api/fhir/__tests__/module-gate.test.ts": 6, "app/api/fhir/__tests__/observation-series.test.ts": 8, "app/api/fhir/__tests__/route.test.ts": 9, "app/api/fitbit/connect/__tests__/route.test.ts": 4, @@ -207,22 +187,14 @@ "app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 6, "app/api/medications/__tests__/route.test.ts": 43, "app/api/medications/[id]/__tests__/route.test.ts": 52, - "app/api/medications/[id]/cadence/__tests__/route.test.ts": 12, "app/api/medications/[id]/compliance/__tests__/route.test.ts": 18, "app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15, "app/api/medications/[id]/glp1/__tests__/route.test.ts": 47, "app/api/medications/[id]/intake/__tests__/route.test.ts": 45, "app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 12, "app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63, - "app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts": 13, "app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9, - "app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts": 2, - "app/api/medications/[id]/intake/purge/__tests__/route.test.ts": 4, - "app/api/medications/[id]/inventory/__tests__/route.test.ts": 45, - "app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts": 12, - "app/api/medications/[id]/phase-config/__tests__/route.test.ts": 2, "app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24, - "app/api/medications/[id]/side-effects/__tests__/route.test.ts": 32, "app/api/medications/compliance/__tests__/route.test.ts": 10, "app/api/medications/extract/__tests__/route.test.ts": 38, "app/api/medications/intake/__tests__/route.test.ts": 59, @@ -243,11 +215,6 @@ "app/api/mood-entries/restore/__tests__/route.test.ts": 11, "app/api/mood/analytics/__tests__/route.test.ts": 15, "app/api/mood/insights/__tests__/route.test.ts": 3, - "app/api/mood/tags/__tests__/custom-crud.test.ts": 2, - "app/api/mood/tags/__tests__/groups-crud.test.ts": 1, - "app/api/mood/tags/__tests__/layout.test.ts": 1, - "app/api/mood/tags/__tests__/route.test.ts": 1, - "app/api/mood/tags/layout/__tests__/route.test.ts": 11, "app/api/nightscout/sync/__tests__/route.test.ts": 3, "app/api/notifications/action/[token]/__tests__/route.test.ts": 5, "app/api/notifications/status/__tests__/route.test.ts": 4, @@ -264,22 +231,12 @@ "app/api/personal-records/__tests__/route.test.ts": 15, "app/api/polar/callback/__tests__/route.test.ts": 1, "app/api/polar/sync/__tests__/route.test.ts": 3, - "app/api/settings/__tests__/channel-enabled-routes.test.ts": 14, - "app/api/settings/__tests__/channel-global-switch-refusal.test.ts": 15, - "app/api/settings/__tests__/email-test-route.test.ts": 2, - "app/api/settings/__tests__/private-origin-routes.test.ts": 4, - "app/api/settings/__tests__/telegram-test-locale.test.ts": 9, - "app/api/settings/__tests__/webhook-format-route.test.ts": 4, - "app/api/settings/reminder-thresholds/__tests__/route.test.ts": 7, - "app/api/settings/webhook/test/__tests__/route.test.ts": 2, "app/api/share-links/__tests__/route.test.ts": 19, "app/api/sleep/night/__tests__/route.test.ts": 24, "app/api/sleep/rhythm/__tests__/route.test.ts": 6, - "app/api/strava/sync/__tests__/route.test.ts": 3, "app/api/sync/changes/__tests__/module-gate.test.ts": 7, "app/api/sync/state/__tests__/route.test.ts": 8, "app/api/telegram/webhook/__tests__/route.test.ts": 43, - "app/api/tokens/__tests__/route.test.ts": 5, "app/api/tokens/documents/__tests__/route.test.ts": 25, "app/api/tokens/measurements/__tests__/route.test.ts": 25, "app/api/tokens/workouts/__tests__/route.test.ts": 25, @@ -297,10 +254,7 @@ "app/api/withings/webhook/__tests__/route.test.ts": 26, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 8, "app/api/workouts/__tests__/batch-create.test.ts": 14, - "app/api/workouts/__tests__/batch-scope.test.ts": 10, "app/api/workouts/__tests__/canonical-dedup.test.ts": 20, - "app/api/workouts/__tests__/delete.test.ts": 7, - "app/api/workouts/__tests__/detail.test.ts": 36, "app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4, "app/c/[token]/__tests__/report-download.test.ts": 11, "app/c/[token]/d/[id]/__tests__/route.test.ts": 8, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 00e6549a5..d54528c42 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2985, - "total": 10634, + "count": 2962, + "total": 10592, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -138,16 +138,12 @@ "app/api/auth/login/__tests__/native-token.test.ts": 2, "app/api/auth/login/__tests__/route.test.ts": 1, "app/api/auth/login/route.ts": 5, - "app/api/auth/me/coach-prefs/__tests__/route.test.ts": 1, "app/api/auth/me/coach-prefs/route.ts": 7, "app/api/auth/me/cycle-prefs/route.ts": 1, - "app/api/auth/me/devices/__tests__/route.test.ts": 1, - "app/api/auth/me/devices/[id]/__tests__/route.test.ts": 1, "app/api/auth/me/devices/[id]/route.ts": 3, "app/api/auth/me/devices/route.ts": 2, "app/api/auth/me/diabetes/route.ts": 1, "app/api/auth/me/disable-coach/route.ts": 1, - "app/api/auth/me/health-score-config/__tests__/route.test.ts": 3, "app/api/auth/me/health-score-config/route.ts": 1, "app/api/auth/me/injection-site-prefs/route.ts": 1, "app/api/auth/me/labs-local-ocr/route.ts": 1, @@ -155,9 +151,7 @@ "app/api/auth/me/mfa/disable/route.ts": 1, "app/api/auth/me/mfa/totp/confirm/route.ts": 1, "app/api/auth/me/mfa/webauthn/register/verify/route.ts": 1, - "app/api/auth/me/modules/__tests__/route.test.ts": 4, "app/api/auth/me/modules/route.ts": 4, - "app/api/auth/me/notification-prefs/__tests__/route.test.ts": 3, "app/api/auth/me/notification-prefs/route.ts": 4, "app/api/auth/me/route.ts": 28, "app/api/auth/me/security-activity/route.ts": 1, @@ -423,7 +417,6 @@ "app/api/medications/__tests__/route.test.ts": 10, "app/api/medications/[id]/__tests__/route.test.ts": 8, "app/api/medications/[id]/api-endpoint/route.ts": 2, - "app/api/medications/[id]/cadence/__tests__/route.test.ts": 1, "app/api/medications/[id]/cadence/route.ts": 10, "app/api/medications/[id]/compliance/__tests__/route.test.ts": 7, "app/api/medications/[id]/compliance/route.ts": 3, @@ -437,24 +430,18 @@ "app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1, "app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9, "app/api/medications/[id]/intake/[eventId]/route.ts": 24, - "app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts": 3, "app/api/medications/[id]/intake/bulk-delete/route.ts": 7, "app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2, "app/api/medications/[id]/intake/import/route.ts": 6, - "app/api/medications/[id]/intake/purge/__tests__/route.test.ts": 3, "app/api/medications/[id]/intake/purge/route.ts": 4, "app/api/medications/[id]/intake/route.ts": 30, - "app/api/medications/[id]/inventory/__tests__/route.test.ts": 2, - "app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts": 5, "app/api/medications/[id]/inventory/[itemId]/route.ts": 9, "app/api/medications/[id]/inventory/route.ts": 5, - "app/api/medications/[id]/phase-config/__tests__/route.test.ts": 1, "app/api/medications/[id]/phase-config/route.ts": 5, "app/api/medications/[id]/route.ts": 59, "app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9, "app/api/medications/[id]/schedule-revisions/route.ts": 3, - "app/api/medications/[id]/side-effects/__tests__/route.test.ts": 2, "app/api/medications/[id]/side-effects/[logId]/route.ts": 2, "app/api/medications/[id]/side-effects/route.ts": 6, "app/api/medications/categories/[key]/route.ts": 1, @@ -487,16 +474,11 @@ "app/api/mood/analytics/__tests__/route.test.ts": 2, "app/api/mood/analytics/route.ts": 1, "app/api/mood/insights/route.ts": 3, - "app/api/mood/tags/__tests__/custom-crud.test.ts": 1, - "app/api/mood/tags/__tests__/groups-crud.test.ts": 1, - "app/api/mood/tags/__tests__/layout.test.ts": 2, - "app/api/mood/tags/__tests__/route.test.ts": 1, "app/api/mood/tags/[key]/hidden/route.ts": 1, "app/api/mood/tags/custom/[key]/route.ts": 1, "app/api/mood/tags/custom/route.ts": 1, "app/api/mood/tags/groups/[key]/route.ts": 1, "app/api/mood/tags/groups/route.ts": 1, - "app/api/mood/tags/layout/__tests__/route.test.ts": 1, "app/api/mood/tags/layout/route.ts": 3, "app/api/mood/tags/route.ts": 1, "app/api/nightscout/connect/__tests__/route.test.ts": 1, @@ -535,12 +517,9 @@ "app/api/practitioners/[id]/route.ts": 1, "app/api/practitioners/route.ts": 1, "app/api/record-settings/integrations/route.ts": 1, - "app/api/settings/__tests__/channel-enabled-routes.test.ts": 1, - "app/api/settings/__tests__/telegram-test-locale.test.ts": 1, "app/api/settings/account/route.ts": 1, "app/api/settings/data/route.ts": 2, "app/api/settings/ntfy/route.ts": 1, - "app/api/settings/reminder-thresholds/__tests__/route.test.ts": 2, "app/api/settings/reminder-thresholds/route.ts": 2, "app/api/settings/telegram/test/route.ts": 1, "app/api/share-links/__tests__/route.test.ts": 2, @@ -554,7 +533,6 @@ "app/api/strava/credentials/route.ts": 1, "app/api/strava/disconnect/route.ts": 1, "app/api/strava/status/route.ts": 1, - "app/api/strava/sync/__tests__/route.test.ts": 1, "app/api/sync/changes/route.ts": 5, "app/api/sync/state/route.ts": 2, "app/api/telegram/webhook/__tests__/route.test.ts": 3, @@ -582,7 +560,6 @@ "app/api/withings/webhook/route.ts": 2, "app/api/workouts/__tests__/batch-create.test.ts": 3, "app/api/workouts/__tests__/canonical-dedup.test.ts": 3, - "app/api/workouts/__tests__/detail.test.ts": 1, "app/api/workouts/[id]/route.ts": 5, "app/api/workouts/batch/route.ts": 9, "app/api/workouts/route.ts": 2, diff --git a/src/__tests__/integration-truncate-deadlock-retry.test.ts b/src/__tests__/integration-truncate-deadlock-retry.test.ts index a5b423a11..1852ef3f0 100644 --- a/src/__tests__/integration-truncate-deadlock-retry.test.ts +++ b/src/__tests__/integration-truncate-deadlock-retry.test.ts @@ -12,6 +12,18 @@ import { describe, expect, it, vi } from "vitest"; import { truncateAllTables } from "../../tests/integration/setup"; +import type { IntegrationDatabasePlan } from "../../tests/integration/worker-database"; + +/** What global setup reads off the migrated template, cut down to the shape. */ +const PLAN: IntegrationDatabasePlan = { + templateSchemas: ["public"], + tables: ["app_settings", "mood_tag_categories", "mood_tags", "users"], + reference: [ + { table: "app_settings", rowsJson: '[{"id":"singleton"}]' }, + { table: "mood_tag_categories", rowsJson: '[{"id":"c1"}]' }, + { table: "mood_tags", rowsJson: '[{"id":"t1","category_id":"c1"}]' }, + ], +}; function deadlock(): Error & { code: string } { const error = new Error("deadlock detected") as Error & { code: string }; @@ -19,45 +31,61 @@ function deadlock(): Error & { code: string } { return error; } -function fakeClient(executeRaw: ReturnType) { - const empty = vi.fn().mockResolvedValue([]); - return { - $executeRawUnsafe: executeRaw, - moodTag: { findMany: empty, createMany: vi.fn() }, - moodTagCategory: { findMany: empty, createMany: vi.fn() }, - } as unknown as Parameters[0]; +type Execute = (query: string, ...values: unknown[]) => Promise; + +function fakeClient(executeRaw: Execute) { + return { $executeRawUnsafe: executeRaw }; } describe("truncateAllTables", () => { it("retries a deadlocked truncate and succeeds once the lock clears", async () => { const executeRaw = vi - .fn() + .fn() .mockRejectedValueOnce(deadlock()) .mockRejectedValueOnce(deadlock()) .mockResolvedValue(0); - await truncateAllTables(fakeClient(executeRaw)); + await truncateAllTables(fakeClient(executeRaw), PLAN); - expect(executeRaw).toHaveBeenCalledTimes(3); + // Three truncate attempts, then the two seeded tables put back. + expect(executeRaw).toHaveBeenCalledTimes(5); + }); + + it("empties every catalogued table and puts the seeded rows back in key order", async () => { + const executeRaw = vi.fn().mockResolvedValue(0); + + await truncateAllTables(fakeClient(executeRaw), PLAN); + + const statements = executeRaw.mock.calls.map((call) => String(call[0])); + expect(statements[0]).toBe( + 'TRUNCATE TABLE "app_settings", "mood_tag_categories", "mood_tags", "users" RESTART IDENTITY CASCADE;', + ); + // The settings singleton starts empty; the catalogues come back, the + // category before the tag that references it. + expect(statements.slice(1)).toEqual([ + expect.stringContaining('INSERT INTO "mood_tag_categories"'), + expect.stringContaining('INSERT INTO "mood_tags"'), + ]); + expect(executeRaw.mock.calls[2][1]).toBe(PLAN.reference[2].rowsJson); }); it("gives up on an error that is not a deadlock, on the first attempt", async () => { const other = new Error("permission denied") as Error & { code: string }; other.code = "42501"; - const executeRaw = vi.fn().mockRejectedValue(other); + const executeRaw = vi.fn().mockRejectedValue(other); - await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( - "permission denied", - ); + await expect( + truncateAllTables(fakeClient(executeRaw), PLAN), + ).rejects.toThrow("permission denied"); expect(executeRaw).toHaveBeenCalledTimes(1); }); it("stops retrying a deadlock that never clears", async () => { - const executeRaw = vi.fn().mockRejectedValue(deadlock()); + const executeRaw = vi.fn().mockRejectedValue(deadlock()); - await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( - "deadlock detected", - ); + await expect( + truncateAllTables(fakeClient(executeRaw), PLAN), + ).rejects.toThrow("deadlock detected"); expect(executeRaw).toHaveBeenCalledTimes(5); }); }); diff --git a/src/__tests__/unit-suite-browser-install-guard.test.ts b/src/__tests__/unit-suite-browser-install-guard.test.ts index aea369329..8e80cc492 100644 --- a/src/__tests__/unit-suite-browser-install-guard.test.ts +++ b/src/__tests__/unit-suite-browser-install-guard.test.ts @@ -23,8 +23,14 @@ import { join } from "node:path"; const WORKFLOWS = join(process.cwd(), ".github", "workflows"); -/** `run: pnpm test` — the whole unit suite, geometry check included. */ -const RUNS_UNIT_SUITE = /^\s*(?:-\s*)?run:\s*pnpm test\s*$/m; +/** + * `run: pnpm test`, or a direct `vitest run` of the unit config with flags + * only (the sharded CI job, the nightly shuffled run): the whole unit suite + * or a slice of it, geometry check included. A run that names a path or + * another `--config` is a different, narrower suite and does not count. + */ +const RUNS_UNIT_SUITE = + /^\s*(?:-\s*)?run:\s*pnpm test\s*$|pnpm exec vitest run(?![^\n]*--config)(?=\s*$|\s+--)/m; const INSTALLS_BROWSER = /playwright install --only-shell chromium/; describe("the browser the unit suite measures with", () => { diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index fc4287334..9758c75e4 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -507,9 +507,9 @@ const REQUIRED_TREE_MODULE: Readonly> = { const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> = [ { - path: "src/app/api/fhir/__tests__/module-gate.test.ts", + path: "tests/integration/routes/fhir-module-gate.test.ts", proves: - "every /api/fhir data route 403s with module.disabled when doctorReport is off", + "every /api/fhir data route, search and read, 403s with module.disabled when doctorReport is off (real database, real handler)", }, { path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts", diff --git a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts deleted file mode 100644 index 71eb1b3af..000000000 --- a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the - * operator's ceiling. A level above it is refused with 422 - * `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null` - * returns to the instance default; the answer is the resolved `ai.usage`. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const db = vi.hoisted(() => ({ - stored: null as string | null, - settings: { - aiUsageMaxLevel: "STANDARD", - aiUsageDefaultLevel: "STANDARD", - aiSyncRegenGapHours: null, - } as Record, - usage: [] as Array<{ totalTokens: number }>, -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(async () => ({ - aiUsageLevel: db.stored, - timezone: "UTC", - })), - update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => { - db.stored = args.data.aiUsageLevel; - return {}; - }), - }, - appSettings: { findUnique: vi.fn(async () => db.settings) }, - coachUsage: { findMany: vi.fn(async () => db.usage) }, - }, -})); -vi.mock("@/lib/tz/resolver", () => ({ - resolveUserTimezone: vi.fn(async () => "UTC"), -})); -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PUT } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -const put = PUT as unknown as (req: Request) => Promise; - -function req(body: unknown): Request { - return new Request("http://localhost/api/auth/me/ai-usage", { - method: "PUT", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - db.stored = null; - db.settings = { - aiUsageMaxLevel: "STANDARD", - aiUsageDefaultLevel: "STANDARD", - aiSyncRegenGapHours: null, - }; - db.usage = []; - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 29, - resetAt: Date.now() + 3_600_000, - } as never); -}); - -describe("PUT /api/auth/me/ai-usage", () => { - it("stores a level within the ceiling and answers the resolved state", async () => { - db.usage = [ - { totalTokens: 90_000 }, - { totalTokens: 110_000 }, - { totalTokens: 100_000 }, - ]; - const res = await put(req({ level: "frugal" })); - expect(res.status).toBe(200); - expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({ - where: { id: "user-1" }, - data: { aiUsageLevel: "FRUGAL" }, - }); - const { data } = await res.json(); - expect(data).toMatchObject({ - level: "frugal", - preference: "frugal", - defaultLevel: "standard", - maxLevel: "standard", - source: "user", - measuredTokensPerDay: 100_000, - measuredDays: 3, - }); - expect(data.options).toEqual([ - { level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 }, - { level: "standard", allowed: true, estimatedTokensPerDay: 200_000 }, - { level: "full", allowed: false, estimatedTokensPerDay: 666_667 }, - ]); - expect(auditLog).toHaveBeenCalledWith( - "user.aiUsage.update", - expect.objectContaining({ - userId: "user-1", - details: { previous: null, next: "frugal" }, - }), - ); - }); - - it("refuses a level above the ceiling with the ceiling named", async () => { - const res = await put(req({ level: "full" })); - expect(res.status).toBe(422); - const body = await res.json(); - expect(body.meta).toMatchObject({ - errorCode: "ai.usage.aboveCeiling", - maxLevel: "standard", - }); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("accepts full once the operator allows it", async () => { - db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" }; - const res = await put(req({ level: "full" })); - expect(res.status).toBe(200); - expect((await res.json()).data.level).toBe("full"); - }); - - it("returns to the instance default on null", async () => { - db.stored = "FRUGAL"; - const res = await put(req({ level: null })); - expect(res.status).toBe(200); - expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({ - aiUsageLevel: null, - }); - const { data } = await res.json(); - expect(data).toMatchObject({ - level: "standard", - preference: null, - source: "default", - }); - // Fewer than three days of history: no estimate. - expect( - data.options.every( - (o: { estimatedTokensPerDay: number | null }) => - o.estimatedTokensPerDay === null, - ), - ).toBe(true); - }); - - it.each([ - [{ level: "lavish" }], - [{}], - [{ level: "frugal", extra: true }], - [{ level: 1 }], - ])("refuses %j as invalid", async (body) => { - const res = await put(req(body)); - expect(res.status).toBe(422); - expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid"); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("refuses the 31st change in an hour", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const res = await put(req({ level: "frugal" })); - expect(res.status).toBe(429); - expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited"); - expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([ - 30, - 60 * 60 * 1000, - ]); - }); - - it("refuses a caller without a session", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await put(req({ level: "frugal" })); - expect(res.status).toBe(401); - }); -}); diff --git a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts deleted file mode 100644 index e39d0ab96..000000000 --- a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * v1.32.22 — `PUT /api/auth/me/coach-prefs` optimistic concurrency. - * - * A full-replace endpoint hydrated from a stale GET (two tabs / two devices) - * clobbers silently; the base token 409s that. The guarded write covers BOTH - * `coachPrefsJson` AND the mirrored `insightsExcludeMetrics` column in one - * conditional update, so a 409 leaves both untouched together. A tokenless PUT - * keeps the prior unconditional write. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - updateMany: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/context", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, annotate: vi.fn() }; -}); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, PUT } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkPut(body: unknown): Request { - return new Request("http://localhost/api/auth/me/coach-prefs", { - method: "PUT", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); -}); - -describe("GET /api/auth/me/coach-prefs", () => { - it("returns a SAVED user's prefs plus the optimistic-concurrency token", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - coachPrefsJson: { tone: "concise" }, - updatedAt: new Date("2026-07-24T10:00:00.000Z"), - } as never); - - const res = await (GET as () => Promise)(); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { tone: string; updatedAt?: string }; - }; - expect(env.data.tone).toBe("concise"); - expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z"); - }); - - it("returns the PURE default with NO token for a never-saved user", async () => { - // Mirrors the insights-layout never-saved GET: nothing to guard yet, and - // the first save is the tokenless unconditional write (backward-compat). - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - coachPrefsJson: null, - updatedAt: new Date("2026-07-24T10:00:00.000Z"), - } as never); - - const res = await (GET as () => Promise)(); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { tone: string; updatedAt?: string }; - }; - expect(env.data.tone).toBe("warm"); - expect(env.data.updatedAt).toBeUndefined(); - }); -}); - -describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => { - it("guards both columns in one conditional write and echoes the token", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - updatedAt: new Date("2026-07-24T10:05:00.000Z"), - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ - excludeMetrics: ["weight"], - baseUpdatedAt: "2026-07-24T10:00:00.000Z", - }), - ); - expect(res.status).toBe(200); - expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0] as { - where: { id: string; updatedAt: Date }; - data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] }; - }; - expect((call.where.updatedAt as Date).toISOString()).toBe( - "2026-07-24T10:00:00.000Z", - ); - // Both mirror columns ride the SAME guarded update. - expect(call.data.coachPrefsJson).toBeTruthy(); - expect(call.data.insightsExcludeMetrics).toEqual(["weight"]); - expect(prisma.user.update).not.toHaveBeenCalled(); - const env = (await res.json()) as { data: { updatedAt: string } }; - expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z"); - }); - - it("interleaved writers: a stale token 409s and leaves BOTH columns untouched", async () => { - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ - excludeMetrics: ["weight"], - baseUpdatedAt: "2026-07-24T08:00:00.000Z", - }), - ); - expect(res.status).toBe(409); - const env = (await res.json()) as { meta?: { errorCode?: string } }; - expect(env.meta?.errorCode).toBe("coach_prefs_conflict"); - // Nothing written — the single guarded update matched no row. - expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("keeps the unconditional write when the token is omitted (compat)", async () => { - vi.mocked(prisma.user.update).mockResolvedValue({ - updatedAt: new Date("2026-07-24T11:00:00.000Z"), - } as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ excludeMetrics: ["weight"] }), - ); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { - data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] }; - }; - expect(call.data.insightsExcludeMetrics).toEqual(["weight"]); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); - - it("422s a malformed base token without touching the row", async () => { - const res = await (PUT as (r: Request) => Promise)( - mkPut({ excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" }), - ); - expect(res.status).toBe(422); - const env = (await res.json()) as { meta?: { errorCode?: string } }; - expect(env.meta?.errorCode).toBe("invalid_base_updated_at"); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); -}); - -describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => { - it("stores the chosen level and names it on the wide event", async () => { - vi.mocked(prisma.user.update).mockResolvedValue({ - updatedAt: new Date("2026-10-06T11:00:00.000Z"), - } as never); - const { annotate } = await import("@/lib/logging/context"); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ reasoning: "high" }), - ); - expect(res.status).toBe(200); - const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { - data: { coachPrefsJson: { reasoning?: string } }; - }; - expect(call.data.coachPrefsJson.reasoning).toBe("high"); - const env = (await res.json()) as { data: { reasoning?: string } }; - expect(env.data.reasoning).toBe("high"); - const meta = vi - .mocked(annotate) - .mock.calls.map((c) => c[0]) - .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; - expect(meta).toMatchObject({ reasoning: "high" }); - }); - - it("reads an absent level as the default on the wide event", async () => { - vi.mocked(prisma.user.update).mockResolvedValue({ - updatedAt: new Date("2026-10-06T11:00:00.000Z"), - } as never); - const { annotate } = await import("@/lib/logging/context"); - - await (PUT as (r: Request) => Promise)(mkPut({ tone: "warm" })); - const meta = vi - .mocked(annotate) - .mock.calls.map((c) => c[0]) - .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; - expect(meta).toMatchObject({ reasoning: "medium" }); - }); - - it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => { - const res = await (PUT as (r: Request) => Promise)( - mkPut({ reasoning: value }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts b/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts deleted file mode 100644 index 27ded92be..000000000 --- a/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - device: { - findUnique: vi.fn(), - delete: vi.fn(), - }, - refreshToken: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - apiToken: { - updateMany: vi.fn(), - }, - // v1.4.23 — the cascade now runs through `prisma.$transaction` - // (HIGH 6 fix). The mock returns the resolved batch payload so the - // route handler walks past the transaction step. - $transaction: vi.fn().mockResolvedValue([] as never), - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { DELETE } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function req(): NextRequest { - return new NextRequest("http://localhost/api/auth/me/devices/dev-x", { - method: "DELETE", - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]); - vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ - count: 0, - } as never); - vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ - count: 0, - } as never); - vi.mocked(prisma.$transaction).mockResolvedValue([] as never); -}); - -describe("DELETE /api/auth/me/devices/[id]", () => { - it("returns 401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-1" }), - }); - expect(res.status).toBe(401); - }); - - it("returns 404 when the device doesn't exist", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findUnique).mockResolvedValue(null); - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-x" }), - }); - expect(res.status).toBe(404); - }); - - it("returns 404 when the device belongs to another user (no enumeration)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findUnique).mockResolvedValue({ - id: "dev-x", - userId: "user-2", - model: "iPhone", - bundleId: "io.healthlog.app", - } as never); - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-x" }), - }); - expect(res.status).toBe(404); - expect(vi.mocked(prisma.device.delete)).not.toHaveBeenCalled(); - }); - - it("revokes refresh tokens + access tokens + deletes the device row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findUnique).mockResolvedValue({ - id: "dev-1", - userId: "user-1", - model: "iPhone", - bundleId: "io.healthlog.app", - } as never); - vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([ - { accessTokenHash: "hash:a1" }, - { accessTokenHash: "hash:a2" }, - { accessTokenHash: null }, - ] as never); - vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ - count: 3, - } as never); - vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ - count: 2, - } as never); - vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never); - - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-1" }), - }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.data.revoked).toBe(true); - expect(body.data.refreshTokensRevoked).toBe(3); - expect(body.data.accessTokensRevoked).toBe(2); - expect(vi.mocked(prisma.device.delete)).toHaveBeenCalledWith({ - where: { id: "dev-1" }, - }); - // The refresh-token revoke filter MUST scope to the deviceId, not - // every refresh row the user has. - const updateCall = vi.mocked(prisma.refreshToken.updateMany).mock - .calls[0]?.[0]; - expect(updateCall?.where).toMatchObject({ - userId: "user-1", - deviceId: "dev-1", - revokedAt: null, - }); - }); -}); diff --git a/src/app/api/auth/me/devices/__tests__/route.test.ts b/src/app/api/auth/me/devices/__tests__/route.test.ts deleted file mode 100644 index a8ff6aa4e..000000000 --- a/src/app/api/auth/me/devices/__tests__/route.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - device: { - findMany: vi.fn(), - }, - refreshToken: { - findFirst: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function req(deviceIdHeader?: string): NextRequest { - const headers: Record = {}; - if (deviceIdHeader) headers["x-device-id"] = deviceIdHeader; - return new NextRequest("http://localhost/api/auth/me/devices", { - method: "GET", - headers, - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(prisma.device.findMany).mockResolvedValue([]); - vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null); -}); - -describe("GET /api/auth/me/devices", () => { - it("returns 401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await GET(req()); - expect(res.status).toBe(401); - }); - - it("lists devices owned by the current user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findMany).mockResolvedValue([ - { - id: "dev-1", - userId: "user-1", - platform: "ios", - token: "tok-1", - bundleId: "io.healthlog.app", - locale: "en-US", - appVersion: "1.0", - model: "iPhone 14 Pro", - apnsToken: "aaaa", - apnsEnvironment: "production", - lastSeen: new Date("2026-05-10T12:00:00Z"), - createdAt: new Date("2026-05-01T12:00:00Z"), - updatedAt: new Date("2026-05-10T12:00:00Z"), - }, - { - id: "dev-2", - userId: "user-1", - platform: "ios", - token: "tok-2", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPad Pro", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date("2026-05-09T12:00:00Z"), - createdAt: new Date("2026-04-01T12:00:00Z"), - updatedAt: new Date("2026-05-09T12:00:00Z"), - }, - ] as never); - - const res = await GET(req()); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.data.devices).toHaveLength(2); - expect(body.data.devices[0].id).toBe("dev-1"); - expect(body.data.devices[0].channels).toEqual(["web_push", "apns"]); - expect(body.data.devices[1].channels).toEqual(["web_push"]); - }); - - it("marks the matching device as isCurrent when X-Device-Id is anchored by an unrevoked refresh token", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue({ - id: "rt-1", - } as never); - vi.mocked(prisma.device.findMany).mockResolvedValue([ - { - id: "dev-1", - userId: "user-1", - platform: "ios", - token: "tok-1", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPhone", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "dev-2", - userId: "user-1", - platform: "ios", - token: "tok-2", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPad", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - ] as never); - - const res = await GET(req("dev-2")); - const body = await res.json(); - const current = body.data.devices.find( - (d: { isCurrent: boolean; id: string }) => d.isCurrent, - ); - expect(current?.id).toBe("dev-2"); - }); - - it("only queries devices for the authenticated user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - await GET(req()); - const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where; - expect(where).toEqual({ userId: "user-1" }); - }); - - // v1.4.23 — a forged X-Device-Id header - // pointing at a device the caller doesn't actually hold a credential - // for must NOT flip `isCurrent: true`. The route now cross-checks the - // header against an unrevoked RefreshToken row owned by the same - // user; when no such row exists, every device returns - // `isCurrent: false`. - it("ignores a forged X-Device-Id header when no refresh token anchors it", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null); - vi.mocked(prisma.device.findMany).mockResolvedValue([ - { - id: "dev-1", - userId: "user-1", - platform: "ios", - token: "tok-1", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPhone", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "dev-2", - userId: "user-1", - platform: "ios", - token: "tok-2", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPad", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - ] as never); - - // Caller forges a header pointing at dev-2 but has no live - // refresh-token bound to that device. Expectation: every device is - // `isCurrent: false`. - const res = await GET(req("dev-2")); - const body = await res.json(); - expect( - body.data.devices.every((d: { isCurrent: boolean }) => !d.isCurrent), - ).toBe(true); - - // The refresh-token lookup must scope by userId AND deviceId AND - // revokedAt: null — never trust the header without all three. - const lookupWhere = vi.mocked(prisma.refreshToken.findFirst).mock - .calls[0]?.[0]?.where; - expect(lookupWhere).toEqual({ - userId: "user-1", - deviceId: "dev-2", - revokedAt: null, - }); - }); -}); diff --git a/src/app/api/auth/me/diabetes/__tests__/route.test.ts b/src/app/api/auth/me/diabetes/__tests__/route.test.ts deleted file mode 100644 index 0cee31013..000000000 --- a/src/app/api/auth/me/diabetes/__tests__/route.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -/** - * The declared diabetes flag is the one preference that switches every - * glucose surface from the general normal bands to the ADA glycemic goal - * bands and reaches the Coach snapshot. The web toggle in the thresholds - * section rides this route, so the round trip has to hold: a PATCH writes - * the column and echoes the resolved state, and a GET reads it back. - */ - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 59, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, PATCH } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkGet(): Request { - return new Request("http://localhost/api/auth/me/diabetes"); -} - -function mkPatch(body: unknown): Request { - return new Request("http://localhost/api/auth/me/diabetes", { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("GET /api/auth/me/diabetes", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(401); - expect(prisma.user.findUnique).not.toHaveBeenCalled(); - }); - - it("returns false for a user who never declared the flag", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: false, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(false); - }); - - it("returns true after the flag was declared", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: true, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(true); - }); -}); - -describe("PATCH /api/auth/me/diabetes", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("writes the flag on, echoes it back, and audits the transition", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: false, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(true); - - // The write must actually land, scoped to the caller, field-by-field. - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { hasDiabetes: true }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.diabetes.update", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ previous: false, next: true }), - }), - ); - }); - - it("writes the flag off and echoes the resolved state", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: true, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: false }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(false); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { hasDiabetes: false }, - }); - }); - - it("rejects a missing hasDiabetes field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ otherField: true }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("rejects a non-boolean hasDiabetes field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: "true" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("returns 429 when the per-user rate-limit fires", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 30_000, - }); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts deleted file mode 100644 index 0726fb835..000000000 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 59, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, PATCH } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkPatch(body: unknown): Request { - return new Request("http://localhost/api/auth/me/disable-coach", { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("GET /api/auth/me/disable-coach", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(401); - expect(prisma.user.findUnique).not.toHaveBeenCalled(); - }); - - it("returns false for a fresh user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: false, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(false); - }); - - it("returns true for an opted-out user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: true, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(true); - }); - - it("returns false when the column is missing (partial-deploy rollback)", async () => { - // Defensive default — the API surface tolerates a `findUnique` - // result that doesn't contain the column. Belt-and-braces with the - // schema-level NOT NULL default; both layers keep the Coach - // visible by default for users who never opted out. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(false); - }); -}); - -describe("PATCH /api/auth/me/disable-coach", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("flips the column on and writes the enable audit row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: false, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(true); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { disableCoach: true }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.disable-coach.enable", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - previous: false, - next: true, - }), - }), - ); - }); - - it("flips the column off and writes the disable audit row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: true, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: false }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(false); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { disableCoach: false }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.disable-coach.disable", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - previous: true, - next: false, - }), - }), - ); - }); - - it("rejects a missing disableCoach field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ otherField: "ignored" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("rejects a non-boolean disableCoach field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: "true" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - // 400, not the 422 this used to pin. A body that will not parse is a - // client-side serialisation fault, and `safeJson` plus roughly two hundred - // and twenty other routes have always answered 400 for it; the dotted token - // moved to `meta.errorCode`, which is where a machine code belongs. - it("rejects malformed JSON with 400", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const req = new Request("http://localhost/api/auth/me/disable-coach", { - method: "PATCH", - body: "{ not valid json", - headers: { "Content-Type": "application/json" }, - }); - - const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(400); - expect(await res.json()).toMatchObject({ - error: "Invalid JSON body", - meta: { errorCode: "disable-coach.body.invalid_json" }, - }); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("is idempotent when the column already holds the requested value", async () => { - // Toggling a column from true→true (or false→false) still writes - // through so the audit row exists. Matches the doctor-report-prefs - // route's "always write" posture. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: true, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledTimes(1); - expect(auditLog).toHaveBeenCalledTimes(1); - }); - - it("returns 429 when the per-user rate-limit fires", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 30_000, - }); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts b/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts deleted file mode 100644 index 037b72c43..000000000 --- a/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * The strict half, end to end on a real route. - * - * `patchBodySchema` is `.strict()`, so a PATCH carrying a key the route - * does not declare is refused rather than trimmed down to the keys it - * does. Before that, `{ disableCoachh: true }` parsed clean as `{}` — - * except `disableCoach` is required, so this particular body already - * 422'd on the missing field. The one worth pinning is the body that - * used to succeed while doing something other than what it said: - * `{ disableCoach: true, disableCoachh: false }`. - * - * The second assertion is the user-visible half. A 422 that says only - * "Validation failed" leaves the caller staring at a body it believes is - * correct, so the envelope has to name the key it refused — and name it - * without reflecting a hostile one back verbatim. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 59, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PATCH } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkPatch(body: unknown): Request { - return new Request("http://localhost/api/auth/me/disable-coach", { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -const patch = PATCH as (r: Request) => Promise; - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.update).mockResolvedValue({ - disableCoach: true, - } as never); -}); - -describe("PATCH /api/auth/me/disable-coach — unknown keys", () => { - it("accepts the declared body", async () => { - const res = await patch(mkPatch({ disableCoach: true })); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledTimes(1); - }); - - it("refuses a body that carries an undeclared key alongside a valid one", async () => { - const res = await patch( - mkPatch({ disableCoach: true, disableCoachh: false }), - ); - expect(res.status).toBe(422); - // Nothing was written. Before `.strict()` this wrote `disableCoach: - // true` and answered 200, so a caller that meant the second field - // was told its request had been honoured. - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("names the offending key in the 422", async () => { - const res = await patch( - mkPatch({ disableCoach: true, disableCoachh: false }), - ); - const body = (await res.json()) as { - error: string; - details?: { - issues: Array<{ code: string; message: string; keys?: string[] }>; - }; - }; - const issue = body.details?.issues.find( - (i) => i.code === "unrecognized_keys", - ); - expect(issue).toBeDefined(); - expect(issue?.keys).toEqual(["disableCoachh"]); - expect(issue?.message).toContain("disableCoachh"); - }); - - it("does not reflect a hostile key name back verbatim", async () => { - const res = await patch( - mkPatch({ disableCoach: true, "": 1 }), - ); - expect(res.status).toBe(422); - const raw = await res.text(); - expect(raw).not.toContain("": 1 }, + auth: await makeSession(user), + }); + expect(res.status).toBe(422); + const raw = JSON.stringify(res.body); + expect(raw).not.toContain("