Merge branch 'release/v1.45.0' into feat/v145-runtime

This commit is contained in:
Marc-André Bombeck committed 2026-10-22 06:41:30 +02:00
commit a859adb59c
146 files changed
+24075 -13836

No files matched your search

+30 -13
View File
@@ -10,20 +10,23 @@ permissions:
contents: read contents: read
jobs: jobs:
# The suite outgrew a single runner, then a half-suite outgrew a slow one, # Four shards of about equal duration, each running its files in parallel
# and then a third of it did too. Three shards of about 68 files cleared in # against one database per worker. The shards used to be four slices of
# 13 minutes on an ordinary runner, but a slow runner day ran every test at # equal file count, run serially: 954 s to 1 449 s, because a slice holding
# twice its usual duration and the first shard was still working through # the sharing matrix and the backup files took half again as long as one
# its list when the 25-minute guard cut it off: three of four pushes to main # holding small files. `--shard` now goes through the duration sequencer
# between 2026-08-31 and 2026-09-01 failed that way, with green tests logged # (`tests/durations/sequencer.ts`, timings in
# up to the last second. Four shards keep each slice around 50 files, and # `tests/durations/integration.json`), and every file in a shard runs on
# the guard sits at three times the ordinary run rather than under two. A # one of three workers, each against its own clone of the migrated template
# wedged test still surfaces through vitest's own 60-second budget, so the # (`tests/integration/worker-database.ts`). Three workers leave a core of
# job timeout only needs to catch a container that never boots. Each shard # the four-core runner to the Postgres container. The job guard stays well
# boots its own Postgres testcontainer. # 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: shard:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
timeout-minutes: 40 timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -48,8 +51,13 @@ jobs:
# vitest a stray positional argument and the shard flag never applies — # vitest a stray positional argument and the shard flag never applies —
# both "shards" then run the full suite. Proven by a paired local run # both "shards" then run the full suite. Proven by a paired local run
# before this workflow shipped. # 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: env:
HEALTHLOG_INTEGRATION_WORKERS: "3"
# The integration suite boots a Postgres testcontainer and points # The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
# These env vars satisfy code paths that read them at module load. # These env vars satisfy code paths that read them at module load.
@@ -63,6 +71,15 @@ jobs:
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000" ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests" 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 # Branch protection requires a check named exactly "integration". This job
# carries that name and answers for every shard: it fails when any shard # 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. # failed or was cancelled, so a half-green matrix cannot satisfy the gate.
+48
View File
@@ -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
+94 -30
View File
@@ -15,11 +15,72 @@ permissions:
contents: read contents: read
jobs: jobs:
quality: # The static checks: everything in the old single quality job except the
name: Lint, Typecheck & Test # unit suite, which runs sharded below.
static:
name: Lint, Typecheck & OpenAPI
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
if: github.event_name != 'schedule' 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: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -38,7 +99,8 @@ jobs:
# real browser and measures the boxes. Without a browser here it caught # 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 # 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 # 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 # Headless shell only: `chromium.launch()` resolves to
# chromium_headless_shell, which is what the failing runs named, and the # 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 - run: pnpm exec playwright install --only-shell chromium
# Blocking since v1.15.20 — the settings-split refactor that kept # vitest directly, not `pnpm test --`: pnpm hands the separator through
# this warn-only is long done and the tree lints clean. # literally and the shard flag would never reach vitest.
- 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
- name: Unit tests - 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). - name: Upload the timing report
# if: always()
# The Zod registry is the source of truth for the public API contract. uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
# Any route signature change must be reflected in `docs/api/openapi.yaml` with:
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in name: unit-report-${{ matrix.shard }}
# lockstep. If this step fails, run `pnpm openapi:generate` locally and path: unit-report-${{ matrix.shard }}.json
# commit the regenerated spec. if-no-files-found: ignore
- name: OpenAPI spec drift check 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: | run: |
pnpm openapi:generate if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
if ! git diff --exit-code docs/api/openapi.yaml; then echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
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 exit 1
fi fi
echo "::notice::OpenAPI spec in sync with Zod source schemas."
# Not on pull requests. `pnpm audit` answers a question about the published # Not on pull requests. `pnpm audit` answers a question about the published
# advisory database, not about the diff: the same lockfile passes on Monday # advisory database, not about the diff: the same lockfile passes on Monday
+169
View File
@@ -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=<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
+3
View File
@@ -98,3 +98,6 @@ next-env.d.ts
# same reason in a different tool. # same reason in a different tool.
/.wt-*/ /.wt-*/
/.worktrees/ /.worktrees/
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
/reports/mutation
/.stryker-tmp
+3
View File
@@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 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 COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false RUN pnpm install --frozen-lockfile --prod=false
+3
View File
@@ -62,6 +62,9 @@ notes, and machine-readable specs that ship alongside the source:
- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the - [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the
large-account benchmark (latency and statements per request) and the large-account benchmark (latency and statements per request) and the
statement budgets on the hot routes. 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 - [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win. it disagrees with the UI standards, the standards win.
+219
View File
@@ -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: "<reason>" }`.
`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=<seed> pnpm test <file>` 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=<seed>`.
+3
View File
@@ -108,6 +108,8 @@
"@axe-core/playwright": "^4.13.0", "@axe-core/playwright": "^4.13.0",
"@next/bundle-analyzer": "^16.3.8", "@next/bundle-analyzer": "^16.3.8",
"@playwright/test": "^1.64.0", "@playwright/test": "^1.64.0",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tesseract.js-data/deu": "1.0.0", "@tesseract.js-data/deu": "1.0.0",
"@tesseract.js-data/eng": "1.0.0", "@tesseract.js-data/eng": "1.0.0",
@@ -122,6 +124,7 @@
"ajv": "^8.20.0", "ajv": "^8.20.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.3.8", "eslint-config-next": "16.3.8",
"fast-check": "4.10.2",
"knip": "^6.41.0", "knip": "^6.41.0",
"lefthook": "^2.2.2", "lefthook": "^2.2.2",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
@@ -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)}`;
+902 -34
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -131,3 +131,6 @@ auditConfig:
# never runs. No patched release is published yet (latest is 1.4.0); # 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. # remove this entry and raise the floor once 1.4.1 is out.
- GHSA-86w9-cpqp-85rv - GHSA-86w9-cpqp-85rv
patchedDependencies:
"@stryker-mutator/vitest-runner@10.0.0": patches/@stryker-mutator__vitest-runner@10.0.0.patch
+3
View File
@@ -157,6 +157,9 @@ describe("container dependency installation", () => {
expect(dockerfile).toContain( expect(dockerfile).toContain(
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" + "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" + "COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
"RUN pnpm install --frozen-lockfile --prod=false", "RUN pnpm install --frozen-lockfile --prod=false",
); );
+78
View File
@@ -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);
}
+77
View File
@@ -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 <suite> <report.json> [<report.json> …]
*
* `<suite>` is `unit` or `integration` and names the file written,
* `tests/durations/<suite>.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 <unit|integration> <report.json>…",
);
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`,
);
@@ -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.", "$comment": "route tests that mock @/lib/db wholesale, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 321, "count": 274,
"total": 321, "total": 274,
"entries": { "entries": {
"app/api/account/grants/__tests__/invite-access-level.test.ts": 1, "app/api/account/grants/__tests__/invite-access-level.test.ts": 1,
"app/api/account/grants/__tests__/invite-recent-proof.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__/native-token.test.ts": 1,
"app/api/auth/login/__tests__/route.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/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/disable/__tests__/route.test.ts": 1,
"app/api/auth/me/mfa/webauthn/register/__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/mfa/webauthn/verify/__tests__/route.test.ts": 1,
"app/api/auth/native/complete/__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, "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/encrypted/__tests__/route.test.ts": 1,
"app/api/export/health-record/__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/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/connect/__tests__/route.test.ts": 1,
"app/api/fitbit/credentials/__tests__/route.test.ts": 1, "app/api/fitbit/credentials/__tests__/route.test.ts": 1,
"app/api/gamification/achievements/__tests__/hidden-redaction.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/measurements/series/__tests__/unknown-query-param-open.test.ts": 1,
"app/api/medications/__tests__/route.test.ts": 1, "app/api/medications/__tests__/route.test.ts": 1,
"app/api/medications/[id]/__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]/compliance/__tests__/route.test.ts": 1,
"app/api/medications/[id]/dose-history/__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]/glp1/__tests__/route.test.ts": 1,
"app/api/medications/[id]/intake/__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/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.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/__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]/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/compliance/__tests__/route.test.ts": 1,
"app/api/medications/intake/__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, "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/bulk/__tests__/route.test.ts": 1,
"app/api/mood-entries/restore/__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/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/nightscout/connect/__tests__/route.test.ts": 1,
"app/api/notifications/action/[token]/__tests__/route.test.ts": 1, "app/api/notifications/action/[token]/__tests__/route.test.ts": 1,
"app/api/notifications/status/__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/disconnect/__tests__/route.test.ts": 1,
"app/api/polar/status/__tests__/route.test.ts": 1, "app/api/polar/status/__tests__/route.test.ts": 1,
"app/api/search/__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/share-links/__tests__/route.test.ts": 1,
"app/api/sleep/night/__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/sleep/rhythm/__tests__/route.test.ts": 1,
"app/api/strava/callback/__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/changes/__tests__/module-gate.test.ts": 1,
"app/api/sync/state/__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/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/documents/__tests__/route.test.ts": 1,
"app/api/tokens/measurements/__tests__/route.test.ts": 1, "app/api/tokens/measurements/__tests__/route.test.ts": 1,
"app/api/tokens/workouts/__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/__tests__/route.test.ts": 1,
"app/api/withings/webhook/[token]/__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-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__/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 "app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 1
} }
} }
@@ -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.", "$comment": "route tests that stub @/lib/api-handler, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 138, "count": 133,
"total": 138, "total": 133,
"entries": { "entries": {
"app/api/account/grants/__tests__/invite-access-level.test.ts": 1, "app/api/account/grants/__tests__/invite-access-level.test.ts": 1,
"app/api/account/grants/__tests__/invite-scope-and-manage.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/polar/sync/__tests__/route.test.ts": 1,
"app/api/record-settings/integrations/[provider]/__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/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/callback/__tests__/route.test.ts": 1,
"app/api/strava/connect/__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/sync/state/__tests__/route.test.ts": 1,
"app/api/telegram/webhook/__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/documents/__tests__/route.test.ts": 1,
"app/api/tokens/measurements/__tests__/route.test.ts": 1, "app/api/tokens/measurements/__tests__/route.test.ts": 1,
"app/api/tokens/workouts/__tests__/route.test.ts": 1, "app/api/tokens/workouts/__tests__/route.test.ts": 1,
+2 -48
View File
@@ -1,7 +1,7 @@
{ {
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", "$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 570, "count": 524,
"total": 6014, "total": 5496,
"entries": { "entries": {
"__tests__/api/auth/login/mfa-branch.test.ts": 10, "__tests__/api/auth/login/mfa-branch.test.ts": 10,
"__tests__/api/auth/mfa/verify.test.ts": 9, "__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__/native-token.test.ts": 8,
"app/api/auth/login/__tests__/route.test.ts": 5, "app/api/auth/login/__tests__/route.test.ts": 5,
"app/api/auth/login/__tests__/trusted-device.test.ts": 2, "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/disable/__tests__/route.test.ts": 7,
"app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 8, "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/mfa/webauthn/verify/__tests__/route.test.ts": 11,
"app/api/auth/native/complete/__tests__/route.test.ts": 2, "app/api/auth/native/complete/__tests__/route.test.ts": 2,
"app/api/auth/native/login/__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/encrypted/__tests__/route.test.ts": 20,
"app/api/export/health-record/__tests__/route.test.ts": 32, "app/api/export/health-record/__tests__/route.test.ts": 32,
"app/api/feature-flags/__tests__/route.test.ts": 6, "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__/observation-series.test.ts": 8,
"app/api/fhir/__tests__/route.test.ts": 9, "app/api/fhir/__tests__/route.test.ts": 9,
"app/api/fitbit/connect/__tests__/route.test.ts": 4, "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/measurements/series/__tests__/unknown-query-param-open.test.ts": 6,
"app/api/medications/__tests__/route.test.ts": 43, "app/api/medications/__tests__/route.test.ts": 43,
"app/api/medications/[id]/__tests__/route.test.ts": 52, "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]/compliance/__tests__/route.test.ts": 18,
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15, "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]/glp1/__tests__/route.test.ts": 47,
"app/api/medications/[id]/intake/__tests__/route.test.ts": 45, "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/__tests__/source-provenance.test.ts": 12,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63, "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/__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]/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/compliance/__tests__/route.test.ts": 10,
"app/api/medications/extract/__tests__/route.test.ts": 38, "app/api/medications/extract/__tests__/route.test.ts": 38,
"app/api/medications/intake/__tests__/route.test.ts": 59, "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-entries/restore/__tests__/route.test.ts": 11,
"app/api/mood/analytics/__tests__/route.test.ts": 15, "app/api/mood/analytics/__tests__/route.test.ts": 15,
"app/api/mood/insights/__tests__/route.test.ts": 3, "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/nightscout/sync/__tests__/route.test.ts": 3,
"app/api/notifications/action/[token]/__tests__/route.test.ts": 5, "app/api/notifications/action/[token]/__tests__/route.test.ts": 5,
"app/api/notifications/status/__tests__/route.test.ts": 4, "app/api/notifications/status/__tests__/route.test.ts": 4,
@@ -264,22 +231,12 @@
"app/api/personal-records/__tests__/route.test.ts": 15, "app/api/personal-records/__tests__/route.test.ts": 15,
"app/api/polar/callback/__tests__/route.test.ts": 1, "app/api/polar/callback/__tests__/route.test.ts": 1,
"app/api/polar/sync/__tests__/route.test.ts": 3, "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/share-links/__tests__/route.test.ts": 19,
"app/api/sleep/night/__tests__/route.test.ts": 24, "app/api/sleep/night/__tests__/route.test.ts": 24,
"app/api/sleep/rhythm/__tests__/route.test.ts": 6, "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/changes/__tests__/module-gate.test.ts": 7,
"app/api/sync/state/__tests__/route.test.ts": 8, "app/api/sync/state/__tests__/route.test.ts": 8,
"app/api/telegram/webhook/__tests__/route.test.ts": 43, "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/documents/__tests__/route.test.ts": 25,
"app/api/tokens/measurements/__tests__/route.test.ts": 25, "app/api/tokens/measurements/__tests__/route.test.ts": 25,
"app/api/tokens/workouts/__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/__tests__/route.test.ts": 26,
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 8, "app/api/withings/webhook/[token]/__tests__/route.test.ts": 8,
"app/api/workouts/__tests__/batch-create.test.ts": 14, "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__/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/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4,
"app/c/[token]/__tests__/report-download.test.ts": 11, "app/c/[token]/__tests__/report-download.test.ts": 11,
"app/c/[token]/d/[id]/__tests__/route.test.ts": 8, "app/c/[token]/d/[id]/__tests__/route.test.ts": 8,
@@ -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.", "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2985, "count": 2962,
"total": 10634, "total": 10592,
"entries": { "entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7, "__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__/native-token.test.ts": 2,
"app/api/auth/login/__tests__/route.test.ts": 1, "app/api/auth/login/__tests__/route.test.ts": 1,
"app/api/auth/login/route.ts": 5, "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/coach-prefs/route.ts": 7,
"app/api/auth/me/cycle-prefs/route.ts": 1, "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/[id]/route.ts": 3,
"app/api/auth/me/devices/route.ts": 2, "app/api/auth/me/devices/route.ts": 2,
"app/api/auth/me/diabetes/route.ts": 1, "app/api/auth/me/diabetes/route.ts": 1,
"app/api/auth/me/disable-coach/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/health-score-config/route.ts": 1,
"app/api/auth/me/injection-site-prefs/route.ts": 1, "app/api/auth/me/injection-site-prefs/route.ts": 1,
"app/api/auth/me/labs-local-ocr/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/disable/route.ts": 1,
"app/api/auth/me/mfa/totp/confirm/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/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/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/notification-prefs/route.ts": 4,
"app/api/auth/me/route.ts": 28, "app/api/auth/me/route.ts": 28,
"app/api/auth/me/security-activity/route.ts": 1, "app/api/auth/me/security-activity/route.ts": 1,
@@ -423,7 +417,6 @@
"app/api/medications/__tests__/route.test.ts": 10, "app/api/medications/__tests__/route.test.ts": 10,
"app/api/medications/[id]/__tests__/route.test.ts": 8, "app/api/medications/[id]/__tests__/route.test.ts": 8,
"app/api/medications/[id]/api-endpoint/route.ts": 2, "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]/cadence/route.ts": 10,
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 7, "app/api/medications/[id]/compliance/__tests__/route.test.ts": 7,
"app/api/medications/[id]/compliance/route.ts": 3, "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/__tests__/source-provenance.test.ts": 1,
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9, "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/[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/bulk-delete/route.ts": 7,
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2, "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/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/purge/route.ts": 4,
"app/api/medications/[id]/intake/route.ts": 30, "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/[itemId]/route.ts": 9,
"app/api/medications/[id]/inventory/route.ts": 5, "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]/phase-config/route.ts": 5,
"app/api/medications/[id]/route.ts": 59, "app/api/medications/[id]/route.ts": 59,
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1, "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/[revisionId]/route.ts": 9,
"app/api/medications/[id]/schedule-revisions/route.ts": 3, "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/[logId]/route.ts": 2,
"app/api/medications/[id]/side-effects/route.ts": 6, "app/api/medications/[id]/side-effects/route.ts": 6,
"app/api/medications/categories/[key]/route.ts": 1, "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/__tests__/route.test.ts": 2,
"app/api/mood/analytics/route.ts": 1, "app/api/mood/analytics/route.ts": 1,
"app/api/mood/insights/route.ts": 3, "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/[key]/hidden/route.ts": 1,
"app/api/mood/tags/custom/[key]/route.ts": 1, "app/api/mood/tags/custom/[key]/route.ts": 1,
"app/api/mood/tags/custom/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/[key]/route.ts": 1,
"app/api/mood/tags/groups/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/layout/route.ts": 3,
"app/api/mood/tags/route.ts": 1, "app/api/mood/tags/route.ts": 1,
"app/api/nightscout/connect/__tests__/route.test.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/[id]/route.ts": 1,
"app/api/practitioners/route.ts": 1, "app/api/practitioners/route.ts": 1,
"app/api/record-settings/integrations/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/account/route.ts": 1,
"app/api/settings/data/route.ts": 2, "app/api/settings/data/route.ts": 2,
"app/api/settings/ntfy/route.ts": 1, "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/reminder-thresholds/route.ts": 2,
"app/api/settings/telegram/test/route.ts": 1, "app/api/settings/telegram/test/route.ts": 1,
"app/api/share-links/__tests__/route.test.ts": 2, "app/api/share-links/__tests__/route.test.ts": 2,
@@ -554,7 +533,6 @@
"app/api/strava/credentials/route.ts": 1, "app/api/strava/credentials/route.ts": 1,
"app/api/strava/disconnect/route.ts": 1, "app/api/strava/disconnect/route.ts": 1,
"app/api/strava/status/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/changes/route.ts": 5,
"app/api/sync/state/route.ts": 2, "app/api/sync/state/route.ts": 2,
"app/api/telegram/webhook/__tests__/route.test.ts": 3, "app/api/telegram/webhook/__tests__/route.test.ts": 3,
@@ -582,7 +560,6 @@
"app/api/withings/webhook/route.ts": 2, "app/api/withings/webhook/route.ts": 2,
"app/api/workouts/__tests__/batch-create.test.ts": 3, "app/api/workouts/__tests__/batch-create.test.ts": 3,
"app/api/workouts/__tests__/canonical-dedup.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/[id]/route.ts": 5,
"app/api/workouts/batch/route.ts": 9, "app/api/workouts/batch/route.ts": 9,
"app/api/workouts/route.ts": 2, "app/api/workouts/route.ts": 2,
@@ -12,6 +12,18 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { truncateAllTables } from "../../tests/integration/setup"; 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 } { function deadlock(): Error & { code: string } {
const error = new Error("deadlock detected") as Error & { code: string }; const error = new Error("deadlock detected") as Error & { code: string };
@@ -19,45 +31,61 @@ function deadlock(): Error & { code: string } {
return error; return error;
} }
function fakeClient(executeRaw: ReturnType<typeof vi.fn>) { type Execute = (query: string, ...values: unknown[]) => Promise<number>;
const empty = vi.fn().mockResolvedValue([]);
return { function fakeClient(executeRaw: Execute) {
$executeRawUnsafe: executeRaw, return { $executeRawUnsafe: executeRaw };
moodTag: { findMany: empty, createMany: vi.fn() },
moodTagCategory: { findMany: empty, createMany: vi.fn() },
} as unknown as Parameters<typeof truncateAllTables>[0];
} }
describe("truncateAllTables", () => { describe("truncateAllTables", () => {
it("retries a deadlocked truncate and succeeds once the lock clears", async () => { it("retries a deadlocked truncate and succeeds once the lock clears", async () => {
const executeRaw = vi const executeRaw = vi
.fn() .fn<Execute>()
.mockRejectedValueOnce(deadlock()) .mockRejectedValueOnce(deadlock())
.mockRejectedValueOnce(deadlock()) .mockRejectedValueOnce(deadlock())
.mockResolvedValue(0); .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<Execute>().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 () => { 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 }; const other = new Error("permission denied") as Error & { code: string };
other.code = "42501"; other.code = "42501";
const executeRaw = vi.fn().mockRejectedValue(other); const executeRaw = vi.fn<Execute>().mockRejectedValue(other);
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( await expect(
"permission denied", truncateAllTables(fakeClient(executeRaw), PLAN),
); ).rejects.toThrow("permission denied");
expect(executeRaw).toHaveBeenCalledTimes(1); expect(executeRaw).toHaveBeenCalledTimes(1);
}); });
it("stops retrying a deadlock that never clears", async () => { it("stops retrying a deadlock that never clears", async () => {
const executeRaw = vi.fn().mockRejectedValue(deadlock()); const executeRaw = vi.fn<Execute>().mockRejectedValue(deadlock());
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( await expect(
"deadlock detected", truncateAllTables(fakeClient(executeRaw), PLAN),
); ).rejects.toThrow("deadlock detected");
expect(executeRaw).toHaveBeenCalledTimes(5); expect(executeRaw).toHaveBeenCalledTimes(5);
}); });
}); });
@@ -23,8 +23,14 @@ import { join } from "node:path";
const WORKFLOWS = join(process.cwd(), ".github", "workflows"); 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/; const INSTALLS_BROWSER = /playwright install --only-shell chromium/;
describe("the browser the unit suite measures with", () => { describe("the browser the unit suite measures with", () => {
@@ -507,9 +507,9 @@ const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> = 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: 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", path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts",
@@ -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<string, unknown>,
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<Response>;
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);
});
});
@@ -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<typeof import("@/lib/logging/context")>();
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<Response>)();
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<Response>)();
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(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<Response>)(
mkPut({ reasoning: value }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -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,
});
});
});
@@ -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<string, string> = {};
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,
});
});
});
@@ -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<Response>)(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<Response>)(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<Response>)(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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
mkPatch({ hasDiabetes: true }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(
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<Response>)(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<Response>)(
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<Response>)(
mkPatch({ disableCoach: true }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -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<Response>;
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, "<script>alert(1)</script>": 1 }),
);
expect(res.status).toBe(422);
const raw = await res.text();
expect(raw).not.toContain("<script>");
expect(raw).toContain("script_alert_1");
});
});
@@ -1,197 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The auto-read opt-in endpoint, and specifically the catch-up it schedules.
*
* The summary job is enqueued at UPLOAD time and no-ops while the flag is OFF,
* so without a catch-up on the flip the toggle only ever applied to future
* uploads — a user who filled the vault first saw the switch do nothing. These
* tests pin that a genuine OFF→ON transition schedules the pass, and that a
* no-op re-save or a flip to OFF does not.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
consentReceipt: { findFirst: vi.fn(), create: 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/ai/capabilities/gate", () => ({
getAiCapability: vi.fn(),
}));
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
}));
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";
import { getAiCapability } from "@/lib/ai/capabilities/gate";
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
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(documentsAutoAiRead: boolean): Request {
return new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
method: "PATCH",
body: JSON.stringify({ documentsAutoAiRead }),
headers: { "Content-Type": "application/json" },
});
}
/** Seed the flag's value BEFORE the PATCH under test. */
function withPrevious(documentsAutoAiRead: boolean) {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentsAutoAiRead,
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({} as never);
vi.mocked(getAiCapability).mockResolvedValue({
available: true,
reason: null,
onDeviceAllowed: true,
});
});
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
it("schedules a catch-up when the opt-in flips OFF→ON", async () => {
withPrevious(false);
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
expect(enqueueSummaryCatchUp).toHaveBeenCalledWith("user-1");
expect(enqueueSummaryCatchUp).toHaveBeenCalledTimes(1);
});
it("does not re-schedule when the opt-in was already ON", async () => {
// Idempotency at the trigger: re-saving an already-ON setting must not
// queue a second pass over the same documents.
withPrevious(true);
await PATCH(mkPatch(true));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
});
it("does not schedule anything when the opt-in is turned OFF", async () => {
withPrevious(true);
await PATCH(mkPatch(false));
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("mints the extraction receipt on the flip that schedules the pass", async () => {
// The catch-up rides the same act of consent, never around it. The toggle
// grants document reading only: `ai_extraction`, not the master `ai_full`
// that would also open the Coach and the AI analysis.
withPrevious(false);
await PATCH(mkPatch(true));
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1);
const data = vi.mocked(prisma.consentReceipt.create).mock.calls[0]![0]
.data as { userId: string; kind: string };
expect(data.userId).toBe("user-1");
expect(data.kind).toBe("ai_extraction");
// The capability is read after the mint, so the fresh receipt counts.
const mintOrder = vi.mocked(prisma.consentReceipt.create).mock
.invocationCallOrder[0]!;
const readOrder = vi.mocked(getAiCapability).mock.invocationCallOrder[0]!;
expect(mintOrder).toBeLessThan(readOrder);
});
it("does not mint a second receipt when one that covers document reads is active", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
id: "r1",
} as never);
await PATCH(mkPatch(true));
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
revokedAt: null,
kind: { in: ["ai_extraction", "ai_full"] },
}),
}),
);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("treats a concurrent mint as success", async () => {
withPrevious(false);
vi.mocked(prisma.consentReceipt.create).mockRejectedValue({
code: "P2002",
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
});
it("saves the preference but schedules no catch-up while document reading is closed", async () => {
// The operator turned document reading off: every summary the pass would
// enqueue could only be refused, so it is not enqueued at all.
withPrevious(false);
vi.mocked(getAiCapability).mockResolvedValue({
available: false,
reason: "operator_disabled",
onDeviceAllowed: false,
});
const res = await PATCH(mkPatch(true));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalled();
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
});
it("persists the flag field-by-field", async () => {
withPrevious(false);
await PATCH(mkPatch(true));
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { documentsAutoAiRead: true },
});
});
});
@@ -1,247 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The blood-glucose display unit — the writer the column never had.
*
* `User.glucoseUnit` shipped in v1.2 and about thirty surfaces read it, but
* no route, form or schema could ever set it, so every account stayed on the
* mg/dL default and the mmol/L half of the app was dead code. This pins the
* missing end: a PATCH writes the column and echoes the resolved state, a GET
* reads it back, and an unrecognised unit is refused rather than stored —
* a stray string in that column would resolve to mg/dL and quietly put every
* mmol/L reader back on the wrong number.
*/
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
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 { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
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/glucose-unit");
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/glucose-unit", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("resolves an account that never chose to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mg/dL");
});
it("reads back the unit the account chose", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
});
});
describe("PATCH /api/auth/me/glucose-unit", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("moves an account to mmol/L, echoes it, and audits the transition", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { glucoseUnit: string } };
expect(env.data.glucoseUnit).toBe("mmol/L");
// The write must actually land, scoped to the caller, field-by-field.
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mmol/L" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.glucose-unit.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: "mg/dL",
next: "mmol/L",
}),
}),
);
});
it("moves an account back to mg/dL", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mg/dL" }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { glucoseUnit: "mg/dL" },
});
});
it("refuses a unit outside the two the app renders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const bad of ["mmol/l", "mgdl", "imperial", "", 1]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: bad }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a missing glucoseUnit field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ otherField: "mmol/L" }),
);
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<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mmol/L",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucoseUnit: "mmol/L" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -1,572 +0,0 @@
/**
* v1.35.0 — GET/PATCH /api/auth/me/health-score-config.
*
* Covers the read projection, the positive-selection-in /
* deselection-out inversion, the write-time breadth refusal, the
* monotonic recipe version, and the score-cache eviction. The
* invalidation assertion is here because a missing one is invisible:
* the write succeeds, the response is right, and the person keeps
* seeing the old number for an hour.
*/
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/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("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserHealthScore: vi.fn() };
});
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";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import type { ScorePillarId } from "@/lib/analytics/score/types";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
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 ALL_PILLARS: ScorePillarId[] = [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
"WELLBEING",
"LIPIDS",
];
interface ResolvedBody {
pillars: ScorePillarId[];
excludedPillars: ScorePillarId[];
hasSelection: boolean;
version: number;
changedAt: string | null;
updatedAt?: string;
}
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/health-score-config", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
/** Mutable row, so a write is visible to the read that follows it. */
function primeUser(healthScoreConfigJson: unknown = null) {
const row: { healthScoreConfigJson: unknown; updatedAt: Date } = {
healthScoreConfigJson,
updatedAt: new Date("2026-07-31T09:00:00.000Z"),
};
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { healthScoreConfigJson?: unknown };
}) => {
if (args.data && "healthScoreConfigJson" in args.data) {
row.healthScoreConfigJson = args.data.healthScoreConfigJson;
}
return Promise.resolve({ updatedAt: row.updatedAt });
}) as never);
return row;
}
function writtenConfig(): {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
} {
const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
data: {
healthScoreConfigJson: {
excludedPillars: ScorePillarId[];
version: number;
changedAt: string;
};
};
};
return call.data.healthScoreConfigJson;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
expect(res.status).toBe(401);
});
it("reports every pillar counting, and no selection, for a fresh account", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(ALL_PILLARS);
expect(env.data.hasSelection).toBe(false);
expect(env.data.version).toBe(0);
expect(env.data.updatedAt).toBeUndefined();
});
it("reports a stored selection as the pillars it leaves counting", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({
excludedPillars: ["SLEEP", "LIPIDS"],
version: 2,
changedAt: "2026-07-30T08:00:00.000Z",
});
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/health-score-config"),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(
ALL_PILLARS.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
expect(env.data.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(env.data.hasSelection).toBe(true);
expect(env.data.version).toBe(2);
expect(env.data.updatedAt).toBe("2026-07-31T09:00:00.000Z");
});
});
describe("PATCH /api/auth/me/health-score-config", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("stores the complement of the selection and returns the selection", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
];
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: selection }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: ResolvedBody };
expect(env.data.pillars).toEqual(selection);
expect(env.data.hasSelection).toBe(true);
const stored = writtenConfig();
expect(stored.excludedPillars).toEqual(
ALL_PILLARS.filter((id) => !selection.includes(id)),
);
expect(stored.version).toBe(1);
expect(stored.changedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(auditLog).toHaveBeenCalledWith(
"user.health_score_config.update",
expect.objectContaining({ userId: "user-1" }),
);
});
it("increments the recipe version on every accepted write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({
excludedPillars: ["LIPIDS"],
version: 7,
changedAt: "2026-07-01T00:00:00.000Z",
});
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(writtenConfig().version).toBe(8);
});
it("keeps a kept-everything selection as an authored choice", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(writtenConfig().excludedPillars).toEqual([]);
expect(env.data.hasSelection).toBe(true);
});
it("evicts the score caches so the old number is not served on", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
});
/**
* v1.38 — what the write still refuses, after the breadth floor became a
* recommendation.
*
* Two of the three refusals this block used to pin are gone. The route
* turned away `["ACTIVITY", "WELLBEING"]` with
* `measured_physiological_domain_required` and the cardiometabolic
* triple with `three_domains_required`, both on the ground that the
* selection could not produce a score. It can now — the scorer computes
* from whatever is readable and labels how broad the set was — and a
* write that kept refusing what the read is willing to serve would be
* the two layers disagreeing, which is the drift the shared rule exists
* to prevent. So those two assertions are inverted to acceptances.
*
* The empty selection stays refused, and that case is why this is a
* change of rule rather than a removal of one.
*/
describe("PATCH — the breadth rule", () => {
it("accepts activity and wellbeing alone, and stores them", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
it("accepts the cardiometabolic triple, which is three pillars in one area", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("accepts a single pillar", async () => {
// The narrowest selection that can still be scored, and the boundary
// the one surviving refusal sits against.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
// The counter-case. A write that accepted everything would have
// stopped enforcing anything, and this is the one selection the
// scorer genuinely cannot answer.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: [] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { errorCode?: string; reason?: string };
};
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
expect(env.meta?.reason).toBe("no_pillars_selected");
expect(env.error).toContain("at least one area of health");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
it("accepts a three-area selection", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
});
describe("PATCH — envelope and transport", () => {
it("rejects an unknown pillar id with 422 and writes nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "MOON_PHASE", "SLEEP"] }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unknown body key with 422 (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS, weights: { SLEEP: 2 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a userId in the body", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS, userId: "user-2" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/health-score-config",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
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<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("guards the write on the base token and 409s a stale one", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({
pillars: ALL_PILLARS,
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("health_score_config_conflict");
expect(prisma.user.update).not.toHaveBeenCalled();
// A write that never landed must not evict the caches either.
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
/**
* v1.35.1 — the route shipped in v1.35.0 with a settings surface in front
* of it and no published contract at all: `grep -c health-score-config
* docs/api/openapi.yaml` returned 0. This block is the second end.
*
* It does not compare the spec against a restatement of the spec. It drives
* the REAL route to produce each response, reads the codes and keys off the
* bytes it actually returns, and asserts the published contract names them.
* A response the route can emit but the contract does not describe fails
* here, which is the failure the release itself did not catch.
*/
describe("the published contract matches what the route serves", () => {
const PATH = "/api/auth/me/health-score-config";
function doc() {
return buildOpenApiDocument() as {
paths?: Record<
string,
Record<string, { responses?: Record<string, { description?: string }> }>
>;
components?: {
schemas?: Record<
string,
{ properties?: Record<string, unknown>; required?: string[] }
>;
};
};
}
/** Every `meta.errorCode` the PATCH route can actually produce. */
async function emittedErrorCodes(): Promise<Map<number, Set<string>>> {
const byStatus = new Map<number, Set<string>>();
const record = async (body: unknown, conflict = false) => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({
count: conflict ? 0 : 1,
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch(body),
);
const env = (await res.json()) as { meta?: { errorCode?: string } };
if (env.meta?.errorCode) {
const set = byStatus.get(res.status) ?? new Set<string>();
set.add(env.meta.errorCode);
byStatus.set(res.status, set);
}
};
// Too narrow. One reason since v1.38, because one selection is: the
// empty one.
await record({ pillars: [] });
// Body that fails validation.
await record({ pillars: ["NOT_A_PILLAR"] });
// Present-but-unparseable base token.
await record({ pillars: ALL_PILLARS, baseUpdatedAt: null });
// Stale base token.
await record(
{ pillars: ALL_PILLARS, baseUpdatedAt: "2026-07-24T08:00:00.000Z" },
true,
);
return byStatus;
}
it("registers both verbs", () => {
const path = doc().paths?.[PATH];
expect(
path,
`${PATH} is not registered in the OpenAPI document`,
).toBeDefined();
expect(Object.keys(path ?? {}).sort()).toEqual(["get", "patch"]);
});
it("names every errorCode the route can emit, on the right status", async () => {
const emitted = await emittedErrorCodes();
const responses = doc().paths?.[PATH]?.patch?.responses ?? {};
// Sanity: the driver really did produce the codes, so a contract that
// named none of them could not pass by the loop never running.
expect([...(emitted.get(422) ?? [])].sort()).toEqual([
"health_score_config.invalid",
"health_score_config.too_narrow",
"invalid_base_updated_at",
]);
expect([...(emitted.get(409) ?? [])]).toEqual([
"health_score_config_conflict",
]);
for (const [status, codes] of emitted) {
const described = responses[String(status)]?.description ?? "";
for (const code of codes) {
expect(
described,
`${status} description does not mention \`${code}\``,
).toContain(code);
}
}
});
it("publishes exactly the fields a real response carries", async () => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ALL_PILLARS }),
);
const env = (await res.json()) as { data: ResolvedBody };
expect(res.status).toBe(200);
const schema = doc().components?.schemas?.["HealthScoreConfigResponse"];
expect(schema, "HealthScoreConfigResponse is not emitted").toBeDefined();
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(
Object.keys(env.data).sort(),
);
// `updatedAt` is absent on a never-configured GET, so it must not be
// required — a generated decoder would reject that read.
expect(schema?.required ?? []).not.toContain("updatedAt");
});
it("leaves updatedAt off the read that genuinely has none", async () => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request(`http://localhost${PATH}`),
);
const env = (await res.json()) as { data: ResolvedBody };
const published = Object.keys(
doc().components?.schemas?.["HealthScoreConfigResponse"]?.properties ??
{},
);
expect(Object.keys(env.data)).not.toContain("updatedAt");
for (const key of Object.keys(env.data)) expect(published).toContain(key);
});
});
@@ -1,463 +0,0 @@
/**
* v1.18.0 — PATCH/GET /api/auth/me/modules.
*
* Covers the read projection, the field-by-field merge, core-module
* refusal (strict schema), the cycle/coach delegation reflected in the
* resolved map, and the standard auth / rate-limit / 422 envelope.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
cycleProfile: {
findUnique: 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: () => ({}),
}));
// Operator master flag = on by default; coach delegation keys off it.
// Partial mock: the rest of the module stays real, and both switch readers
// answer from the one the test drives.
vi.mock("@/lib/feature-flags", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/feature-flags")>()),
...(
await import("@/__tests__/helpers/assistant-switches-mock")
).mockAssistantSwitches(vi.fn().mockResolvedValue({ coach: true })),
}));
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/cache/invalidate")>();
return { ...actual, invalidateUserHealthScore: vi.fn() };
});
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";
import { getAssistantFlags } from "@/lib/feature-flags";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
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 },
};
type ModuleMap = Record<string, boolean>;
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/modules", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
/**
* Default DB shape: fresh user, no module prefs, no cycle row. The
* `findUnique` mock reads from a mutable holder so a PATCH `update`
* (which writes `modulePreferencesJson`) is reflected by the post-update
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
*/
function primeUser(
over: {
gender?: string | null;
disableCoach?: boolean;
modulePreferencesJson?: unknown;
cycleTrackingEnabled?: boolean | null;
} = {},
) {
const row: {
gender: string | null;
disableCoach: boolean;
modulePreferencesJson: unknown;
} = {
gender: over.gender ?? null,
disableCoach: over.disableCoach ?? false,
modulePreferencesJson: over.modulePreferencesJson ?? null,
};
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
Promise.resolve({ ...row })) as never);
vi.mocked(prisma.user.update).mockImplementation(((args: {
data?: { modulePreferencesJson?: unknown };
}) => {
const data = args.data;
if (data && "modulePreferencesJson" in data) {
row.modulePreferencesJson = data.modulePreferencesJson;
}
return Promise.resolve({});
}) as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(
over.cycleTrackingEnabled === undefined
? null
: ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never);
});
describe("GET /api/auth/me/modules", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
expect(res.status).toBe(401);
});
it("returns every toggleable module on for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.mood).toBe(true);
expect(env.data.modules.sleep).toBe(true);
expect(env.data.modules.insights).toBe(true);
// cycle gender-derived (null gender ⇒ off); coach delegated (on).
expect(env.data.modules.cycle).toBe(false);
expect(env.data.modules.coach).toBe(true);
});
it("reflects a disabled module from the persisted allowlist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: { glucose: false } });
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.glucose).toBe(false);
expect(env.data.modules.mood).toBe(true);
});
it("reflects coach delegation (operator master flag off)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never);
primeUser();
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/modules"),
);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.coach).toBe(false);
});
});
describe("PATCH /api/auth/me/modules", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("disables a module and persists the merged allowlist", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { modules: ModuleMap } };
expect(env.data.modules.mood).toBe(false);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { mood: false } },
});
expect(auditLog).toHaveBeenCalledWith(
"user.modules.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ changed: ["mood"] }),
}),
);
});
it("merges over the existing allowlist field-by-field (siblings intact)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// user previously disabled glucose; now disabling sleep too.
primeUser({ modulePreferencesJson: { glucose: false } });
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: false, sleep: false } },
});
});
it("can re-enable a previously disabled module", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: { glucose: false } });
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ glucose: true }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { glucose: true } },
});
});
it("REFUSES to disable a core module (strict schema 422)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
// v1.18.1 — medications graduated to a toggleable module, so only
// weight / blood pressure / pulse stay refused as core domains.
for (const core of ["weight", "bloodPressure", "pulse"]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ [core]: false }),
);
expect(res.status).toBe(422);
}
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an unknown module key with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser();
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: "false" }),
);
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/modules", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "modules.body.invalid_json" },
});
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<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("drops a previously-corrupted stored blob to clean booleans on merge", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// junk in the stored row must not leak into the persisted merge.
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ labs: false }),
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: { modulePreferencesJson: { sleep: false, labs: false } },
});
});
});
/**
* v1.35.0 — four of the score's pillars come and go with a module
* toggle, so a toggle changes what the composite is made of. The score
* caches hold the old composition and are served for up to an hour, so
* the person who just turned a module off keeps seeing a number
* computed from it. Nothing about that is visible in a test that does
* not assert the eviction, which is why it went unnoticed for so long.
*/
describe("PATCH /api/auth/me/modules — the score caches", () => {
it("evicts the score caches after a module toggle lands", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false }),
);
expect(res.status).toBe(200);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
it("does not evict them when the write never landed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ sleep: false, baseUpdatedAt: "2026-07-24T08:00:00.000Z" }),
);
expect(res.status).toBe(409);
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 — optimistic concurrency. The base token is stripped BEFORE
* the `.strict()` schema parse, so it never trips strict validation; a stale
* token 409s and writes nothing; a tokenless PATCH keeps the unconditional
* write.
*/
describe("PATCH /api/auth/me/modules — optimistic concurrency", () => {
it("guards on the base token and skips the unconditional write", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, 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("modules_conflict");
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(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("strips the token pre-parse: a token ALONE does not trip the strict schema", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
// 200, not 422 — `baseUpdatedAt` was removed before the strict parse.
expect(res.status).toBe(200);
});
it("still 422s an unknown key even alongside a valid token (strict stays strict)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ notAModule: false, baseUpdatedAt: "2026-07-24T10:00:00.000Z" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser({ modulePreferencesJson: null });
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: false, 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();
});
});
@@ -1,754 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
}));
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/notification-prefs", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the documented defaults for a fresh user (null row)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
it("returns the resolved prefs when the row holds a value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
it("returns defaults when the persisted shape has drifted", async () => {
// Forward-compat: an admin hand-edit / future-rename should not
// crash the GET.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { unknownCategory: { foo: "bar" } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
});
});
});
describe("PATCH /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("flips medication.clientManaged on and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
// v1.32.22 — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.notification-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
next: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
changed: ["medication"],
}),
}),
);
});
// 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/notification-prefs", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({
error: "Invalid JSON body",
meta: { errorCode: "notification-prefs.body.invalid_json" },
});
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean clientManaged with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: "yes" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("deep-merges over existing siblings without overwriting them", async () => {
// Forward-compat: a row that already carries an unknown sibling
// category (e.g. a future "mood" key persisted by a newer client)
// must survive a PATCH that only touches `medication`. The route
// parses with the current zod schema first, then deep-merges the
// input over the parsed (defaulted) base. Today, "unknown sibling"
// resolves back to defaults; the test pins that the medication
// PATCH still lands and that future schema growth (adding new
// siblings to the zod shape) will preserve them.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: false } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("returns the merged shape unchanged on an empty PATCH body", async () => {
// Idempotent — an empty body keeps the row as-is and still writes
// the audit trail (mirrors the disable-coach posture).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(mkPatch({}));
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
// v1.32.22 — the write echoes the fresh optimistic-concurrency token.
updatedAt: expect.any(String),
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: true,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 22 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("v1.7.0 — persists a custom mood.reminderHour", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { mood: { reminderHour: number } };
};
expect(env.data.mood).toEqual({ reminderHour: 9 });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: {
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 7,
reorderLeadDays: 10,
},
mood: { reminderHour: 9 },
cycle: { clientManaged: false },
coach: {
nudgesEnabled: true,
nudgeMedication: true,
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
nudgeAiComposed: false,
},
measurementReminder: {
clientManaged: false,
},
content: { detail: "generic" },
},
},
});
});
it("persists content.detail and keeps the siblings", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { mood: { reminderHour: 7 } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ content: { detail: "detailed" } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { content: { detail: string }; mood: { reminderHour: number } };
};
expect(env.data.content).toEqual({ detail: "detailed" });
expect(env.data.mood).toEqual({ reminderHour: 7 });
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: { notificationPrefs: { content: { detail: string } } };
};
expect(written.data.notificationPrefs.content).toEqual({
detail: "detailed",
});
});
it("rejects an unknown content.detail with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ content: { detail: "everything" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("v1.7.0 — rejects a mood.reminderHour outside 0..23 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 24 } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("v1.16.11 — round-trips a custom lowStockRunwayDays", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: 14 } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { lowStockRunwayDays: number | null } };
};
expect(env.data.medication.lowStockRunwayDays).toBe(14);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
select: { updatedAt: true },
data: {
notificationPrefs: expect.objectContaining({
medication: {
clientManaged: false,
deliveryDefault: "server",
lowStockRunwayDays: 14,
reorderLeadDays: 10,
},
}),
},
});
});
it("v1.16.11 — persists the explicit null (alert off)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 14 } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: null } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { lowStockRunwayDays: number | null } };
};
expect(env.data.medication.lowStockRunwayDays).toBe(null);
});
it("v1.16.11 — rejects a lowStockRunwayDays outside 1..60 with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
for (const value of [0, 61]) {
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { lowStockRunwayDays: value } }),
);
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<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
/**
* v1.32.22 — optimistic concurrency (issue #581 family). A PATCH carrying
* the `baseUpdatedAt` it was based on writes CONDITIONALLY on the stored row
* still carrying that token. The headline case: a concurrent web PATCH of a
* DIFFERENT category can no longer silently revert the iOS `clientManaged`
* flag (the double-reminder class). A tokenless PATCH keeps the prior
* unconditional write — the iOS-compat arm.
*/
describe("PATCH /api/auth/me/notification-prefs — optimistic concurrency", () => {
function mkTokenedPatch(body: unknown, baseUpdatedAt?: string): Request {
return mkPatch(
baseUpdatedAt !== undefined
? { ...(body as object), baseUpdatedAt }
: body,
);
}
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ notificationPrefs: null } as never)
// post-write fresh-token read
.mockResolvedValueOnce({
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch(
{ medication: { clientManaged: true } },
"2026-07-24T10:00:00.000Z",
),
);
expect(res.status).toBe(200);
// Conditional write, no unconditional fallback.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect(whereArg.id).toBe("user-1");
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
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("regression: a stale-token web write does NOT revert the iOS clientManaged flag", async () => {
// iOS set medication.clientManaged = true (row now carries it). A web card
// PATCH based on a pre-iOS read of a DIFFERENT category arrives with a stale
// token: it must 409 and write NOTHING, so clientManaged survives.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ mood: { reminderHour: 9 } }, "2026-07-24T08:00:00.000Z"),
);
expect(res.status).toBe(409);
const env = (await res.json()) as {
data: null;
meta?: { errorCode?: string };
};
expect(env.data).toBeNull();
expect(env.meta?.errorCode).toBe("notification_prefs_conflict");
// The guarded write matched no row; nothing was written, so the persisted
// clientManaged flag is untouched.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("compat: the SAME second write TOKENLESS still lands (old clients keep last-write-wins)", async () => {
// Deliberate: a tokenless client keeps the pre-guard race. This pins that
// the clobber for old clients is a documented choice, not an accident.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ mood: { reminderHour: 9 } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkTokenedPatch({ medication: { clientManaged: true } }, "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.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -1,68 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findMany: vi.fn(), create: 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 { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
};
const req = (qs = "") =>
new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`);
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
describe("GET /api/auth/me/security-activity", () => {
it("reads one row past the requested page, in a total order", async () => {
const res = await GET(req("?limit=10"));
expect(res.status).toBe(200);
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as {
take: number;
orderBy: unknown;
};
expect(call.take).toBe(11);
expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]);
const body = (await res.json()) as {
meta: { truncated: boolean; limit: number };
};
expect(body.meta).toMatchObject({ truncated: false, limit: 10 });
});
it("answers a limit past the published ceiling with a 422 naming it", async () => {
const res = await GET(req("?limit=500"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
meta: { errorCode: string };
details: { issues: Array<{ path: string }> };
};
expect(body.meta.errorCode).toBe("security_activity.invalid");
expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]);
expect(prisma.auditLog.findMany).not.toHaveBeenCalled();
});
});
+9 -3
View File
@@ -11,7 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context"; import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit"; import { auditLog } from "@/lib/auth/audit";
import { destroySessionById } from "@/lib/auth/session"; import { destroySessionById, sessionHandle } from "@/lib/auth/session";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,15 +25,21 @@ export const DELETE = apiHandler(
return apiError("Session not found", 404); return apiError("Session not found", 404);
} }
// The path carries the public handle, never the row id, so "is this the
// caller's own session" compares handles. A Bearer caller's `session.id`
// is a token id, whose handle matches no session, which is the right
// answer for it too.
const self = id === sessionHandle(session.id);
await auditLog("auth.session.revoke", { await auditLog("auth.session.revoke", {
userId: user.id, userId: user.id,
ipAddress: getClientIp(request), ipAddress: getClientIp(request),
details: { self: id === session.id }, details: { self },
}); });
annotate({ annotate({
action: { name: "auth.session.revoke" }, action: { name: "auth.session.revoke" },
meta: { self: id === session.id }, meta: { self },
}); });
return apiSuccess({ revoked: true }); return apiSuccess({ revoked: true });
@@ -1,296 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The handle is an HMAC, so the route needs the key the real helper reads.
process.env.API_TOKEN_HMAC_KEY ??= "a".repeat(64);
vi.mock("@/lib/db", () => ({
prisma: {
session: { findMany: vi.fn() },
apiToken: {
findUnique: vi.fn(),
update: vi.fn(() => ({ catch: () => undefined })),
},
user: { findUnique: vi.fn() },
accountGrant: { findMany: vi.fn(async () => []) },
},
}));
// `sessionHandle` stays REAL: the point of the handle test is that the route
// emits the actual derivation, and a stubbed one would let the route return
// row ids while the assertion still passed.
vi.mock("@/lib/auth/session", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/auth/session")>();
return {
getSession: vi.fn(),
destroyOtherSessions: vi.fn(),
destroySessionById: vi.fn(),
sessionHandle: actual.sessionHandle,
};
});
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
}));
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 { GET, DELETE } from "../route";
import { headers } from "next/headers";
import { hashToken } from "@/lib/auth/hmac";
import { DELETE as DELETE_ONE } from "../[id]/route";
import { prisma } from "@/lib/db";
import {
getSession,
destroyOtherSessions,
destroySessionById,
sessionHandle,
} from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) },
user: { id: "user-1", username: "u", role: "USER" as const },
};
function del(): NextRequest {
return new NextRequest("http://localhost/api/auth/me/sessions", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/auth/me/sessions", () => {
it("401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(401);
});
it("lists sessions with masked IP, location, and the current marker", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.session.findMany).mockResolvedValue([
{
id: "sess-current",
ipAddress: "203.0.113.7",
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
lastActiveAt: new Date(),
createdAt: new Date(),
},
{
id: "sess-other",
ipAddress: "198.51.100.4",
userAgent:
"Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36",
lastActiveAt: null,
createdAt: new Date(),
},
] as never);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
const list = body.data.sessions;
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
device: "Firefox on macOS",
ipMasked: "203.0.x.x",
location: "Berlin, DE",
isCurrent: true,
});
expect(list[1].isCurrent).toBe(false);
// The full IP never appears in the response body.
expect(JSON.stringify(body)).not.toContain("203.0.113.7");
});
it("never puts a session row id in the response body", async () => {
// A row id created before the secret cookie landed IS that session's
// cookie value, so listing it hands the client working logins for the
// account's other devices. The list carries an opaque handle instead;
// it is what the revoke route accepts.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.session.findMany).mockResolvedValue([
{
id: "sess-current",
ipAddress: "203.0.113.7",
userAgent: "Mozilla/5.0 (Macintosh) Firefox/120.0",
lastActiveAt: new Date(),
createdAt: new Date(),
},
{
id: "sess-other",
ipAddress: "198.51.100.4",
userAgent: "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0",
lastActiveAt: null,
createdAt: new Date(),
},
] as never);
const res = await GET();
const body = await res.json();
const serialised = JSON.stringify(body);
expect(serialised).not.toContain("sess-current");
expect(serialised).not.toContain("sess-other");
// Still identified well enough to act on: distinct, stable handles.
const [a, b] = body.data.sessions.map((s: { id: string }) => s.id);
expect(a).toBeTruthy();
expect(a).not.toBe(b);
expect(a).toBe(sessionHandle("sess-current"));
});
});
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
it("revokes other sessions and reports the count", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 3,
accessTokensRevoked: 0,
connectorsRevoked: 0,
shareLinksRevoked: 0,
pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.sessionsRevoked).toBe(3);
expect(destroyOtherSessions).toHaveBeenCalledWith(
"user-1",
{ kind: "session", sessionId: "sess-current" },
// Everything, share links included, when the client says nothing: the
// shipped app sends no parameter and gets the safe default.
{ reach: "everything", keepShareLinks: false },
);
});
it("names a Bearer caller by its access token so its own device login is spared", async () => {
// No cookie session: the caller is a phone presenting its login token.
vi.mocked(getSession).mockResolvedValue(null);
const raw = `hlk_${"d".repeat(64)}`;
vi.mocked(headers).mockResolvedValue({
get: (name: string) =>
name.toLowerCase() === "authorization" ? `Bearer ${raw}` : null,
} as never);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-phone",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user-1",
username: "u",
role: "USER",
} as never);
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 1,
accessTokensRevoked: 1,
connectorsRevoked: 0,
shareLinksRevoked: 0,
pendingInvitesRevoked: 0,
});
const res = await DELETE(del());
expect(res.status).toBe(200);
expect(destroyOtherSessions).toHaveBeenCalledWith(
"user-1",
{ kind: "accessToken", accessTokenHash: hashToken(raw) },
{ reach: "everything", keepShareLinks: false },
);
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
});
});
describe("sign out everywhere and record sharing", () => {
it("reports withdrawn invitations and lists the accepted grants it kept", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroyOtherSessions).mockResolvedValue({
sessionsRevoked: 1,
accessTokensRevoked: 0,
connectorsRevoked: 0,
shareLinksRevoked: 0,
pendingInvitesRevoked: 2,
});
const party = { id: "user-2", username: "carer", displayName: null };
const live = {
id: "grant-live",
access: "READ",
acceptedAt: new Date("2026-01-01"),
revokedAt: null,
expiresAt: null,
grantee: party,
};
const lapsed = {
...live,
id: "grant-lapsed",
expiresAt: new Date("2026-02-01"),
};
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
live,
lapsed,
] as never);
const res = await DELETE(del());
const body = await res.json();
expect(body.data.pendingInvitesRevoked).toBe(2);
// The lapsed one confers nothing any more and is not listed.
expect(body.data.grantsKept).toEqual([
{ id: "grant-live", account: party, access: "READ" },
]);
expect(prisma.accountGrant.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
grantorId: "user-1",
revokedAt: null,
}),
}),
);
});
});
describe("DELETE /api/auth/me/sessions/[id]", () => {
function ctx(id: string) {
return { params: Promise.resolve({ id }) };
}
function delReq(id: string): NextRequest {
return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, {
method: "DELETE",
});
}
it("revokes a single owned session", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroySessionById).mockResolvedValue(true);
const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other"));
expect(res.status).toBe(200);
expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other");
});
it("404 for a non-existent or not-owned id", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(destroySessionById).mockResolvedValue(false);
const res = await DELETE_ONE(delReq("nope"), ctx("nope"));
expect(res.status).toBe(404);
});
});
@@ -1,153 +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/integrations/sources-in-use", () => ({
getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]),
}));
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 { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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/source-priority", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(401);
});
it("returns the resolved shape with defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as { data: Record<string, unknown> };
// Resolver always emits the keys the analytics layer needs.
expect(env.data).toHaveProperty("weight");
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
it("carries the account's sources in use beside the ladders", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as { data: { inUse: string[] } };
expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]);
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("persists the partial shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { sourcePriorityJson: body },
});
});
it("rejects an invalid shape with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: "not-an-array" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── v1.4.25 W10 reconcile (security M-3) ──
it("writes an audit-log entry with the previous and new shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const previousShape = {
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: previousShape,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.source-priority.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: previousShape,
next: body,
}),
}),
);
});
});
@@ -1,146 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: 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),
}));
// The supplement nutrient rows are keyed by the same local day.
const nutrientMock = vi.hoisted(() => vi.fn(async () => ({ queued: true })));
vi.mock("@/lib/medications/ingredients/recompute-job", () => ({
enqueueSupplementNutrientRecompute: nutrientMock,
}));
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";
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 mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/me/timezone", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/auth/me/timezone", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(401);
});
it("accepts a valid IANA zone and writes it to the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Pacific/Auckland",
} as never);
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Pacific/Auckland" },
});
});
it("rejects an invalid timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "" }));
expect(res.status).toBe(422);
});
it("rejects a missing timezone field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({}));
expect(res.status).toBe(422);
});
// The supplement nutrient rows' day keys were minted under the old zone;
// without the enqueue nothing re-buckets them.
it("enqueues the nutrient recompute when the zone actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await PUT(mkReq({ timezone: "America/New_York" }));
expect(res.status).toBe(200);
expect(nutrientMock).toHaveBeenCalledWith("user-1");
});
it("does not recompute when the zone is unchanged", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(200);
expect(nutrientMock).not.toHaveBeenCalled();
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Asia/Tokyo",
} as never);
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Asia/Tokyo" },
});
});
});
@@ -1,234 +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/insights/unit-change-refresh", () => ({
refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined),
}));
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 { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh";
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/unit-preference", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/unit-preference", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
expect(res.status).toBe(401);
});
it("defaults to metric for a fresh user (null column)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { unitPreference: string };
};
expect(env.data.unitPreference).toBe("metric");
});
it("defaults to metric when the column is missing (partial rollback)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("metric");
});
it("returns imperial when set", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/unit-preference"),
);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("imperial");
});
});
describe("PATCH /api/auth/me/unit-preference", () => {
it("sets imperial and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: { unitPreference: string } };
expect(env.data.unitPreference).toBe("imperial");
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { unitPreference: "imperial" },
});
expect(auditLog).toHaveBeenCalledWith(
"user.unit-preference.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: "metric",
next: "imperial",
}),
}),
);
});
it("rejects an unknown preference value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "furlongs" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// The three cases below pin the capped `safeJson` read. Before it, the
// handler called `request.json()` bare: malformed JSON came back 422 rather
// than the 400 every sibling scalar answers, a wrong content type was parsed
// rather than refused, and there was NO body-size limit — this was the one
// endpoint in the family that would materialise whatever it was handed. The
// malformed-JSON assertion used to say 422 and was changed deliberately: it
// pinned the inconsistency rather than a contract, and the only caller
// branches on `res.ok` alone.
it("rejects malformed JSON with 400, like its sibling scalars", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(400);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a body over the 1 KB cap with 413", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Valid JSON, valid enum value, and far too big: the cap has to be what
// rejects it, not the schema.
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: JSON.stringify({
unitPreference: "imperial",
padding: "x".repeat(2048),
}),
headers: { "Content-Type": "application/json" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(413);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses a non-JSON content type with 415", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request("http://localhost/api/auth/me/unit-preference", {
method: "PATCH",
body: "unitPreference=imperial",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(415);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("PATCH re-warms the texts that quote the unit", () => {
it("re-warms the stored notes when the unit actually changes", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1");
});
it("leaves them alone when the same unit is saved again", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
unitPreference: "imperial",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ unitPreference: "imperial" }),
);
expect(res.status).toBe(200);
expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled();
});
});
@@ -1,103 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The workout-route opt-in: off by default, a hard-set PATCH, audit-logged,
* and a malformed body refused before anything is written.
*/
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 { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
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/workout-routes", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
describe("/api/auth/me/workout-routes", () => {
it("reads off when the row carries no value", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: false },
});
});
it("sets the flag field by field and audits the change", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
storeWorkoutRoutes: false,
} as never);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: true },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { storeWorkoutRoutes: true },
});
expect(auditLog).toHaveBeenCalledWith(
"user.storeWorkoutRoutes.update",
expect.objectContaining({
details: { previous: false, next: true },
}),
);
});
it("refuses a malformed body with 422 and writes nothing", async () => {
const res = await PATCH(mkPatch({ storeWorkoutRoutes: "yes" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses an unauthenticated caller", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -1,389 +0,0 @@
/**
* The CapabilityStatement must describe a face that exists, and
* `Patient/$everything` must return the whole record.
*
* A statement is a promise a generic FHIR client acts on: it will issue a
* `read` because the statement said `read` is there. So the advertised set is
* checked against the route tree on disk, not against a second list. And the
* whole-record operation is checked against the document export it mirrors —
* "everything" that quietly omits a resource family is the kind of gap only a
* clinician notices, when the family is the one they needed.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { existsSync } from "node:fs";
import path from "node:path";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
// The password hash is read off the stored row; none here.
user: { findUnique: vi.fn(async () => null) },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(),
isModuleEnabled: vi.fn(),
}));
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as metadataGet } from "../metadata/route";
import { GET as everythingGet } from "../Patient/$everything/route";
import { prisma } from "@/lib/db";
import { GET as observationRead } from "../Observation/[id]/route";
import { GET as patientRead } from "../Patient/[id]/route";
import { GET as coverageRead } from "../Coverage/[id]/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadFhirContext, FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest";
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle";
import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
import type { DoctorReportData } from "@/lib/doctor-report-data";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
const DATA = {
period: {
days: 90,
since: "2026-02-02T00:00:00.000Z",
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T12:00:00.000Z",
},
patient: {
username: "sample-user",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
fullName: "Sample Patient",
insurerName: "Example Insurer",
insurerIkNumber: "101234567",
},
practiceName: null,
measurements: {
WEIGHT: [{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }],
},
stats: {},
glucoseStats: {},
glucoseRanges: {},
glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }),
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {},
medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }],
medicationAdministrations: [
{
medicationName: "Example Drug",
effectiveAt: "2026-04-30T08:00:00.000Z",
status: "completed",
doseText: "5mg",
dose: { value: 5, unit: "mg" },
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "ORAL",
},
],
illnessEpisodes: [
{
label: "Erkältung",
type: "INFECTION",
lifecycle: "ACUTE",
onsetAt: "2026-04-01T00:00:00.000Z",
resolvedAt: "2026-04-10T00:00:00.000Z",
},
],
cycle: {
lastPeriodStart: "2026-04-20",
recentCycles: [],
observedCycleCount: 3,
averageCycleLengthDays: 27.5,
cycleLengthVariabilityDays: 0.5,
averagePeriodLengthDays: 5,
currentPhase: "LUTEAL",
},
mood: { avg: 3.8, count: 20 },
glp1: null,
} as unknown as DoctorReportData;
const RECORDS = {
allergies: [
{
id: "a1",
substance: "Penicillin",
category: "MEDICATION",
type: "ALLERGY",
status: "ACTIVE",
severity: "SEVERE",
reaction: "Rash",
onsetAt: null,
note: null,
},
],
familyHistory: [
{
id: "f1",
relationship: "MOTHER",
condition: "Hypertension",
ageAtOnset: 50,
note: null,
},
],
} as never;
function req(pathname: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(loadFhirContext).mockResolvedValue({
data: DATA,
identity: { insuranceNumber: "A123456780" },
germanAtc: false,
records: RECORDS,
});
});
interface CapabilityStatement {
resourceType: string;
implementation?: { description: string; url: string };
software?: { name: string; version: string };
description?: string;
rest: Array<{
resource: Array<{
type: string;
interaction?: Array<{ code: string }>;
operation?: Array<{ name: string }>;
}>;
}>;
}
describe("GET /api/fhir/metadata — CapabilityStatement", () => {
it("describes the instance it speaks for (cpb-2)", async () => {
const res = await metadataGet(req("/api/fhir/metadata"));
const body = (await res.json()) as CapabilityStatement;
expect(body.resourceType).toBe("CapabilityStatement");
expect(body.description?.length).toBeGreaterThan(0);
expect(body.software?.name).toBe("HealthLog");
expect(body.software?.version.length).toBeGreaterThan(0);
expect(body.implementation?.description.length).toBeGreaterThan(0);
expect(body.implementation?.url).toBe("http://localhost/api/fhir");
});
it("describes Patient exactly once, carrying the operation (cpb-9)", async () => {
const res = await metadataGet(req("/api/fhir/metadata"));
const body = (await res.json()) as CapabilityStatement;
const resources = body.rest[0].resource;
const types = resources.map((r) => r.type);
expect(types.filter((t) => t === "Patient")).toHaveLength(1);
expect(new Set(types).size).toBe(types.length);
const patient = resources.find((r) => r.type === "Patient");
expect(patient?.operation?.[0].name).toBe("everything");
expect(patient?.interaction?.map((i) => i.code)).toEqual([
"read",
"search-type",
]);
});
it("advertises no interaction without a route behind it", async () => {
const res = await metadataGet(req("/api/fhir/metadata"));
const body = (await res.json()) as CapabilityStatement;
const root = path.join(process.cwd(), "src/app/api/fhir");
for (const resource of body.rest[0].resource) {
for (const { code } of resource.interaction ?? []) {
const file =
code === "read"
? path.join(root, resource.type, "[id]", "route.ts")
: path.join(root, resource.type, "route.ts");
expect(existsSync(file), `${resource.type} ${code} → ${file}`).toBe(
true,
);
}
for (const { name } of resource.operation ?? []) {
const file = path.join(root, resource.type, `$${name}`, "route.ts");
expect(existsSync(file), `${resource.type} $${name} → ${file}`).toBe(
true,
);
}
}
// …and the catalogue the statement derives from is the routed set.
expect(body.rest[0].resource.map((r) => r.type)).toEqual([
...FHIR_REST_RESOURCE_TYPES,
]);
});
it("advertises no write interaction", async () => {
const res = await metadataGet(req("/api/fhir/metadata"));
const body = (await res.json()) as CapabilityStatement;
const codes = body.rest[0].resource.flatMap((r) =>
(r.interaction ?? []).map((i) => i.code),
);
for (const code of codes) {
expect(["read", "search-type"]).toContain(code);
}
});
});
describe("GET /api/fhir/{type}/{id} — read", () => {
it("serves the resource the searchset fullUrl points at", async () => {
const res = await observationRead(req("/api/fhir/Observation/obs-1"), {
params: Promise.resolve({ id: "obs-1" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/fhir+json");
const body = (await res.json()) as { resourceType: string; id: string };
expect(body.resourceType).toBe("Observation");
expect(body.id).toBe("obs-1");
});
it("answers an id the record does not hold with a not-found OperationOutcome", async () => {
const res = await observationRead(req("/api/fhir/Observation/obs-999"), {
params: Promise.resolve({ id: "obs-999" }),
});
expect(res.status).toBe(404);
const body = (await res.json()) as {
resourceType: string;
issue: Array<{ code: string }>;
};
expect(body.resourceType).toBe("OperationOutcome");
expect(body.issue[0].code).toBe("not-found");
});
it("reads the Patient", async () => {
const res = await patientRead(req("/api/fhir/Patient/patient-1"), {
params: Promise.resolve({ id: "patient-1" }),
});
const body = (await res.json()) as { resourceType: string };
expect(body.resourceType).toBe("Patient");
});
it("reads the Coverage", async () => {
const res = await coverageRead(req("/api/fhir/Coverage/coverage-1"), {
params: Promise.resolve({ id: "coverage-1" }),
});
const body = (await res.json()) as { resourceType: string };
expect(body.resourceType).toBe("Coverage");
});
});
describe("GET /api/fhir/Patient/$everything", () => {
it("asks a browser session that signed in an hour ago to confirm first", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await everythingGet(req("/api/fhir/Patient/$everything"));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
});
it("returns every resource family the document bundle carries", async () => {
const res = await everythingGet(
req("/api/fhir/Patient/$everything?_count=200"),
);
expect(res.status).toBe(200);
const bundle = (await res.json()) as {
type: string;
total: number;
entry: Array<{ fullUrl: string; resource: { resourceType: string } }>;
};
expect(bundle.type).toBe("searchset");
const document = buildFhirDocumentBundle(
DATA,
{ insuranceNumber: "A123456780" },
FIXED_NOW,
{ germanAtc: false },
RECORDS,
);
const expected = new Set(
document.entry.map((e) => e.resource.resourceType),
);
const served = new Set(bundle.entry.map((e) => e.resource.resourceType));
expect(served).toEqual(expected);
expect(bundle.total).toBe(document.entry.length);
// The families the operation used to drop.
for (const type of [
"Composition",
"Device",
"Condition",
"Encounter",
"AllergyIntolerance",
"FamilyMemberHistory",
"DiagnosticReport",
"Coverage",
]) {
expect(served.has(type), `missing ${type}`).toBe(true);
}
// …including the cycle Observations, which share a resourceType with the
// vitals and would hide inside the set check above.
const ids = bundle.entry.map(
(e) => (e.resource as unknown as { id: string }).id,
);
expect(ids.some((id) => id.startsWith("obs-cycle-"))).toBe(true);
});
it("keeps the entry identities the references resolve against", async () => {
const res = await everythingGet(
req("/api/fhir/Patient/$everything?_count=200"),
);
const bundle = (await res.json()) as {
entry: Array<{
fullUrl: string;
search: { mode: string };
resource: { subject?: { reference?: string } };
}>;
};
const urls = new Set(bundle.entry.map((e) => e.fullUrl));
for (const entry of bundle.entry) {
expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true);
expect(entry.search.mode).toBe("match");
const subject = entry.resource.subject?.reference;
if (subject) expect(urls.has(subject)).toBe(true);
}
});
});
@@ -1,264 +0,0 @@
/**
* `/api/fhir/*` — doctorReport module gate.
*
* The FHIR REST face serves the same whole-record aggregate as
* `/api/export/health-record`, including the decrypted insurance number on
* the Patient resource. The export gates on the `doctorReport` module; the
* FHIR routes did not, so the module could be off and `$everything` still
* returned the full Bundle to the same token.
*
* REFUSE, not omit — a whole-record export has no truthful partial answer.
* Every data route 403s with the shared `module.disabled` envelope; only the
* static CapabilityStatement at `/api/fhir/metadata` stays open (server
* metadata, no user data).
*
* Behavioural: the assertions read status codes and check that no aggregate
* was ever loaded, so removing a gate turns them red.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// The module gate itself is NOT mocked — the whole point is to exercise the
// real resolver and the real 403 envelope. Only its data sources are stubbed,
// so the module state is driven the same way production drives it: through
// the persisted `modulePreferencesJson` allowlist.
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/modules/operator-availability")
>();
return { ...actual, getOperatorModuleAvailability: vi.fn() };
});
vi.mock("@/lib/fhir/rest", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/fhir/rest")>();
return { ...actual, loadFhirContext: vi.fn() };
});
vi.mock("@/lib/fhir/resources", () => ({
GERMAN_ATC_DEFAULT_LOCALES: ["de"],
PATIENT_RESOURCE_ID: "patient-1",
patientResource: vi.fn(() => ({ resourceType: "Patient", id: "patient-1" })),
coverageResource: vi.fn(() => null),
observationsFromReportData: vi.fn(() => []),
cycleObservationsFromReportData: vi.fn(() => []),
anamnesisObservationsFromReportData: vi.fn(() => []),
anamnesisNarrativeFromReportData: vi.fn(() => null),
conditionsFromReportData: vi.fn(() => ({ conditions: [], encounters: [] })),
medicationStatementsFromReportData: vi.fn(() => []),
medicationAdministrationsFromReportData: vi.fn(() => []),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET as patientGet } from "../Patient/route";
import { GET as coverageGet } from "../Coverage/route";
import { GET as patientRead } from "../Patient/[id]/route";
import { GET as coverageRead } from "../Coverage/[id]/route";
import { GET as observationRead } from "../Observation/[id]/route";
import { GET as medStatementRead } from "../MedicationStatement/[id]/route";
import { GET as medAdminRead } from "../MedicationAdministration/[id]/route";
import { GET as observationGet } from "../Observation/route";
import { GET as medStatementGet } from "../MedicationStatement/route";
import { GET as medAdminGet } from "../MedicationAdministration/route";
import { GET as everythingGet } from "../Patient/$everything/route";
import { GET as metadataGet } from "../metadata/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate";
import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability";
import { loadFhirContext } from "@/lib/fhir/rest";
import { prisma } from "@/lib/db";
/**
* Drive the REAL gate the way production does — through the persisted
* `modulePreferencesJson` disabled-allowlist, where only a literal `false`
* turns a module off.
*/
function setDoctorReportModule(enabled: boolean): void {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
gender: null,
disableCoach: false,
modulePreferencesJson: enabled ? {} : { doctorReport: false },
} as never);
vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never);
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
/**
* Every data route under `/api/fhir`, by the path a caller would hit. The
* `/{type}/{id}` reads are here too: a route that 403s on search and serves on
* read would leak the same record through the other door.
*/
const DATA_ROUTES: ReadonlyArray<
[string, (req: NextRequest) => Promise<Response>]
> = [
["Patient", patientGet],
["Coverage", coverageGet],
["Observation", observationGet],
["MedicationStatement", medStatementGet],
["MedicationAdministration", medAdminGet],
["Patient/$everything", everythingGet],
];
/**
* The `/{type}/{id}` reads. Same gate, different success shape (a bare
* resource, not a Bundle) — a route that 403s on search and serves on read
* would leak the same record through the other door.
*/
function idCtx(id: string) {
return { params: Promise.resolve({ id }) };
}
const READ_ROUTES: ReadonlyArray<
[string, (req: NextRequest) => Promise<Response>]
> = [
["Patient/patient-1", (req) => patientRead(req, idCtx("patient-1"))],
["Coverage/coverage-1", (req) => coverageRead(req, idCtx("coverage-1"))],
["Observation/obs-1", (req) => observationRead(req, idCtx("obs-1"))],
["MedicationStatement/med-1", (req) => medStatementRead(req, idCtx("med-1"))],
[
"MedicationAdministration/medadmin-1",
(req) => medAdminRead(req, idCtx("medadmin-1")),
],
];
function req(path: string): NextRequest {
return new NextRequest(`http://localhost/api/fhir/${path}`);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
count: 1,
resetAt: Date.now(),
} as never);
// Operator layer available for every module; the per-user layer is what
// each test drives.
vi.mocked(getOperatorModuleAvailability).mockResolvedValue(
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never,
);
vi.mocked(loadFhirContext).mockResolvedValue({
data: {
period: {
days: 90,
since: "2026-02-02T00:00:00.000Z",
start: "2026-02-02T00:00:00.000Z",
end: "2026-05-03T00:00:00.000Z",
},
patient: {},
} as never,
identity: { insuranceNumber: "A123456789" },
germanAtc: false,
records: {},
});
});
describe("/api/fhir/* — doctorReport module gate", () => {
describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => {
it("403s with module.disabled when the module is off", async () => {
setDoctorReportModule(false);
const res = await handler(req(path));
expect(res.status).toBe(403);
const body = (await res.json()) as {
data: unknown;
error: string;
meta?: { errorCode?: string; module?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
expect(body.meta?.module).toBe("doctorReport");
// Refused before any record was assembled — the insurance number
// never left the store.
expect(loadFhirContext).not.toHaveBeenCalled();
expect(JSON.stringify(body)).not.toContain("A123456789");
});
it("serves the Bundle when the module is on", async () => {
setDoctorReportModule(true);
const res = await handler(req(path));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain(
"application/fhir+json",
);
const bundle = (await res.json()) as { resourceType: string };
expect(bundle.resourceType).toBe("Bundle");
expect(loadFhirContext).toHaveBeenCalledWith("user-1");
});
});
describe.each(READ_ROUTES)("GET /api/fhir/%s", (path, handler) => {
it("403s with module.disabled when the module is off", async () => {
setDoctorReportModule(false);
const res = await handler(req(path));
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { errorCode?: string; module?: string };
};
expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE);
expect(body.meta?.module).toBe("doctorReport");
expect(loadFhirContext).not.toHaveBeenCalled();
expect(JSON.stringify(body)).not.toContain("A123456789");
});
it("reaches the record when the module is on", async () => {
setDoctorReportModule(true);
const res = await handler(req(path));
// The emitters are stubbed empty here, so the honest answer is a
// `not-found` OperationOutcome — what matters is that the gate did not
// refuse and the aggregate was assembled.
expect(res.status).not.toBe(403);
expect(loadFhirContext).toHaveBeenCalledWith("user-1");
});
});
it("leaves the static CapabilityStatement reachable while the module is off", async () => {
// Server metadata, no user data — the one FHIR route that stays open.
setDoctorReportModule(false);
const res = await metadataGet(req("metadata"));
expect(res.status).toBe(200);
const body = (await res.json()) as { resourceType: string };
expect(body.resourceType).toBe("CapabilityStatement");
});
});
@@ -1,202 +0,0 @@
/**
* v1.4.25 — cadence + compliance API route tests.
*
* Mirrors the W19d side-effect test fixture pattern: external
* dependencies are mocked at module boundaries (Prisma, session,
* logging transport), and each test pins one contract case.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: 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 },
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/cadence", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 for an out-of-range days parameter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=9999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns the timeline + chips for an owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sch-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence?days=14"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(14);
expect(Array.isArray(json.data.timeline)).toBe(true);
expect(json.data.chips).toMatchObject({
adherenceRate: expect.anything(),
currentStreak: expect.any(Number),
longestStreak: expect.any(Number),
missedLast30: expect.any(Number),
});
});
it("defaults to a 30-day window when no `days` query param is supplied", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(30);
});
it("returns a `null` next-dose when no schedule expands in the lookahead", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.next).toBeNull();
});
});
describe("GET /api/medications/[id]/cadence — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
} as never);
// Schema accepts `days` (coerce.number, range 1-365). Force two
// failures: non-numeric `days` + extra unknown knob is silently
// stripped, so we use the bounds-violation route: a negative number
// string still coerces to a negative int → min violation.
// For ≥2 we use a non-coercible string AND query for too-many keys.
// Cadence schema is small, so we pin the contract on ≥ 2 issues.
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=notanumber",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(1);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
});
@@ -1,299 +0,0 @@
/**
* v1.5.5 — POST /api/medications/[id]/intake/bulk-delete tests.
*
* Pins the contract the detail-page intake-history preview's
* multi-select Löschen depends on:
*
* - Asserts ownership through the shared `assertMedicationOwnership`
* helper. Negative branch returns the helper's 404 response.
* - Reads rows scoped by `(eventId IN, userId, medicationId)` so a
* leaked id from another medication never deletes anything.
* - Recomputes the rollup once per unique dayKey (not once per
* event), so a 14-row delete spanning two days closes in two
* SQL recompute trips.
* - 422 on malformed body via `bulkDeleteIntakeEventsSchema`.
* - 200 with `{ deleted: number }` carrying the real `deleteMany`
* count so the client toast announces the truth.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
medication: { findUnique: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
// v1.15.18 — bulk delete is now a soft-delete updateMany.
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
}));
vi.mock("@/lib/medications/dose-day-refresh", () => ({
dayKeyForScheduledFor: vi.fn((d: Date) => {
const iso = d.toISOString();
return iso.slice(0, 10);
}),
refreshDoseDay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: 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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// (#22) — silent cross-device intake sync. Mocked so the route test can
// assert the hook without reaching the APNs senders or the coalescing
// timers.
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
import { POST } from "../route";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import {
dayKeyForScheduledFor,
refreshDoseDay,
} from "@/lib/medications/dose-day-refresh";
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 ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function postWithBody(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/medications/med-1/intake/bulk-delete",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("POST /api/medications/[id]/intake/bulk-delete", () => {
it("422 on missing body", async () => {
const res = await POST(postWithBody({}), ROUTE_PARAMS);
expect(res.status).toBe(422);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("422 on empty eventIds", async () => {
const res = await POST(postWithBody({ eventIds: [] }), ROUTE_PARAMS);
expect(res.status).toBe(422);
});
it("returns the 404 from the shared ownership helper", async () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
const res = await POST(postWithBody({ eventIds: ["evt-1"] }), ROUTE_PARAMS);
expect(res.status).toBe(404);
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
});
it("returns deleted:0 + skips recompute when no matching rows are found", async () => {
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce(
[] as never,
);
const res = await POST(
postWithBody({ eventIds: ["evt-leaked", "evt-other"] }),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ deleted: 0 });
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(refreshDoseDay).not.toHaveBeenCalled();
// (#22) — a no-op delete queues no cross-device sync fan-out.
expect(queueMedicationIntakeSync).not.toHaveBeenCalled();
});
it("recomputes the rollup once per unique dayKey", async () => {
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
const day2 = new Date(Date.UTC(2026, 4, 28, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "evt-a", scheduledFor: day1 },
{ id: "evt-b", scheduledFor: day1 },
{ id: "evt-c", scheduledFor: day2 },
] as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 3,
} as never);
const res = await POST(
postWithBody({ eventIds: ["evt-a", "evt-b", "evt-c"] }),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ deleted: 3 });
expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3);
// Three rows, two unique dayKeys → two recompute calls.
expect(refreshDoseDay).toHaveBeenCalledTimes(2);
// (#22) — the whole bulk delete queues exactly ONE cross-device
// sync fan-out, not one per removed row.
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
});
it("soft-deletes scoped by userId + medicationId + deletedAt:null", async () => {
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{ id: "evt-a", scheduledFor: day1 },
] as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 1,
} as never);
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
// v1.15.18 — a bulk delete is a soft-delete updateMany: scoped by
// (id IN, user, medication, deletedAt:null) so a re-post counts zero, and
// the data sets deletedAt + bumps syncVersion for the iOS tombstone feed.
const call = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0];
expect(call?.where).toEqual({
id: { in: ["evt-a"] },
userId: "user-1",
medicationId: "med-1",
deletedAt: null,
});
expect(call?.data).toMatchObject({
deletedAt: expect.any(Date),
syncVersion: { increment: 1 },
});
});
});
describe("POST /api/medications/[id]/intake/bulk-delete — F-1 H-6 rate limit", () => {
it("returns 429 when the per-user cap is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled();
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
});
it("scopes the bucket key to the calling user", async () => {
await POST(postWithBody({ eventIds: ["evt-a"] }), ROUTE_PARAMS);
expect(checkRateLimit).toHaveBeenCalledWith(
"medication-intake-bulk-delete:user-1",
30,
60_000,
);
});
});
describe("POST /api/medications/[id]/intake/bulk-delete — v1.16.10 inventory refund", () => {
it("restores each stamped row before the tombstone sweep and ignores unstamped rows", async () => {
const { restoreForIntake } =
await import("@/lib/medications/inventory/consumption");
const day1 = new Date(Date.UTC(2026, 4, 27, 8, 0, 0));
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([
{
id: "evt-stamped",
scheduledFor: day1,
inventoryConsumption: [{ itemId: "item-1", units: 2 }],
},
{ id: "evt-unstamped", scheduledFor: day1, inventoryConsumption: null },
] as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValueOnce({
count: 2,
} as never);
const res = await POST(
postWithBody({ eventIds: ["evt-stamped", "evt-unstamped"] }),
ROUTE_PARAMS,
);
expect(res.status).toBe(200);
expect(restoreForIntake).toHaveBeenCalledTimes(1);
expect(restoreForIntake).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", eventId: "evt-stamped" }),
);
// The refund runs BEFORE the soft-delete sweep.
const restoreOrder =
vi.mocked(restoreForIntake).mock.invocationCallOrder[0];
const sweepOrder = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.invocationCallOrder[0];
expect(restoreOrder).toBeLessThan(sweepOrder);
});
});
@@ -1,106 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeImportJob: {
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 request = new NextRequest(
"http://localhost/api/medications/med-1/intake/import/job-1",
);
const context = {
params: Promise.resolve({ id: "med-1", jobId: "job-1" }),
};
const session = {
session: { id: "session-1", expiresAt: new Date(Date.now() + 60_000) },
user: { id: "user-1", username: "owner", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(session as never);
});
describe("GET medication intake import status", () => {
it("rejects unauthenticated polling", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await GET(request, context);
expect(response.status).toBe(401);
expect(prisma.medicationIntakeImportJob.findFirst).not.toHaveBeenCalled();
});
it("uses a single owner-and-medication-scoped lookup and hides foreign jobs", async () => {
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue(
null,
);
const response = await GET(request, context);
expect(response.status).toBe(404);
expect(prisma.medicationIntakeImportJob.findFirst).toHaveBeenCalledWith({
where: {
id: "job-1",
medicationId: "med-1",
recordUserId: "user-1",
},
});
});
it.each(["queued", "running", "done", "failed"])(
"returns the owner-visible %s state",
async (status) => {
vi.mocked(prisma.medicationIntakeImportJob.findFirst).mockResolvedValue({
id: "job-1",
status,
progress: { processed: status === "queued" ? 0 : 2, total: 2 },
result: status === "done" ? { imported: 2 } : null,
failureReason:
status === "failed" ? "Medication intake import failed" : null,
createdAt: new Date("2026-07-21T10:00:00.000Z"),
startedAt:
status === "queued" ? null : new Date("2026-07-21T10:00:01.000Z"),
completedAt:
status === "done" || status === "failed"
? new Date("2026-07-21T10:00:02.000Z")
: null,
} as never);
const response = await GET(request, context);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.data).toEqual(
expect.objectContaining({
jobId: "job-1",
status,
progress: expect.any(Object),
}),
);
expect(JSON.stringify(body)).not.toContain("user-1");
expect(JSON.stringify(body)).not.toContain("med-1");
},
);
});
@@ -1,149 +0,0 @@
/**
* v1.5.5 — purge route fires the medication cache bundle.
*
* Pins the contract the detail-page Verlauf-löschen cascade depends
* on: tombstoning the rows is necessary but not sufficient. The
* analytics + iOS today-tally + dashboard tiles all read off cached
* shapes that survive the tombstoning for their TTL; the bundle
* invalidation forces every downstream reader to converge on the
* post-purge counts in the same tick the user sees the toast.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { updateMany: vi.fn() },
// v1.43 — no composition, so no supplement recompute is queued.
medicationIngredient: { findFirst: vi.fn().mockResolvedValue(null) },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: 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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// (#22) — silent cross-device intake sync. Mocked so the route test can
// assert the hook without reaching the APNs senders or the coalescing
// timers.
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
import { DELETE } from "../route";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function deleteReq(): NextRequest {
return new NextRequest(
"http://localhost/api/medications/med-1/intake/purge",
{
method: "DELETE",
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
name: "Test",
} as never);
vi.mocked(prisma.medicationIntakeEvent.updateMany).mockResolvedValue({
count: 7,
} as never);
});
describe("DELETE /api/medications/[id]/intake/purge", () => {
it("returns 200 with the deleted count", async () => {
const res = await DELETE(deleteReq(), ROUTE_PARAMS);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data).toEqual({ purged: true, count: 7 });
// (#22) — the purge queues exactly ONE cross-device sync fan-out.
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
});
it("invalidates the user medication caches on success (F-1 C-3)", async () => {
await DELETE(deleteReq(), ROUTE_PARAMS);
// v1.16.8 — the purge is an interactive write, so it hard-evicts the
// SWR buckets instead of marking them stale.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns the 404 from the shared ownership helper without invalidating", async () => {
vi.mocked(assertMedicationOwnership).mockResolvedValueOnce(
new Response(null, { status: 404 }) as never,
);
const res = await DELETE(deleteReq(), ROUTE_PARAMS);
expect(res.status).toBe(404);
expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled();
expect(invalidateUserMedications).not.toHaveBeenCalled();
});
// The purge was the one destructive verb in the intake family that removed
// rows outright: a mis-tap was final, and a client offline at purge time
// kept every row forever because a row that no longer exists produces no
// tombstone. Assert the CALL SHAPE rather than a count — a count comes back
// identical from a `deleteMany`, which is exactly the regression this pins.
it("tombstones the events instead of removing them", async () => {
await DELETE(deleteReq(), ROUTE_PARAMS);
expect(prisma.medicationIntakeEvent.updateMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.medicationIntakeEvent.updateMany).mock
.calls[0][0] as {
where: Record<string, unknown>;
data: Record<string, unknown>;
};
// Scoped to the caller's own rows for this medication, and to LIVE rows
// only, so a second purge counts zero rather than re-stamping tombstones
// and re-bumping every syncVersion the clients have already seen.
expect(call.where).toEqual({
medicationId: "med-1",
userId: "user-1",
deletedAt: null,
});
// A tombstone the sync feed can carry: a stamp plus a monotonic bump.
expect(call.data.deletedAt).toBeInstanceOf(Date);
expect(call.data.syncVersion).toEqual({ increment: 1 });
});
});
@@ -1,355 +0,0 @@
/**
* v1.4.43 — multi-issue 422 envelope on PATCH
* /api/medications/[id]/inventory/[itemId].
*
* v1.16.1 — stock-correction contract: `unitsRemaining` sets the count
* absolutely, clamps to `unitsTotal`, and the canonical state machine
* derives the next state (0 ⇒ USED_UP).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => {
// v1.32.22 — the PATCH / DELETE now run inside `prisma.$transaction`
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as the tx, so the existing model mocks apply
// inside the transaction and `$queryRaw` (the advisory lock) is observable.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/medications/inventory/service", () => ({
computeExpiresAt: vi.fn().mockReturnValue(null),
buildPatchInventoryUpdate: vi.fn().mockReturnValue({}),
// v1.16.12 — the route serialises its Decimal unit columns to numbers
// on the way out; a passthrough keeps these update-logic assertions
// focused on the Prisma call, not the response shape.
serializeInventoryItem: <T>(item: T) => item,
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
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 { PATCH, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const prismaTx = prisma as unknown as {
$queryRaw: ReturnType<typeof vi.fn>;
$transaction: ReturnType<typeof vi.fn>;
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/m1/inventory/i1", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = {
params: Promise.resolve({ id: "m1", itemId: "i1" }),
};
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it so
// the callback still runs with the base client as the tx.
prismaTx.$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "ACTIVE",
unitsRemaining: 4,
printedExpiry: null,
} as never);
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `markAsFirstUseAt` iso + bad `markAsUsedUp` (not boolean).
const res = await PATCH(
patchReq({ markAsFirstUseAt: "not-iso", markAsUsedUp: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
const res = await PATCH(
patchReq({
markAsFirstUseAt: "not-iso",
markAsUsedUp: "string",
printedExpiry: "also-bad",
notes: 123,
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stock correction (v1.16.1)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
});
it("sets the remaining count absolutely", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
expect(update.data.unitsRemaining).toBe(1);
});
it("derives USED_UP when corrected to zero", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 0 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as unknown as {
data: { unitsRemaining: number; state: string };
};
expect(update.data.unitsRemaining).toBe(0);
expect(update.data.state).toBe("USED_UP");
});
it("clamps a raise above the item's capacity to unitsTotal", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 99 }), ROUTE_CTX);
expect(res.status).toBe(200);
const update = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0] as unknown as { data: { unitsRemaining: number } };
expect(update.data.unitsRemaining).toBe(4);
});
it("rejects a negative correction with 422", async () => {
const res = await PATCH(patchReq({ unitsRemaining: -1 }), ROUTE_CTX);
expect(res.status).toBe(422);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
it("updates manufacturer + doseStrength when supplied", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(
patchReq({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBe("Example Pharma");
expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
});
it("leaves both untouched when the request omits them", async () => {
// Absent must mean "I have nothing to say", never "blank it" — a client
// that does not know the fields must not wipe what another populated.
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect("manufacturer" in arg.data).toBe(false);
expect("doseStrength" in arg.data).toBe(false);
});
it("clears a value on an explicit null", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBeNull();
expect("doseStrength" in arg.data).toBe(false);
});
});
/**
* v1.32.22 — the stock-correction PATCH and the DELETE route their
* read-compose-write through the SAME per-medication advisory lock that
* `consumeForIntake` takes, so an absolute stock write can no longer clobber a
* concurrent dose decrement. These pin the routing: the write runs inside a
* transaction that takes the advisory lock BEFORE it reads + writes.
*/
describe("PATCH / DELETE inventory — advisory-lock serialisation (M5)", () => {
beforeEach(() => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "user-1",
firstUseAt: null,
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
notes: null,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockImplementation(
(async (args: { data: Record<string, unknown> }) => ({
id: "i1",
...args.data,
})) as never,
);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "i1",
} as never);
});
it("takes the medication advisory lock inside a transaction before the write", async () => {
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(200);
// One transaction, one advisory-lock query.
expect(prismaTx.$transaction).toHaveBeenCalledTimes(1);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
// The lock SQL is the canonical `pg_advisory_xact_lock` — proves the route
// shares the intake hook's key rather than re-deriving one.
const sql = prismaTx.$queryRaw.mock.calls[0]?.[0] as {
strings?: string[];
};
expect((sql.strings ?? []).join("")).toContain("pg_advisory_xact_lock");
// Lock is acquired BEFORE the row update (serialises against a concurrent
// consume decrement).
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const updateOrder = vi.mocked(prisma.medicationInventoryItem.update).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(updateOrder);
});
it("re-reads the row INSIDE the locked transaction", async () => {
await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
// The ownership re-read runs after the lock is taken.
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const readOrder = vi.mocked(prisma.medicationInventoryItem.findUnique).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(readOrder);
});
it("DELETE takes the same lock before deleting", async () => {
const res = await DELETE(patchReq({}), ROUTE_CTX);
expect(res.status).toBe(200);
expect(prismaTx.$queryRaw).toHaveBeenCalledTimes(1);
const lockOrder = prismaTx.$queryRaw.mock.invocationCallOrder[0];
const deleteOrder = vi.mocked(prisma.medicationInventoryItem.delete).mock
.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(deleteOrder);
});
it("still 404s an item owned by another user (checked inside the lock)", async () => {
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "i1",
medicationId: "m1",
userId: "someone-else",
state: "IN_USE",
unitsTotal: 4,
unitsRemaining: 3,
printedExpiry: null,
firstUseAt: null,
notes: null,
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 1 }), ROUTE_CTX);
expect(res.status).toBe(404);
expect(prisma.medicationInventoryItem.update).not.toHaveBeenCalled();
});
});
@@ -1,625 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => {
// v1.32.22 — the [itemId] PATCH / DELETE now run inside a transaction
// that takes the per-medication advisory lock. The mock transaction runs the
// callback with the base client as tx, and `$queryRaw` stands in for the
// advisory-lock query.
const prisma: Record<string, unknown> = {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(prisma)),
};
return { prisma };
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: 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, POST } from "../route";
import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
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 MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the transaction implementation — re-establish it.
(
prisma as unknown as { $transaction: ReturnType<typeof vi.fn> }
).$transaction.mockImplementation((fn: (tx: unknown) => unknown) =>
fn(prisma),
);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/inventory", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the inventory list for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "ACTIVE" },
{ id: "inv-2", state: "IN_USE" },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
// v1.19.0 (iOS#25) — the GET response carries a server-computed supply
// summary so the detail-page clients render the canonical Bestand
// instead of re-deriving it. ACTIVE / IN_USE with units pool in;
// EXPIRED surfaces separately and never counts as available.
it("returns a server-computed supply summary (dose-derived, expired separate)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
unitsPerDose: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 },
{ id: "inv-2", state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 },
{ id: "inv-3", state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 },
{ id: "inv-4", state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
summary: {
unitsRemaining: number;
unitsTotal: number;
dosesRemaining: number;
dosesTotal: number;
expiredUnits: number;
};
};
};
// 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses;
// 4 units sit in the expired container (visible, never available).
expect(body.data.summary).toEqual({
unitsRemaining: 7,
unitsTotal: 8,
dosesRemaining: 3,
dosesTotal: 4,
expiredUnits: 4,
});
});
});
describe("POST /api/medications/[id]/inventory", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when unitsTotal is missing or invalid", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 0,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("creates an ACTIVE pen with computed expiresAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
// Now-anchored so the fixture can never age into a past expiry.
const printed = new Date(
Date.now() + 300 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
printedExpiry: printed,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE",
containerType: "OTHER",
unitsTotal: 4,
unitsRemaining: 4,
firstUseAt: null,
printedExpiry: new Date(printed),
expiresAt: new Date(printed),
}),
});
// Staleness regression: the dose-derived stock the medications-list
// payload carries must hard-evict on a registration so the card / table
// reflect the new supply on the very next read (not after the SWR
// stale window). Mark-stale would serve the pre-write stock.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
const existingActive = {
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE" as const,
// A pen — the container kind that carries the post-opening clock.
containerType: "PEN" as const,
unitsTotal: 4,
unitsRemaining: 4,
firstUseAt: null,
printedExpiry: null,
purchasedAt: null,
notes: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("flips ACTIVE → IN_USE on markAsFirstUseAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
// Anchor first-use to one day ago so the 30-day in-use window is
// always open relative to "now" — a previously hardcoded date
// (2026-05-14) silently aged past its window and flipped the expected
// state to EXPIRED once the wall clock crossed firstUseAt + 30 days.
const intake = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: intake },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(intake),
});
});
it("flips state to USED_UP and zeros remaining on markAsUsedUp", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date("2026-05-01"),
unitsRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "USED_UP",
unitsRemaining: 0,
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "USED_UP",
unitsRemaining: 0,
});
// Staleness regression: a stock-affecting correction must hard-evict
// the medications-list bucket so the card reflects the change next read.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
// Regression for the W19b state-machine bypass: composing the next
// state by hand sent ACTIVE → IN_USE on any first-use stamp, but a
// back-dated stamp whose 30-day window already lapsed must land at
// EXPIRED. Re-running `computeInventoryState` after the composed
// view closes the gap.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "EXPIRED",
} as never);
// 45 days in the past — well past the 30-day in-use window.
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "EXPIRED",
firstUseAt: new Date(backdated),
});
});
it("leaves a back-dated first use on a blister IN_USE with no expiresAt", async () => {
// The same back-dated stamp on a tablet pack must not write the
// remaining tablets off: a blister carries no post-opening clock,
// so the only deadline it could have is a printed one, and this row
// has none.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
containerType: "BLISTER",
unitsTotal: 60,
unitsRemaining: 54,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(backdated),
expiresAt: null,
});
});
it("clears the opening date when markAsFirstUseAt is null", async () => {
// The correction path for a container the consumption hook
// auto-opened by mistake: unsetting the date returns the row to
// ACTIVE and drops any in-use deadline with it.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + 25 * 24 * 60 * 60 * 1000),
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "ACTIVE",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: null },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "ACTIVE",
firstUseAt: null,
expiresAt: null,
});
});
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
userId: "OTHER",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
it("hard-deletes the row and audit-logs the final state", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "EXPIRED",
unitsRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "inv-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/inventory/inv-1",
{
method: "DELETE",
},
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
// Staleness regression: removing a container drops the dose-derived
// stock the list payload reports — hard-evict so it reflects next read.
expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
});
describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: "string",
printedExpiry: "not-iso",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: "string",
printedExpiry: "not-iso",
purchasedAt: "also-not-iso",
notes: 123,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("POST /api/medications/[id]/inventory — carton labelling", () => {
it("persists manufacturer + doseStrength when the client sends them", async () => {
// The native pen list uses these two as its headline and subhead. With
// nowhere on the server to hold them, a container registered on the web
// could not be rendered there at all.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
containerType: "PEN",
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
});
});
it("stores null for both when the client omits them", async () => {
// A plain supply row (blister, bottle) carries no carton labelling, and
// every row that predates the columns is truthfully null.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 30,
containerType: "BLISTER",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: null,
doseStrength: null,
}),
});
});
});
@@ -1,163 +0,0 @@
/**
* v1.5.5 — phase-config PUT now returns the multi-issue 422
* envelope rather than the flat `400 Invalid input`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
reminderPhaseConfig: { upsert: vi.fn() },
},
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
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 { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
function putReq(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/medications/med-1/phase-config",
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(assertMedicationOwnership).mockResolvedValue(null);
vi.mocked(prisma.reminderPhaseConfig.upsert).mockResolvedValue({
medicationId: "med-1",
} as never);
});
describe("PUT /api/medications/[id]/phase-config — F-1 H-3 multi-issue 422", () => {
it("returns the multi-issue envelope on a malformed body", async () => {
// Three issues: greenValue not int, greenMode not enum, redValue overflow.
const res = await PUT(
putReq({
greenValue: "not-a-number",
greenMode: "BOGUS",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 10_000,
redMode: "MINUTES",
}),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
});
describe("PUT /api/medications/[id]/phase-config — F-1 H-4 field-by-field upsert", () => {
it("builds the create + update payloads field-by-field rather than spreading parsed.data", async () => {
const body = {
greenValue: 60,
greenMode: "MINUTES",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 240,
redMode: "MINUTES",
};
await PUT(putReq(body), ROUTE_PARAMS);
const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock
.calls[0][0];
// create payload contains exactly the eight enumerated phase fields
// plus the `medicationId` route binding — nothing else.
expect(Object.keys(callArg.create).sort()).toEqual([
"greenMode",
"greenValue",
"medicationId",
"orangeMode",
"orangeValue",
"redMode",
"redValue",
"yellowMode",
"yellowValue",
]);
// update payload mirrors the eight phase fields (no `medicationId`).
expect(Object.keys(callArg.update).sort()).toEqual([
"greenMode",
"greenValue",
"orangeMode",
"orangeValue",
"redMode",
"redValue",
"yellowMode",
"yellowValue",
]);
});
it("ignores an unknown field smuggled in the body (no mass assignment)", async () => {
const body = {
greenValue: 60,
greenMode: "MINUTES",
yellowValue: 30,
yellowMode: "MINUTES",
orangeValue: 0,
orangeMode: "MINUTES",
redValue: 240,
redMode: "MINUTES",
// Unknown rogue field — should never reach the Prisma upsert.
maliciousField: "owned",
};
await PUT(putReq(body), ROUTE_PARAMS);
const callArg = vi.mocked(prisma.reminderPhaseConfig.upsert).mock
.calls[0][0];
expect(
(callArg.create as Record<string, unknown>).maliciousField,
).toBeUndefined();
expect(
(callArg.update as Record<string, unknown>).maliciousField,
).toBeUndefined();
});
});
@@ -1,447 +0,0 @@
/**
* v1.4.25 — side-effect API route tests.
*
* Mirrors the inventory route test fixture pattern: every external
* dependency is mocked at module boundaries (Prisma, session, audit,
* rate-limit, logging transport), and each describe block exercises
* one HTTP verb's contract.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationSideEffect: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: 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, POST } from "../route";
import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { readNote } from "@/lib/crypto/note-cipher";
// The POST path encrypts the free-text note at rest; drive the legacy single
// key so encryptNote has material to work with.
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64));
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 MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/side-effects", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the logs for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue([
{ id: "se-1", entry: "NAUSEA", severity: 2 },
{ id: "se-2", entry: "DIARRHEA", severity: 3 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
it("applies from / to / limit query params", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue(
[] as never,
);
const from = "2026-05-01T00:00:00Z";
const to = "2026-05-14T00:00:00Z";
await GET(
new NextRequest(
`http://localhost/api/medications/med-1/side-effects?from=${from}&to=${to}&limit=10`,
),
{ params: Promise.resolve({ id: "med-1" }) },
);
const call = vi.mocked(prisma.medicationSideEffect.findMany).mock
.calls[0][0];
expect(call).toMatchObject({
where: {
userId: "user-1",
medicationId: "med-1",
occurredAt: { gte: new Date(from), lt: new Date(to) },
},
// One row past the limit, so the response can say it was cut.
take: 11,
});
});
});
describe("POST /api/medications/[id]/side-effects", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when severity is out of range", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 7,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => {
// Client maliciously sends category=INJECTION_SITE for a
// NAUSEA entry. The server now derives the canonical category
// from `entry`, so the row lands with category="GI" regardless
// of the client claim. Backwards-compatible with older clients
// that still send `category`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "INJECTION_SITE",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as { data: { category: string } };
expect(callArgs.data.category).toBe("GI");
});
it("creates a row with the server-derived category", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const occurredAt = "2026-05-14T08:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt,
notes: "after breakfast",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as {
data: {
userId: string;
medicationId: string;
category: string;
entry: string;
severity: number;
occurredAt: Date;
notes: string | null;
notesEncrypted: Uint8Array | null;
};
};
expect(createArgs.data).toMatchObject({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
// v1.25 — the plaintext column is nulled; the note lands encrypted.
notes: null,
});
expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array);
expect(readNote(createArgs.data.notesEncrypted, null)).toBe(
"after breakfast",
);
});
it("defaults occurredAt to now when omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
} as never);
const before = Date.now();
await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GLP1_SPECIFIC",
entry: "EARLY_SATIETY",
severity: 3,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
const after = Date.now();
const call = vi.mocked(prisma.medicationSideEffect.create).mock.calls[0][0];
const created = call.data.occurredAt as Date;
expect(created.getTime()).toBeGreaterThanOrEqual(before);
expect(created.getTime()).toBeLessThanOrEqual(after);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
it("returns 401 unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "OTHER",
medicationId: "med-1",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(),
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(404);
});
it("hard-deletes the row and audit-logs entry + severity", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "user-1",
medicationId: "med-1",
entry: "NAUSEA",
severity: 3,
occurredAt: new Date("2026-05-14T08:00:00Z"),
} as never);
vi.mocked(prisma.medicationSideEffect.delete).mockResolvedValue({
id: "se-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationSideEffect.delete).toHaveBeenCalledWith({
where: { id: "se-1" },
});
});
});
describe("/api/medications/[id]/side-effects — 422 multi-issue (v1.4.43 W6)", () => {
it("GET surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// Bad `from` iso + bad `limit` (out of bounds).
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects?from=not-iso&limit=99999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// Missing `entry` + bad `severity`.
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
severity: "junk",
notes: "x".repeat(2000),
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("POST surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
entry: 999,
severity: "junk",
occurredAt: "not-iso",
notes: "x".repeat(5000),
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -1,284 +0,0 @@
/**
* v1.13.0 — custom mood-tag CRUD route guards: create cap, ownership 404 on
* edit/delete, soft-delete vs purge, and the custom-key rejection on the
* catalogue-hide route.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => ({
moodTag: {
count: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
moodTagCategory: { findFirst: vi.fn() },
moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
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 { POST } from "../custom/route";
import { PATCH, DELETE } from "../custom/[key]/route";
import { PUT } from "../[key]/hidden/route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "t", role: "USER" as const },
};
function jsonReq(url: string, method: string, body: unknown) {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/mood/tags/custom", () => {
it("creates a custom tag and returns it (custom:true, decrypted label)", async () => {
db.moodTag.count.mockResolvedValue(3);
db.moodTag.create.mockResolvedValue({
key: "custom:abc",
icon: "Heart",
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
});
const res = await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
label: "Date night",
icon: "Heart",
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { custom: boolean; label: string; labelKey: null; key: string };
};
expect(body.data).toMatchObject({
custom: true,
label: "Date night",
labelKey: null,
key: "custom:abc",
});
// Label is stored encrypted, never plaintext.
expect(db.moodTag.create.mock.calls[0][0].data.labelEncrypted).toBe(
"enc:Date night",
);
});
it("rejects over the per-user cap with 422", async () => {
db.moodTag.count.mockResolvedValue(50);
const res = await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }),
);
expect(res.status).toBe(422);
expect(db.moodTag.create).not.toHaveBeenCalled();
});
it("defaults to the seeded custom category without a categoryKey (v1.13 contract)", async () => {
db.moodTag.count.mockResolvedValue(0);
db.moodTag.create.mockResolvedValue({
key: "custom:x",
icon: null,
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
});
await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }),
);
expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe(
"mtc_custom",
);
});
it("resolves categoryKey to a seeded OR own group (v1.17.0) and 422s an unknown one", async () => {
db.moodTag.count.mockResolvedValue(0);
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
db.moodTag.create.mockResolvedValue({
key: "custom:x",
icon: null,
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
});
const ok = await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
label: "X",
categoryKey: "customcat:g1",
}),
);
expect(ok.status).toBe(201);
expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("cg1");
// Resolution is owner-scoped: seeded (userId null) OR the caller's own.
const catWhere = db.moodTagCategory.findFirst.mock.calls[0][0].where;
expect(catWhere).toEqual({
key: "customcat:g1",
isActive: true,
OR: [{ userId: null }, { userId: "user-1" }],
});
db.moodTagCategory.findFirst.mockResolvedValue(null);
const bad = await POST(
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
label: "X",
categoryKey: "customcat:theirs",
}),
);
expect(bad.status).toBe(422);
});
});
describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
const params = (key: string) => ({ params: Promise.resolve({ key }) });
it("404s when the key is not the caller's own custom tag", async () => {
db.moodTag.findFirst.mockResolvedValue(null);
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
isActive: false,
}),
params("custom:x"),
);
expect(res.status).toBe(404);
});
it("404s a non-custom key without touching the DB", async () => {
const res = await DELETE(
new NextRequest("http://localhost/api/mood/tags/custom/happy"),
params("happy"),
);
expect(res.status).toBe(404);
expect(db.moodTag.findFirst).not.toHaveBeenCalled();
});
it("moves a custom tag to another group via categoryKey (v1.17.0)", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
db.moodTag.update.mockResolvedValue({
key: "custom:x",
icon: null,
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
isActive: true,
labelEncrypted: "enc:X",
});
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
categoryKey: "customcat:g1",
}),
params("custom:x"),
);
expect(res.status).toBe(200);
expect(db.moodTag.update.mock.calls[0][0].data).toEqual({
categoryId: "cg1",
});
});
it("422s a move to an unknown / foreign group without writing", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
db.moodTagCategory.findFirst.mockResolvedValue(null);
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
categoryKey: "ghost",
}),
params("custom:x"),
);
expect(res.status).toBe(422);
expect(db.moodTag.update).not.toHaveBeenCalled();
});
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
const soft = await DELETE(
new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"),
params("custom:abc"),
);
expect(soft.status).toBe(200);
expect(db.moodTag.update).toHaveBeenCalledWith({
where: { id: "id1" },
data: { isActive: false },
});
expect(db.moodTag.delete).not.toHaveBeenCalled();
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
const purge = await DELETE(
new NextRequest(
"http://localhost/api/mood/tags/custom/custom:abc?purge=true",
),
params("custom:abc"),
);
expect(purge.status).toBe(200);
expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
// A purge cascades `mood_entry_tag_links` and unpicks history, so it must
// leave a ledger row recording that it happened.
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
"mood.tag.custom.delete",
expect.objectContaining({
details: expect.objectContaining({ purge: true }),
}),
);
});
});
describe("PUT /api/mood/tags/:key/hidden", () => {
it("rejects a custom key with 400 (custom tags hide via isActive)", async () => {
const res = await PUT(
jsonReq("http://localhost/api/mood/tags/custom:abc/hidden", "PUT", {
hidden: true,
}),
{ params: Promise.resolve({ key: "custom:abc" }) },
);
expect(res.status).toBe(400);
});
it("upserts a hide row for a catalogue tag", async () => {
db.moodTag.findFirst.mockResolvedValue({ id: "t_sad" });
const res = await PUT(
jsonReq("http://localhost/api/mood/tags/sad/hidden", "PUT", {
hidden: true,
}),
{ params: Promise.resolve({ key: "sad" }) },
);
expect(res.status).toBe(200);
expect(db.moodTagHidden.upsert).toHaveBeenCalled();
});
});
@@ -1,256 +0,0 @@
/**
* v1.17.0 — custom mood-tag group CRUD guards: create cap, ownership 404 on
* edit/delete, and the non-destructive delete (re-home own tags → seeded
* `custom`, strip the group from the layout blob, soft-deactivate by default
* vs hard-delete on ?purge=true).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => {
const base = {
moodTagCategory: {
count: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
moodTag: { updateMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
$transaction: vi.fn(),
};
// Interactive tx: hand the same mock surface to the callback.
base.$transaction.mockImplementation(
async (fn: (tx: typeof base) => Promise<unknown>) => fn(base),
);
return base;
});
vi.mock("@/lib/db", () => ({
prisma: db,
toJson: <T>(v: T) => v,
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
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 { POST } from "../groups/route";
import { PATCH, DELETE } from "../groups/[key]/route";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "t", role: "USER" as const },
};
function jsonReq(url: string, method: string, body: unknown) {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const params = (key: string) => ({ params: Promise.resolve({ key }) });
beforeEach(() => {
vi.clearAllMocks();
db.$transaction.mockImplementation(
async (fn: (tx: typeof db) => Promise<unknown>) => fn(db),
);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("POST /api/mood/tags/groups", () => {
it("creates a group and returns it (custom:true, decrypted label)", async () => {
db.moodTagCategory.count.mockResolvedValue(2);
db.moodTagCategory.create.mockResolvedValue({
key: "customcat:abc",
icon: "Stethoscope",
});
const res = await POST(
jsonReq("http://localhost/api/mood/tags/groups", "POST", {
label: "Therapie",
icon: "Stethoscope",
}),
);
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: { key: string; label: string; labelKey: null; custom: boolean };
};
expect(body.data).toMatchObject({
key: "customcat:abc",
label: "Therapie",
labelKey: null,
custom: true,
});
const data = db.moodTagCategory.create.mock.calls[0][0].data;
// Label is stored encrypted, never plaintext; owner pinned from session.
expect(data.labelEncrypted).toBe("enc:Therapie");
expect(data.userId).toBe("user-1");
expect(data.key.startsWith("customcat:")).toBe(true);
});
it("rejects over the per-user cap with 422", async () => {
db.moodTagCategory.count.mockResolvedValue(12);
const res = await POST(
jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "X" }),
);
expect(res.status).toBe(422);
expect(db.moodTagCategory.create).not.toHaveBeenCalled();
});
});
describe("PATCH /api/mood/tags/groups/:key", () => {
it("404s a seeded category key without touching the DB", async () => {
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", {
label: "x",
}),
params("feelings"),
);
expect(res.status).toBe(404);
expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
});
it("404s when the key is not the caller's own group", async () => {
db.moodTagCategory.findFirst.mockResolvedValue(null);
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", {
label: "x",
}),
params("customcat:x"),
);
expect(res.status).toBe(404);
});
it("renames (re-encrypts) and returns the updated group", async () => {
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
db.moodTagCategory.update.mockResolvedValue({
key: "customcat:x",
icon: null,
isActive: true,
labelEncrypted: "enc:Neu",
});
const res = await PATCH(
jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", {
label: "Neu",
}),
params("customcat:x"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { label: string; isActive: boolean };
};
expect(body.data).toMatchObject({ label: "Neu", isActive: true });
expect(db.moodTagCategory.update.mock.calls[0][0].data).toEqual({
labelEncrypted: "enc:Neu",
});
});
});
describe("DELETE /api/mood/tags/groups/:key", () => {
beforeEach(() => {
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
db.moodTag.updateMany.mockResolvedValue({ count: 2 });
db.user.findUnique.mockResolvedValue({
moodTagLayoutJson: {
groupOrder: ["customcat:x", "feelings"],
placements: { "customcat:x": ["happy"], feelings: ["sad"] },
},
});
});
it("re-homes own tags, strips the layout, soft-deactivates by default", async () => {
const res = await DELETE(
new NextRequest("http://localhost/api/mood/tags/groups/customcat:x"),
params("customcat:x"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { purged: boolean; rehomedCount: number };
};
expect(body.data).toMatchObject({ purged: false, rehomedCount: 2 });
// Own custom tags re-home to the seeded custom category.
expect(db.moodTag.updateMany).toHaveBeenCalledWith({
where: { categoryId: "cg1", userId: "user-1" },
data: { categoryId: CUSTOM_CATEGORY_ID },
});
// Layout blob drops the group from order + placements, keeps the rest.
expect(db.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
moodTagLayoutJson: {
groupOrder: ["feelings"],
placements: { feelings: ["sad"] },
},
},
});
// Default = retire, never delete.
expect(db.moodTagCategory.update).toHaveBeenCalledWith({
where: { id: "cg1" },
data: { isActive: false },
});
expect(db.moodTagCategory.delete).not.toHaveBeenCalled();
});
it("hard-deletes the emptied group row on ?purge=true", async () => {
const res = await DELETE(
new NextRequest(
"http://localhost/api/mood/tags/groups/customcat:x?purge=true",
),
params("customcat:x"),
);
expect(res.status).toBe(200);
expect(db.moodTagCategory.delete).toHaveBeenCalledWith({
where: { id: "cg1" },
});
expect(db.moodTagCategory.update).not.toHaveBeenCalled();
// Re-home still ran first — purge removes the group, never the tags.
expect(db.moodTag.updateMany).toHaveBeenCalled();
// A group delete/purge leaves a ledger row recording purge + re-home.
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
"mood.tag.group.delete",
expect.objectContaining({
details: expect.objectContaining({ purge: true }),
}),
);
});
it("404s a non-custom key and a foreign group", async () => {
const seeded = await DELETE(
new NextRequest("http://localhost/api/mood/tags/groups/custom"),
params("custom"),
);
expect(seeded.status).toBe(404);
db.moodTagCategory.findFirst.mockResolvedValue(null);
const foreign = await DELETE(
new NextRequest("http://localhost/api/mood/tags/groups/customcat:theirs"),
params("customcat:theirs"),
);
expect(foreign.status).toBe(404);
expect(db.moodTag.updateMany).not.toHaveBeenCalled();
});
});
@@ -1,139 +0,0 @@
/**
* v1.17.0 — `GET/PUT /api/mood/tags/layout`: resolved groupOrder over the
* effective category set, preserve-when-absent merge on PUT, and the Zod
* bounds on the blob.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const db = vi.hoisted(() => ({
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
moodTagCategory: { findMany: vi.fn() },
}));
vi.mock("@/lib/db", () => ({
prisma: db,
toJson: <T>(v: T) => v,
}));
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 { GET, PUT } from "../layout/route";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "t", role: "USER" as const },
};
const CATEGORY_KEYS = [
{ key: "feelings" },
{ key: "custom" },
{ key: "customcat:g1" },
];
function putReq(body: unknown) {
return new NextRequest("http://localhost/api/mood/tags/layout", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
db.moodTagCategory.findMany.mockResolvedValue(CATEGORY_KEYS);
db.user.findUnique.mockResolvedValue({ moodTagLayoutJson: null });
});
describe("GET /api/mood/tags/layout", () => {
it("returns seeded defaults when nothing is stored", async () => {
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { groupOrder: string[]; placements: Record<string, string[]> };
};
expect(body.data).toEqual({
groupOrder: ["feelings", "custom", "customcat:g1"],
placements: {},
});
});
it("resolves the stored order: layout first, unknown dropped, missing appended", async () => {
db.user.findUnique.mockResolvedValue({
moodTagLayoutJson: {
groupOrder: ["customcat:g1", "ghost", "feelings"],
placements: { "customcat:g1": ["happy"] },
},
});
const res = await GET();
const body = (await res.json()) as {
data: { groupOrder: string[]; placements: Record<string, string[]> };
};
expect(body.data.groupOrder).toEqual([
"customcat:g1",
"feelings",
"custom",
]);
expect(body.data.placements).toEqual({ "customcat:g1": ["happy"] });
});
});
describe("PUT /api/mood/tags/layout", () => {
it("merges preserve-when-absent: a groupOrder-only PUT keeps stored placements", async () => {
db.user.findUnique.mockResolvedValue({
moodTagLayoutJson: { placements: { feelings: ["sad"] } },
});
const res = await PUT(putReq({ groupOrder: ["custom", "feelings"] }));
expect(res.status).toBe(200);
expect(db.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
// v1.32.22 — tokenless write echoes the fresh `updatedAt` token.
select: { updatedAt: true },
data: {
moodTagLayoutJson: {
groupOrder: ["custom", "feelings"],
placements: { feelings: ["sad"] },
},
},
});
const body = (await res.json()) as {
data: { groupOrder: string[]; placements: Record<string, string[]> };
};
expect(body.data.groupOrder).toEqual([
"custom",
"feelings",
"customcat:g1",
]);
expect(body.data.placements).toEqual({ feelings: ["sad"] });
});
it("422s an over-bound blob without writing", async () => {
const res = await PUT(
putReq({ groupOrder: Array.from({ length: 51 }, (_, i) => `g${i}`) }),
);
expect(res.status).toBe(422);
expect(db.user.update).not.toHaveBeenCalled();
});
it("422s a malformed body (non-array groupOrder)", async () => {
const res = await PUT(putReq({ groupOrder: "feelings" }));
expect(res.status).toBe(422);
expect(db.user.update).not.toHaveBeenCalled();
});
});
@@ -1,354 +0,0 @@
/**
* v1.12.0 / v1.13.0 / v1.17.0 — `GET /api/mood/tags` exposes the rated-factor
* metadata (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the effective
* per-user set: own custom tags merged in (label decrypted, `custom: true`),
* hidden catalogue tags omitted by default and surfaced under
* `?include=hidden`. v1.17.0 layers custom groups, the layout blob (group
* order + placements), `include=archived` (own inactive customs) and
* `include=usage` (per-tag live-entry counts) on top.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const categoryFindMany = vi.fn();
const tagFindMany = vi.fn();
const hiddenFindMany = vi.fn();
const userFindUnique = vi.fn();
const linkGroupBy = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) },
moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) },
moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) },
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
moodEntryTagLink: { groupBy: (...a: unknown[]) => linkGroupBy(...a) },
},
}));
// Identity-ish crypto so the decrypted custom label is asserted in plaintext.
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc:${s}`,
decrypt: (s: string) => s.replace(/^enc:/, ""),
}));
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 { GET } from "../route";
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: "tester", role: "USER" as const },
};
const CATEGORIES = [
{
id: "c1",
key: "feelings",
labelKey: "mood.tagCategory.feelings",
icon: "Smile",
userId: null,
labelEncrypted: null,
},
{
id: "mtc_custom",
key: "custom",
labelKey: "mood.tagCategory.custom",
icon: "Tag",
userId: null,
labelEncrypted: null,
},
];
const OWN_GROUP = {
id: "cg1",
key: "customcat:g1",
labelKey: "customcat:g1",
icon: "Stethoscope",
userId: "user-1",
labelEncrypted: "enc:Therapie",
};
const TAGS = [
{
id: "t_happy",
categoryId: "c1",
key: "happy",
labelKey: "mood.tag.happy",
icon: "Smile",
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
isActive: true,
userId: null,
labelEncrypted: null,
},
{
id: "t_sad",
categoryId: "c1",
key: "sad",
labelKey: "mood.tag.sad",
icon: "Frown",
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
isActive: true,
userId: null,
labelEncrypted: null,
},
{
id: "t_custom",
categoryId: "mtc_custom",
key: "custom:abc",
labelKey: "custom:abc",
icon: "Heart",
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
isActive: true,
userId: "user-1",
labelEncrypted: "enc:Migräne",
},
];
const ARCHIVED_TAG = {
id: "t_arch",
categoryId: "mtc_custom",
key: "custom:arch",
labelKey: "custom:arch",
icon: null,
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
isActive: false,
userId: "user-1",
labelEncrypted: "enc:Alt",
};
interface TagOut {
key: string;
labelKey: string | null;
label: string | null;
custom: boolean;
kind: string;
scaleMin: number;
scaleMax: number;
inverse: boolean;
hidden?: boolean;
archived?: boolean;
usageCount?: number;
}
interface CategoryOut {
key: string;
labelKey: string | null;
label: string | null;
icon: string | null;
custom: boolean;
tags: TagOut[];
}
interface Body {
data: { categories: CategoryOut[] };
}
function req(url = "http://localhost/api/mood/tags") {
return new NextRequest(url);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
categoryFindMany.mockResolvedValue(CATEGORIES);
tagFindMany.mockResolvedValue(TAGS);
hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]);
userFindUnique.mockResolvedValue({ moodTagLayoutJson: null });
linkGroupBy.mockResolvedValue([]);
});
function flat(body: Body): Record<string, TagOut> {
const out: Record<string, TagOut> = {};
for (const c of body.data.categories) for (const t of c.tags) out[t.key] = t;
return out;
}
describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => {
it("emits rated-factor metadata + custom/label fields and omits hidden by default", async () => {
const res = await GET(req());
expect(res.status).toBe(200);
const tags = flat((await res.json()) as Body);
// Catalogue tag: custom=false, label=null, labelKey kept, metadata present.
expect(tags.happy).toMatchObject({
custom: false,
label: null,
labelKey: "mood.tag.happy",
kind: "BINARY",
scaleMin: 1,
scaleMax: 5,
inverse: false,
});
expect(tags.happy.hidden).toBeUndefined();
// Custom tag merged in: custom=true, decrypted label, labelKey null.
expect(tags["custom:abc"]).toMatchObject({
custom: true,
label: "Migräne",
labelKey: null,
});
// Hidden catalogue tag omitted by default.
expect(tags.sad).toBeUndefined();
// Tag query scopes to catalogue OR the caller's own customs.
const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
// Plain picker read never pays the usage groupBy.
expect(linkGroupBy).not.toHaveBeenCalled();
});
it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => {
const res = await GET(req("http://localhost/api/mood/tags?include=hidden"));
const tags = flat((await res.json()) as Body);
expect(tags.sad).toMatchObject({ hidden: true, custom: false });
expect(tags.happy).toMatchObject({ hidden: false });
// Custom tags never carry a hidden flag.
expect(tags["custom:abc"].hidden).toBeUndefined();
});
});
describe("GET /api/mood/tags — groups + layout + archived + usage (v1.17.0)", () => {
it("returns own custom groups with decrypted label and custom:true on categories", async () => {
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
tagFindMany.mockResolvedValue([
...TAGS,
{
...TAGS[2],
id: "t_c2",
categoryId: "cg1",
key: "custom:ing1",
labelKey: "custom:ing1",
labelEncrypted: "enc:Sitzung",
},
]);
const res = await GET(req());
const body = (await res.json()) as Body;
const group = body.data.categories.find((c) => c.key === "customcat:g1");
expect(group).toMatchObject({
custom: true,
label: "Therapie",
labelKey: null,
icon: "Stethoscope",
});
expect(group?.tags.map((t) => t.key)).toEqual(["custom:ing1"]);
// Seeded categories stay labelKey-driven.
const feelings = body.data.categories.find((c) => c.key === "feelings");
expect(feelings).toMatchObject({
custom: false,
label: null,
labelKey: "mood.tagCategory.feelings",
});
// Category query scopes to seeded OR own groups.
const catWhere = categoryFindMany.mock.calls[0]?.[0]?.where;
expect(catWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
});
it("applies the layout blob: group order + catalogue-tag placement", async () => {
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
userFindUnique.mockResolvedValue({
moodTagLayoutJson: {
groupOrder: ["customcat:g1", "custom", "feelings"],
placements: { "customcat:g1": ["happy"] },
},
});
const res = await GET(req());
const body = (await res.json()) as Body;
// `feelings` ends empty (happy placed away, sad hidden) → dropped.
expect(body.data.categories.map((c) => c.key)).toEqual([
"customcat:g1",
"custom",
]);
// Catalogue tag `happy` rendered inside the user's group (placement,
// not a categoryId change) — gone from its home category.
const group = body.data.categories.find((c) => c.key === "customcat:g1");
expect(group?.tags.map((t) => t.key)).toEqual(["happy"]);
const feelings = body.data.categories.find((c) => c.key === "feelings");
expect(feelings).toBeUndefined(); // only hidden `sad` left → dropped
});
it("drops a stale layout placement (hidden tag) silently", async () => {
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
userFindUnique.mockResolvedValue({
moodTagLayoutJson: {
placements: { "customcat:g1": ["sad", "ghost"] }, // sad is hidden
},
});
const res = await GET(req());
const body = (await res.json()) as Body;
expect(
body.data.categories.find((c) => c.key === "customcat:g1"),
).toBeUndefined(); // nothing visible placed → empty → dropped (plain read)
});
it("returns archived own customs with archived:true under include=archived and keeps empty own groups", async () => {
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
tagFindMany.mockResolvedValue([...TAGS, ARCHIVED_TAG]);
const res = await GET(
req("http://localhost/api/mood/tags?include=hidden,archived"),
);
const body = (await res.json()) as Body;
const tags = flat(body);
expect(tags["custom:arch"]).toMatchObject({
custom: true,
archived: true,
label: "Alt",
});
expect(tags["custom:abc"]).toMatchObject({ archived: false });
// Catalogue rows never carry the archived flag.
expect(tags.happy.archived).toBeUndefined();
// The archived read lifts isActive only for the caller's own rows.
const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
expect(tagWhere).toEqual({
OR: [{ userId: null, isActive: true }, { userId: "user-1" }],
});
// Empty own group kept on a management read.
expect(
body.data.categories.find((c) => c.key === "customcat:g1"),
).toMatchObject({ custom: true, tags: [] });
});
it("emits usageCount per tag under include=usage (live entries only)", async () => {
linkGroupBy.mockResolvedValue([
{ moodTagId: "t_happy", _count: { _all: 7 } },
]);
const res = await GET(req("http://localhost/api/mood/tags?include=usage"));
const tags = flat((await res.json()) as Body);
expect(tags.happy.usageCount).toBe(7);
expect(tags["custom:abc"].usageCount).toBe(0);
expect(linkGroupBy).toHaveBeenCalledWith(
expect.objectContaining({
by: ["moodTagId"],
where: { moodEntry: { userId: "user-1", deletedAt: null } },
}),
);
});
});
@@ -1,173 +0,0 @@
/**
* v1.32.22 — `PUT /api/mood/tags/layout` optimistic concurrency.
*
* The two manage cards write DIFFERENT fields of the same blob (group order
* vs placements) from the same page; a stale merge would resurrect the other
* card's overwritten field. The base token 409s that instead. A tokenless PUT
* keeps the prior unconditional write.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
moodTagCategory: {
findMany: vi.fn(),
},
},
toJson: <T>(v: T) => v,
}));
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<typeof import("@/lib/logging/context")>();
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): NextRequest {
return new NextRequest("http://localhost/api/mood/tags/layout", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
});
describe("GET /api/mood/tags/layout", () => {
it("returns the layout plus the optimistic-concurrency token", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: { groupOrder: ["a", "b"], placements: {} },
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { groupOrder: string[]; updatedAt?: string };
};
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
});
it("omits updatedAt for a fresh user (null row)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
const env = (await res.json()) as {
data: { updatedAt?: string };
};
expect(env.data.updatedAt).toBeUndefined();
});
});
describe("PUT /api/mood/tags/layout — optimistic concurrency", () => {
it("guards on the base token and echoes the advanced token", async () => {
vi.mocked(prisma.user.findUnique)
// merge read
.mockResolvedValueOnce({ moodTagLayoutJson: null } as never)
// post-write fresh token
.mockResolvedValueOnce({
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: NextRequest) => Promise<Response>)(
mkPut({
groupOrder: ["a", "b"],
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
}),
);
expect(res.status).toBe(200);
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
?.where as { id: string; updatedAt: Date };
expect((whereArg.updatedAt as Date).toISOString()).toBe(
"2026-07-24T10:00:00.000Z",
);
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: the stale-base second writer gets 409 and clobbers nothing", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
moodTagLayoutJson: { groupOrder: ["a"], placements: {} },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({
placements: { a: ["t1"] },
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("mood_tag_layout_conflict");
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.findUnique).mockResolvedValue({
moodTagLayoutJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
} as never);
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({ groupOrder: ["a", "b"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("422s a malformed base token without touching the row", async () => {
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
mkPut({ groupOrder: ["a"], 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();
});
});
@@ -1,418 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/telegram", () => ({
setTelegramWebhook: vi.fn().mockResolvedValue(true),
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/lib/notifications/senders/email-config", () => ({
isEmailConfigured: vi.fn(() => true),
}));
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 { PUT as putWebhook } from "../webhook/route";
import { PUT as putNtfy } from "../ntfy/route";
import { PUT as putEmail } from "../email/route";
import { PUT as putTelegram } from "../telegram/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { setTelegramWebhook } from "@/lib/telegram";
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 routes = {
webhook: putWebhook,
ntfy: putNtfy,
email: putEmail,
telegram: putTelegram,
};
type PutRoute = (request: Request) => Promise<Response>;
function put(channel: keyof typeof routes, body: unknown) {
return (routes[channel] as PutRoute)(
new Request(`http://localhost/api/settings/${channel}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("notification channel enable-only routes", () => {
it.each(Object.keys(routes) as Array<keyof typeof routes>)(
"%s rejects unauthenticated toggle writes",
async (channel) => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await put(channel, { enabled: false });
expect(response.status).toBe(401);
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
},
);
it("updates only the owned webhook enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}',
} as never);
const response = await put("webhook", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "WEBHOOK" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only the owned ntfy enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only the owned email enabled field", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"recipient":"saved@example.com"}',
} as never);
const response = await put("email", { enabled: true });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { saved: true },
error: null,
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "EMAIL" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("updates only Telegram enabled fields and leaves stored credentials untouched", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: true,
} as never);
const response = await put("telegram", { enabled: false });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { updated: true },
error: null,
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { telegramEnabled: false },
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "TELEGRAM" },
data: { enabled: false },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
expect(prisma.notificationChannel.deleteMany).not.toHaveBeenCalled();
});
it("registers Telegram on enable without rewriting stored credentials", async () => {
vi.stubEnv("APP_URL", "https://health.example");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: false,
} as never);
const response = await put("telegram", { enabled: true });
expect(response.status).toBe(200);
expect(setTelegramWebhook).toHaveBeenCalledWith(
"saved-token",
"https://health.example/api/telegram/webhook",
undefined,
);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { telegramEnabled: true },
});
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "TELEGRAM" },
data: { enabled: true },
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it.each([
[
"webhook",
{
url: "https://saved.example/hook",
headerName: "X-Test",
headerValue: "secret",
enabled: false,
},
],
[
"ntfy",
{
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "secret",
enabled: false,
},
],
["email", { recipient: "saved@example.com", enabled: false }],
] as const)(
"keeps the existing full %s save contract",
async (channel, body) => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put(channel, body);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
},
);
it("keeps the existing full Telegram save contract", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
} as never);
const response = await put("telegram", {
botToken: "new-token",
chatId: "new-chat",
enabled: false,
});
expect(response.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
telegramEnabled: false,
telegramBotToken: "encrypted:new-token",
telegramChatId: "new-chat",
},
});
expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
});
it.each(["webhook", "ntfy", "email"] as const)(
"rejects enabling an unconfigured %s channel without creating configuration",
async (channel) => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put(channel, { enabled: true });
expect(response.status).toBe(422);
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
},
);
});
/**
* v1.32.1 — regression for iOS coordination issue #63: `PUT
* /api/settings/ntfy` reconstructed the entire stored config from the
* request body. `GET` never returns the write-only `authToken` (only
* `hasAuthToken: true/false`), so both native and web clients correctly
* OMIT — or round-trip an empty string through — the field on any
* unrelated edit. Rebuilding `config` without a preserve fallback
* silently dropped a working token on the next save.
*
* The fix mirrors the pre-existing `headerValue` preserve-on-empty
* contract in `src/app/api/settings/webhook/route.ts` (asserted here too,
* as a parity pin — that route was never broken, but any future drift
* between the two should fail the same suite). Covers exactly the four
* cases the report asks for.
*/
describe("PUT /api/settings/ntfy — write-only authToken preserve-on-omit contract", () => {
function upsertConfig() {
const call = vi.mocked(prisma.notificationChannel.upsert).mock
.calls[0]?.[0] as {
create: { config: string };
update: { config: string };
};
// The route always upserts — assert both arms agree, then decode one.
expect(call.update.config).toBe(call.create.config);
return JSON.parse(call.update.config.replace(/^encrypted:/, "")) as {
serverUrl: string;
topic: string;
authToken?: string;
};
}
it("an omitted authToken preserves the stored secret", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("secret");
});
it("an explicit empty-string authToken preserves the stored secret (matches current clients)", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("secret");
});
it("a non-empty authToken replaces the stored secret", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"old-secret"}',
} as never);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "saved-topic",
authToken: "new-secret",
enabled: true,
});
expect(response.status).toBe(200);
expect(upsertConfig().authToken).toBe("new-secret");
});
it("creating without a token remains valid where anonymous ntfy is allowed", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null);
const response = await put("ntfy", {
serverUrl: "https://ntfy.sh",
topic: "public-topic",
enabled: false,
});
expect(response.status).toBe(200);
const config = upsertConfig();
expect(config.authToken).toBeUndefined();
expect(config.topic).toBe("public-topic");
});
it("parity pin: the webhook headerValue preserve-on-empty contract this fix mirrors is intact", async () => {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config:
'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}',
} as never);
const response = await put("webhook", {
url: "https://saved.example/hook",
headerName: "Authorization",
enabled: true,
});
expect(response.status).toBe(200);
const call = vi.mocked(prisma.notificationChannel.upsert).mock
.calls[0]?.[0] as { update: { config: string } };
const config = JSON.parse(
call.update.config.replace(/^encrypted:/, ""),
) as { headerValue?: string };
expect(config.headerValue).toBe("secret");
});
});
@@ -1,226 +0,0 @@
/**
* Enabling a channel the operator switched off instance-wide is refused.
*
* The three switches (`AppSettings.telegramGlobal` / `.ntfyGlobal` /
* `.webPushGlobal`) now stop delivery in the dispatcher. Without a matching
* refusal on the way in, a user would still see the toggle flip, the row
* would still say `enabled = true`, and nothing would ever arrive. These
* tests pin the refusal and the absence of any write behind it.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
notificationChannel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
pushSubscription: { upsert: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const setTelegramWebhookMock = vi.fn().mockResolvedValue(true);
vi.mock("@/lib/telegram", () => ({
setTelegramWebhook: (...args: unknown[]) => setTelegramWebhookMock(...args),
deleteTelegramWebhook: vi.fn().mockResolvedValue(true),
}));
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 { PUT as putNtfy } from "../ntfy/route";
import { PUT as putTelegram } from "../telegram/route";
import { POST as postWebPush } from "../../notifications/web-push/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 },
};
const WEB_PUSH_SUBSCRIPTION = {
endpoint: "https://push.example.com/subscription/abc",
keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) },
};
function stubSettings(over: Record<string, boolean>) {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
...over,
} as never);
}
function request(path: string, method: string, body: unknown) {
return new Request(`http://localhost${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic"}',
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
telegramBotToken: "encrypted:saved-token",
telegramChatId: "saved-chat",
telegramEnabled: false,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never);
vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(null);
});
describe("ntfy enable against the instance-wide switch", () => {
it("refuses with 403 and writes nothing when the operator switched ntfy off", async () => {
stubSettings({ ntfyGlobal: false });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
data: null,
error: expect.stringContaining("disabled on this instance"),
});
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("still allows disabling the channel while the switch is off", async () => {
stubSettings({ ntfyGlobal: false });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: false }) as never,
);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: false },
});
});
it("allows enabling while the switch is on", async () => {
stubSettings({ ntfyGlobal: true });
const response = await putNtfy(
request("/api/settings/ntfy", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(200);
expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", type: "NTFY" },
data: { enabled: true },
});
});
});
describe("Telegram enable against the instance-wide switch", () => {
it("refuses with 403, registers no webhook, and writes nothing", async () => {
stubSettings({ telegramGlobal: false });
const response = await putTelegram(
request("/api/settings/telegram", "PUT", { enabled: true }) as never,
);
expect(response.status).toBe(403);
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled();
});
it("refuses a full-config save that enables the channel", async () => {
stubSettings({ telegramGlobal: false });
const response = await putTelegram(
request("/api/settings/telegram", "PUT", {
botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii",
chatId: "9876543",
enabled: true,
}) as never,
);
expect(response.status).toBe(403);
expect(setTelegramWebhookMock).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("Web Push subscribe against the instance-wide switch", () => {
it("refuses with 403 and stores no subscription", async () => {
stubSettings({ webPushGlobal: false });
const response = await postWebPush(
request(
"/api/notifications/web-push",
"POST",
WEB_PUSH_SUBSCRIPTION,
) as never,
);
expect(response.status).toBe(403);
expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled();
expect(prisma.notificationChannel.create).not.toHaveBeenCalled();
});
it("stores the subscription while the switch is on", async () => {
stubSettings({ webPushGlobal: true });
const response = await postWebPush(
request(
"/api/notifications/web-push",
"POST",
WEB_PUSH_SUBSCRIPTION,
) as never,
);
expect(response.status).toBe(200);
expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1);
});
});
@@ -1,138 +0,0 @@
/**
* POST /api/settings/email/test — a failed send names its cause.
*
* Before this the route answered every failure with a bare 500, so the card
* read "Test failed" whether the mail server refused the login, timed out,
* or rejected the recipient.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { notificationChannel: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const sendViaEmailMock = vi.fn();
vi.mock("@/lib/notifications/senders/email", () => ({
sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args),
}));
vi.mock("@/lib/notifications/senders/email-config", () => ({
isEmailConfigured: () => true,
}));
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/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
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 { POST } from "../email/test/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 post(): Promise<Response> {
return (POST as (request: Request) => Promise<Response>)(
new Request("http://localhost/api/settings/email/test", {
method: "POST",
}),
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: 'encrypted:{"recipient":"you@example.com"}',
} as never);
});
describe("POST /api/settings/email/test", () => {
it.each([
["credentials_rejected", 535, "rejected the credentials (SMTP 535)"],
["upstream_rejected", 550, "refused the message (SMTP 550)"],
])(
"answers an SMTP rejection with 502, %s and the reply code",
async (failureCode, smtpCode, sentence) => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: smtpCode >= 500,
reason: "email_smtp_5xx",
failureCode,
smtpCode,
});
const response = await post();
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: failureCode,
smtpCode,
});
expect(json.error).toContain(sentence);
},
);
it("answers a timeout with 502 and the timeout code", async () => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: "email_smtp_error",
failureCode: "timeout",
});
const response = await post();
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
meta: { errorCode: "timeout" },
});
});
it("keeps a 500 for a failure the sender could not name", async () => {
sendViaEmailMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: "email_smtp_error",
});
const response = await post();
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
});
it("answers a delivered test with 200", async () => {
sendViaEmailMock.mockResolvedValue({ ok: true });
const response = await post();
expect(response.status).toBe(200);
});
});
@@ -1,510 +1,17 @@
/** /**
* #947 — a webhook or ntfy target on a private network, at the four routes * #947: the test button and the dispatcher must reach the SAME sender
* that meet it. * function for a webhook or ntfy target, so the private-origin decision
* cannot differ between the button that says "works" and the reminder that
* then does not arrive. A structural check over the sources.
* *
* The save routes evaluate the same policy the senders do, so a private * The behaviour at the routes (save-time refusals, the test routes' 422 and
* target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that * 502 answers) is proven against the real database in
* names the reason (`meta.errorCode = private_origin_not_approved`) instead * `tests/integration/routes/settings-channels.test.ts` and
* of a generic "Invalid data", and a listed literal address saves although * `tests/integration/routes/settings-channel-tests.test.ts`.
* the plain public floor would refuse it. The test routes forward the
* sender's own refusal as a 422 with the same code instead of the bare 500
* that made the reporter read the wide-event log to learn it was policy, and
* answer a relay's error status with a 502 that carries the status and what
* the relay said.
*
* The last block is the structural half: the test button and the dispatcher
* must reach the SAME sender function, so the decision cannot differ between
* the button that says "works" and the reminder that then does not arrive.
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
vi.mock("@/lib/app-settings", () => ({
isChannelGloballyEnabled: vi.fn().mockResolvedValue(true),
}));
const sendViaWebhookMock = vi.fn();
const sendViaNtfyMock = vi.fn();
vi.mock("@/lib/notifications/senders/webhook", () => ({
sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args),
}));
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
const emitIfSampledMock = vi.fn();
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: (...args: unknown[]) => emitIfSampledMock(...args),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
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 { PUT as putWebhook } from "../webhook/route";
import { PUT as putNtfy } from "../ntfy/route";
import { POST as testWebhook } from "../webhook/test/route";
import { POST as testNtfy } from "../ntfy/test/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 },
};
type Route = (request: Request) => Promise<Response>;
function put(path: string, body: unknown): Promise<Response> {
const route = (path === "webhook" ? putWebhook : putNtfy) as Route;
return route(
new Request(`http://localhost/api/settings/${path}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function post(path: string): Promise<Response> {
const route = (path === "webhook" ? testWebhook : testNtfy) as Route;
return route(
new Request(`http://localhost/api/settings/${path}/test`, {
method: "POST",
}),
);
}
const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS;
beforeEach(() => {
vi.clearAllMocks();
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
});
afterEach(() => {
if (ORIGINAL_PRIVATE_ORIGINS === undefined) {
delete process.env.NOTIFICATION_PRIVATE_ORIGINS;
} else {
process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS;
}
});
describe("PUT /api/settings/webhook — private targets", () => {
it("refuses an unlisted private address with the reason, and stores nothing", async () => {
const response = await put("webhook", {
url: "http://10.0.0.5:8080/message",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.data).toBeNull();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("refuses a sub-host of a listed origin: a grant names one origin only", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com";
const response = await put("webhook", {
url: "https://push.gotify.example.com/message",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves a listed private address the public floor alone would refuse", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080";
const response = await put("webhook", {
url: "http://10.0.0.5:8080/message",
enabled: true,
});
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
expect(call.create.config).toBe(
'encrypted:{"url":"http://10.0.0.5:8080/message"}',
);
});
it("keeps the plain shape refusal for a body that is not a private-origin case", async () => {
const response = await put("webhook", { url: 42, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.error).toBe("Invalid data");
// No private-origin code: only the status's generic one.
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.invalid",
});
});
it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => {
// Host networking: an exact loopback origin with its port is a grant like
// any other. Unlisted, it is refused with the code that says "list it",
// because listing it does open it.
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080";
const refused = await put("webhook", {
url: "http://127.0.0.1:8080/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://localhost:8080/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => {
// This used to pin a dead end: the route said "list it", the parser
// dropped the listing, and the next save said "list it" again. A target
// no grant can open gets its own code and a message that says so.
vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.NOTIFICATION_PRIVATE_ORIGINS =
"http://169.254.169.254, http://0.0.0.0:8080";
for (const url of [
"http://169.254.169.254/latest",
"http://0.0.0.0:8080/message",
"http://[fe80::1]:8080/message",
]) {
const response = await put("webhook", { url, enabled: true });
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_grantable",
});
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(json.details.issues.length).toBeGreaterThan(0);
}
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves an mDNS name the input floor refuses, once the operator lists it", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local";
const refused = await put("webhook", {
url: "http://ntfy.local/message",
enabled: true,
});
expect(refused.status).toBe(422);
await expect(refused.json()).resolves.toMatchObject({
meta: { errorCode: "private_origin_not_approved" },
});
const saved = await put("webhook", {
url: "http://gotify.local/message",
enabled: true,
});
expect(saved.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
});
describe("PUT /api/settings/ntfy — private servers", () => {
it("refuses an unlisted private server with the reason, and stores nothing", async () => {
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:8080",
topic: "health",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
it("saves a listed private server", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080";
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:8080",
topic: "health",
enabled: true,
});
expect(response.status).toBe(200);
expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1);
});
it("refuses a sibling port of a listed server", async () => {
process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080";
const response = await put("ntfy", {
serverUrl: "http://192.168.1.20:9090",
topic: "health",
enabled: true,
});
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
});
});
describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => {
beforeEach(() => {
vi.mocked(prisma.notificationChannel.findUnique).mockImplementation(
(async (args: { where: { userId_type: { type: string } } }) =>
args.where.userId_type.type === "WEBHOOK"
? { config: 'encrypted:{"url":"https://gotify.lan/message"}' }
: {
config:
'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}',
}) as never,
);
});
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: answers a private-origin refusal with 422 and the code, not a 500",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_approved",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_approved",
});
expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: forwards the not-grantable code with a message that does not say list it (M1)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_private_origin_refused`,
errorCode: "private_origin_not_grantable",
});
const response = await post(path);
expect(response.status).toBe(422);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "private_origin_not_grantable",
});
expect(json.error).toMatch(/no operator grant can open/);
expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS");
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_400`,
statusCode: 400,
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
const response = await post(path);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "upstream_rejected",
upstreamStatus: 400,
upstreamBody: '{"error":"Bad Request","errorCode":400}',
});
expect(json.error).toContain("HTTP 400");
// A user's relay refusing is not an operator error: the event is warn.
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "warn" }),
);
},
);
it.each([
[401, "credentials_rejected"],
[403, "credentials_rejected"],
[404, "endpoint_not_found"],
[410, "endpoint_not_found"],
[415, "upstream_rejected"],
[422, "upstream_rejected"],
[429, "rate_limited"],
[503, "upstream_error"],
[307, "redirected"],
])(
"webhook: an upstream %i maps to %s, without a body when the sender dropped it",
async (status, code) => {
sendViaWebhookMock.mockResolvedValue({
ok: false,
hardReject: false,
reason: `webhook_${status}`,
statusCode: status,
});
const response = await post("webhook");
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: code,
upstreamStatus: status,
});
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: a timeout without a status is a 502 with the timeout code",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_network_error`,
failureCode: "timeout",
});
const response = await post(path);
expect(response.status).toBe(502);
await expect(response.json()).resolves.toMatchObject({
meta: { errorCode: "timeout" },
});
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])(
"%s: only a failure the sender could not name stays a 500",
async (path, sender) => {
sender.mockResolvedValue({
ok: false,
hardReject: false,
reason: `${path}_network_error`,
});
const response = await post(path);
expect(response.status).toBe(500);
const json = await response.json();
expect(json.meta).toEqual({
requestId: expect.any(String),
errorCode: "http.internal",
});
expect(emitIfSampledMock).toHaveBeenCalledWith(
expect.objectContaining({ level: "error" }),
);
},
);
it.each([
["webhook", sendViaWebhookMock],
["ntfy", sendViaNtfyMock],
])("%s: a delivered test is a 200", async (path, sender) => {
sender.mockResolvedValue({ ok: true, statusCode: 200 });
const response = await post(path);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: { sent: true },
error: null,
});
});
});
describe("the test button and the dispatcher share one sender", () => { describe("the test button and the dispatcher share one sender", () => {
const SRC = join(process.cwd(), "src"); const SRC = join(process.cwd(), "src");
@@ -1,202 +0,0 @@
/**
* v1.4.27 — settings/telegram/test locale path.
*
* Asserts the per-user Telegram test message renders in the user's
* persisted `User.locale` rather than the hardcoded English string
* the pre-fix route used. The actual translator behaviour is covered
* by `notifications/__tests__/admin-locale.test.ts`; this file is
* the route-level smoke test that confirms the wiring.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ ok: true, data }),
apiError: (message: string, status: number) => ({
ok: false,
error: message,
status,
}),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (s: string) => `decrypted(${s})`,
}));
const sendTelegramMessageMock = vi.fn();
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
const tCalls: Array<{ locale: string; key: string }> = [];
vi.mock("@/lib/i18n/server-translator", () => ({
getServerTranslator: (locale: string) => ({
locale,
t: (key: string) => {
tCalls.push({ locale, key });
const fakeStrings: Record<string, Record<string, string>> = {
de: {
"notifications.user.telegramTestBody":
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
},
en: {
"notifications.user.telegramTestBody":
"HealthLog: connection successful. Telegram notifications are active.",
},
fr: {
"notifications.user.telegramTestBody":
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
},
};
return (fakeStrings[locale] ?? fakeStrings.en)[key] ?? key;
},
}),
}));
import { POST } from "../telegram/test/route";
import { prisma } from "@/lib/db";
import { requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
tCalls.length = 0;
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "user-1" },
} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
sendTelegramMessageMock.mockResolvedValue({ ok: true });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("POST /api/settings/telegram/test — locale-aware message", () => {
it("sends the German body when User.locale=de", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1);
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.",
);
expect(tCalls).toEqual([
{
locale: "de",
key: "notifications.user.telegramTestBody",
},
]);
expect((result as { ok: boolean }).ok).toBe(true);
});
it("sends the French body when User.locale=fr", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "fr",
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connexion réussie. Les notifications Telegram sont actives.",
);
});
it("falls back to the default locale (en) when User.locale is null", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: null,
} as never);
await (POST as () => Promise<unknown>)();
const [, , body] = sendTelegramMessageMock.mock.calls[0];
expect(body).toBe(
"HealthLog: connection successful. Telegram notifications are active.",
);
expect(tCalls[0].locale).toBe("en");
});
it("falls back to the default locale (en) when User.locale is unsupported", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "klingon",
} as never);
await (POST as () => Promise<unknown>)();
expect(tCalls[0].locale).toBe("en");
});
it("returns a 422 error when bot token is not configured", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: null,
telegramChatId: null,
locale: "de",
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(422);
});
it("returns a 429 error when rate-limited", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const result = await (POST as () => Promise<unknown>)();
expect(sendTelegramMessageMock).not.toHaveBeenCalled();
expect((result as { status: number }).status).toBe(429);
});
it("returns a 422 error when the Telegram send fails", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
telegramBotToken: "enc-token",
telegramChatId: "chat-1",
locale: "en",
} as never);
sendTelegramMessageMock.mockResolvedValueOnce({ ok: false });
const result = await (POST as () => Promise<unknown>)();
expect((result as { status: number }).status).toBe(422);
});
});
@@ -1,194 +0,0 @@
/**
* The webhook payload format at the settings route (#947).
*
* Absent means generic, so a config saved before the choice existed keeps
* sending the body it always sent. Only the Gotify choice is written, an
* omitted format keeps the stored one, and the GET reports the resolved
* value rather than leaving the client to guess the default.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findUnique: vi.fn(),
updateMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
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/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
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, PUT } from "../webhook/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 },
};
type Route = (request: Request) => Promise<Response>;
function put(body: unknown): Promise<Response> {
return (PUT as Route)(
new Request("http://localhost/api/settings/webhook", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function get(): Promise<Response> {
return (GET as Route)(new Request("http://localhost/api/settings/webhook"));
}
function stored(): string {
const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0];
return call.create.config as string;
}
function existing(config: Record<string, unknown>) {
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
enabled: true,
config: `encrypted:${JSON.stringify(config)}`,
} as never);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never);
});
describe("PUT /api/settings/webhook — payload format", () => {
it("stores the Gotify choice", async () => {
const response = await put({
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify",
enabled: true,
});
expect(response.status).toBe(200);
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({
url: "https://gotify.example.com/message",
headerName: "X-Gotify-Key",
headerValue: "AbCdEfGh123",
format: "gotify",
});
});
it("writes nothing for generic, so the stored config keeps its old shape", async () => {
await put({
url: "https://relay.example.com/hook",
format: "generic",
enabled: true,
});
expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}');
});
it("keeps a stored Gotify choice when the body omits the field", async () => {
existing({
url: "https://gotify.example.com/message",
headerValue: "AbCdEfGh123",
format: "gotify",
});
await put({
url: "https://gotify.example.com/message",
headerValue: "NewToken456",
enabled: true,
});
expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({
headerValue: "NewToken456",
format: "gotify",
});
});
it("switches a Gotify channel back to generic when asked", async () => {
existing({ url: "https://gotify.example.com/message", format: "gotify" });
await put({
url: "https://gotify.example.com/message",
format: "generic",
enabled: true,
});
expect(stored()).toBe(
'encrypted:{"url":"https://gotify.example.com/message"}',
);
});
it("refuses a format it does not know", async () => {
const response = await put({
url: "https://relay.example.com/hook",
format: "discord",
enabled: true,
});
expect(response.status).toBe(422);
expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled();
});
});
describe("GET /api/settings/webhook — payload format", () => {
it("reports generic for a config saved before the choice existed", async () => {
existing({ url: "https://relay.example.com/hook" });
const json = await (await get()).json();
expect(json.data.format).toBe("generic");
});
it("reports the stored Gotify choice", async () => {
existing({ url: "https://gotify.example.com/message", format: "gotify" });
const json = await (await get()).json();
expect(json.data.format).toBe("gotify");
});
it("reports generic when no channel exists", async () => {
const json = await (await get()).json();
expect(json.data).toEqual({
enabled: false,
url: "",
headerName: "",
hasHeaderValue: false,
format: "generic",
});
});
});
@@ -1,119 +0,0 @@
/**
* v1.16.11 — reminder-thresholds GET: the operator-level late / missed
* minutes ride along unchanged; the new `lowStockRunwayDays` field is
* PER-USER (from `notificationPrefs.medication`), so the endpoint
* reflects whatever the notification-prefs PATCH persisted — the read
* half of the settings round-trip.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
appSettings: {
findUnique: 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 mkGet(): Request {
return new Request("http://localhost/api/settings/reminder-thresholds");
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
} as never);
});
describe("GET /api/settings/reminder-thresholds", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(401);
});
it("returns the default low-stock threshold (7) for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
lateMinutes: number;
missedMinutes: number;
lowStockRunwayDays: number | null;
reorderLeadDays: number;
};
};
expect(env.data).toEqual({
lateMinutes: 120,
missedMinutes: 240,
lowStockRunwayDays: 7,
// v1.17.0 — the per-user reorder lead default rides along.
reorderLeadDays: 10,
});
});
it("reflects the per-user threshold persisted by the prefs PATCH", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: 21 } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(21);
});
it("reflects the OFF state as null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { lowStockRunwayDays: null } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(mkGet());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { lowStockRunwayDays: number | null };
};
expect(env.data.lowStockRunwayDays).toBe(null);
});
});
@@ -1,150 +0,0 @@
/**
* The webhook test button against a Discord webhook URL, through the real
* sender with only the network mocked.
*
* The mocked target answers the way Discord does: 400 with code 50006
* ("Cannot send an empty message") for a body without `content` or `embeds`,
* 204 otherwise. The generic body used to carry neither, so the test button
* and every reminder failed against a Discord URL the settings card names.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const safeFetchMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
SafeFetchError: class SafeFetchError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: 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: 4,
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 { POST as postTest } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const DISCORD_URL =
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
/** Answers the way Discord's execute-webhook endpoint does. */
function discordAnswer(_url: string, init: { body: string }): Response {
const body = JSON.parse(init.body) as Record<string, unknown>;
const content = typeof body.content === "string" ? body.content : "";
if (content.trim().length === 0 && body.embeds === undefined) {
return new Response(
JSON.stringify({ message: "Cannot send an empty message", code: 50006 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (content.length > 2000) {
return new Response(
JSON.stringify({ message: "Invalid Form Body", code: 50035 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
return new Response(null, { status: 204 });
}
const POST = postTest as (request: Request) => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" },
} as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`,
} as never);
safeFetchMock.mockImplementation(
async (url: string, init: { body: string }) => discordAnswer(url, init),
);
});
describe("POST /api/settings/webhook/test — a Discord webhook URL", () => {
it("delivers: the generic body carries the content Discord requires", async () => {
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect((await response.json()).data).toEqual({ sent: true });
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const [url, init] = safeFetchMock.mock.calls[0] as [
string,
{ body: string },
];
expect(url).toBe(DISCORD_URL);
const sent = JSON.parse(init.body);
expect(sent.content).toBe(
"HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.",
);
expect(sent.allowed_mentions).toEqual({ parse: [] });
});
it("passes Discord's own error through when the target still refuses", async () => {
safeFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
{ status: 404, headers: { "Content-Type": "application/json" } },
),
);
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta.upstreamStatus).toBe(404);
expect(json.meta.upstreamBody).toContain("Unknown Webhook");
});
});
@@ -1,102 +0,0 @@
/**
* `DELETE /api/strava/credentials` — the teardown always leaves a trail.
*
* The audit row and the ledger park used to run only when an access token
* happened to be present, so removing a stored credential pair that had never
* completed OAuth was invisible afterwards: no audit row, and a ledger left at
* whatever state it last held. The Polar twin has always done both
* unconditionally. Strava had no test file at all, which is why this one
* exists.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
const { storeMock, clearMock } = vi.hoisted(() => ({
storeMock: vi.fn(),
clearMock: vi.fn(),
}));
vi.mock("@/lib/strava/credentials", async (importOriginal) => ({
STRAVA_DISCONNECTED: (
await importOriginal<typeof import("@/lib/strava/credentials")>()
).STRAVA_DISCONNECTED,
storeStravaClientCredentials: storeMock,
clearStravaClientCredentials: clearMock,
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(),
}));
import { DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
beforeEach(() => {
vi.clearAllMocks();
userFind.mockResolvedValue(null);
userUpdate.mockResolvedValue({});
clearMock.mockResolvedValue(undefined);
});
describe("DELETE /api/strava/credentials", () => {
const del = DELETE as unknown as () => Promise<RouteResult>;
it("audits + marks disconnected when a live token was present", async () => {
userFind.mockResolvedValue({ stravaAccessTokenEncrypted: "enc:tok" });
const res = await del();
expect(res.data).toEqual({ deleted: true });
expect(clearMock).toHaveBeenCalledWith("u1");
expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "strava");
});
it("audits + marks disconnected even when nothing was connected", async () => {
userFind.mockResolvedValue({ stravaAccessTokenEncrypted: null });
const res = await del();
expect(res.data).toEqual({ deleted: true });
expect(clearMock).toHaveBeenCalledWith("u1");
expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "strava");
});
it("clears the athlete id and the backfill state alongside both tokens", async () => {
await del();
// The backfill markers belong to the account that was connected; a later
// connection must start its own history walk from the newest activity.
expect(userUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: {
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
stravaBackfillCompletedAt: null,
stravaBackfillResumeBefore: null,
},
}),
);
});
});
@@ -1,71 +0,0 @@
/**
* `POST /api/strava/disconnect` leaves nothing of the connected account behind
* that a later connection would inherit: not the tokens, and not the backfill
* markers. A resume point left standing made the next account's first walk
* start below the old account's oldest activity, so its newer history was
* never asked for.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
update: vi.fn(),
findUnique: vi.fn(async () => null),
findMany: vi.fn(async () => []),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => null,
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/strava/client", () => ({ deauthorize: vi.fn() }));
vi.mock("@/lib/strava/credentials", async (importOriginal) => ({
STRAVA_DISCONNECTED: (
await importOriginal<typeof import("@/lib/strava/credentials")>()
).STRAVA_DISCONNECTED,
getStravaConnection: vi.fn(async () => ({ accessToken: "tok" })),
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
userUpdate.mockResolvedValue({});
});
describe("POST /api/strava/disconnect", () => {
it("clears the backfill markers with the tokens", async () => {
const res = (await (POST as unknown as () => Promise<{ data: unknown }>)())
.data;
expect(res).toEqual({ disconnected: true });
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
data: {
stravaAccessTokenEncrypted: null,
stravaRefreshTokenEncrypted: null,
stravaAthleteId: null,
stravaBackfillCompletedAt: null,
stravaBackfillResumeBefore: null,
},
});
});
});
@@ -1,104 +0,0 @@
/**
* v1.32.28 — manual Strava sync. Incremental by nature (Strava carries a real
* cursor); no full-history arm is exposed here, that stays with the backfill.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/strava/sync", () => ({ syncUserStrava: vi.fn() }));
import { POST } from "../route";
import { HttpError, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { syncUserStrava } from "@/lib/strava/sync";
function request(): NextRequest {
return new NextRequest("http://localhost/api/strava/sync", {
method: "POST",
});
}
// The route takes no arguments (no body, no flags); the alias keeps the
// direct invoke typed while still handing apiHandler a real request.
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
async function envelope(response: Response) {
return (await response.json()) as {
data: { imported?: number } | null;
error: string | null;
meta?: { errorCode?: string };
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
});
describe("POST /api/strava/sync", () => {
it("syncs the session user and answers with the imported count", async () => {
vi.mocked(syncUserStrava).mockResolvedValue(4);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 4, failed: false, outcome: "success" },
error: null,
});
expect(syncUserStrava).toHaveBeenCalledWith("u1");
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const response = await post(request());
expect(response.status).toBe(401);
expect(syncUserStrava).not.toHaveBeenCalled();
});
it("refuses past the per-user rate limit without touching the sync", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(429);
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(syncUserStrava).not.toHaveBeenCalled();
expect(checkRateLimit).toHaveBeenCalledWith("strava-sync:u1", 5, 60_000);
});
it("answers 502 generically when the sync throws", async () => {
vi.mocked(syncUserStrava).mockRejectedValue(
new Error("strava said: token abc123 is invalid"),
);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(502);
expect(body.error).toBe("Strava sync failed");
expect(JSON.stringify(body)).not.toContain("abc123");
});
});
-179
View File
@@ -1,179 +0,0 @@
/**
* `/api/tokens` — the instance-wide API switch must not hide live credentials.
*
* `AppSettings.apiGlobal` gates the surfaces a token is FOR — external
* medication ingest, the MCP bridge — and not a token's ability to
* authenticate: nothing on the `requireAuth` path consults it, which the last
* case here asserts directly rather than assuming. So a token minted before an
* operator flipped the switch stays a live credential while it is off, and the
* LIST used to answer 403 — hiding exactly the credentials their owner most
* needed to see, including the wildcard access token their own phone signs in
* with.
*
* The read is ungated now. The revoke is not, and that asymmetry is pinned
* below rather than left to drift: it is a decision about what the switch
* means, and a change to it should be a visible diff here.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireAuth: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() }));
import { GET } from "../route";
import { DELETE } from "../[id]/route";
import { requireAuth } from "@/lib/api-handler";
import { prisma } from "@/lib/db";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { readSource } from "@/__tests__/helpers/source-files";
const USER = { id: "user-1", username: "u" };
const ROW = {
id: "tok-1",
name: "iPhone",
permissions: ["*"],
lastUsedAt: new Date("2026-08-01T10:00:00Z"),
expiresAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
revoked: false,
};
type ListEnvelope = {
data: Array<{ id: string }> | null;
error: string | null;
};
const params = Promise.resolve({ id: "tok-1" });
type DeleteHandler = (
r: Request,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
vi.mocked(prisma.apiToken.findMany).mockResolvedValue([ROW] as never);
});
describe("GET /api/tokens", () => {
it("lists the caller's tokens while the operator has the API switched off", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as ListEnvelope;
expect(env.error).toBeNull();
expect(env.data?.[0]?.id).toBe("tok-1");
expect(prisma.apiToken.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: USER.id } }),
);
});
it("does not consult the switch at all", async () => {
// Stronger than the status assertion above and the reason it is separate:
// a future re-gating that happened to answer 200 anyway would still be a
// read whose behaviour depends on an operator toggle it has no business
// reading.
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
await (GET as unknown as () => Promise<Response>)();
expect(isApiGloballyEnabled).not.toHaveBeenCalled();
});
it("still scopes the list to the caller", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
await (GET as unknown as () => Promise<Response>)();
const args = vi.mocked(prisma.apiToken.findMany).mock.calls[0][0];
expect(args?.where).toEqual({ userId: USER.id });
// No `revoked: false` filter — revoked rows stay in the list, which is
// what the published contract says and what the UI relies on.
expect(JSON.stringify(args?.where)).not.toContain("revoked");
});
});
describe("DELETE /api/tokens/{id}", () => {
it("keeps refusing while the operator has the API switched off", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await (DELETE as unknown as DeleteHandler)(
new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }),
{ params },
);
expect(res.status).toBe(403);
expect(prisma.apiToken.update).not.toHaveBeenCalled();
});
it("revokes when the switch is on", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
...ROW,
userId: USER.id,
} as never);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
const res = await (DELETE as unknown as DeleteHandler)(
new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }),
{ params },
);
expect(res.status).toBe(200);
expect(prisma.apiToken.update).toHaveBeenCalledWith({
where: { id: "tok-1" },
data: { revoked: true },
});
});
it("does not reach another account's token", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
...ROW,
userId: "someone-else",
} as never);
const res = await (DELETE as unknown as DeleteHandler)(
new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }),
{ params },
);
expect(res.status).toBe(404);
expect(prisma.apiToken.update).not.toHaveBeenCalled();
});
});
describe("the switch and the authentication path", () => {
it("is not consulted by requireAuth, which is why the read must not gate on it", async () => {
// The premise the fix rests on, asserted rather than assumed: if the auth
// kit ever started refusing Bearer callers while `apiGlobal` is off, then
// a token would NOT be live during the outage and hiding the list would
// stop being wrong. This reads the source because the behaviour is an
// absence, and an absence has no call to spy on.
const source = readSource(
"src/lib/api-handler.ts",
"export async function requireAuth(",
);
expect(source).not.toContain("isApiGloballyEnabled");
expect(source).not.toContain("apiGlobal");
});
});
@@ -1,311 +0,0 @@
/**
* Who may call `POST /api/workouts/batch` (#1054).
*
* The route names `workouts:write`, so the set of credentials it admits is:
* a cookie session, a wildcard (`["*"]`) token, and a narrow token carrying
* exactly that scope. Every other narrow token — `measurements:write`
* included, however adjacent it looks — is refused 403 by the fail-closed
* default.
*
* The real `requireAuth` and the real `resolveBearerToken` run here; only the
* token row lookup is stubbed, so the admission decision under test is the
* production one rather than a re-implementation of it.
*
* The second half pins what a scoped caller writes: its rows are `EXTERNAL`,
* resolved from the credential, and a body naming any source is refused 422
* rather than relabelled. A session and a wildcard token keep the source they
* send.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
workout: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
workoutRoute: { createMany: vi.fn() },
workoutSamples: { createManyAndReturn: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { workout: unknown });
}
}),
},
}));
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() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../batch/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { headers } from "next/headers";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
const USER = {
id: "user-1",
username: "tester",
role: "USER" as const,
sourcePriorityJson: null,
};
const TOKEN = "hlk_" + "c".repeat(64);
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function workout(
externalId: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
sportType: "running",
startedAt: "2026-09-20T06:30:00.000Z",
endedAt: "2026-09-20T07:15:00.000Z",
externalId,
...overrides,
};
}
/** Present `TOKEN` as a Bearer whose row carries `permissions`. */
function armToken(permissions: string[]) {
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(headers).mockResolvedValue({
get: (name: string) =>
name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null,
} as never);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: USER.id,
permissions,
revoked: false,
expiresAt: new Date(Date.now() + 86_400_000),
} as never);
}
function armSession() {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: USER,
} as never);
}
/** The rows the route handed to `INSERT ... RETURNING`. */
function insertedRows(): Array<Record<string, unknown>> {
return vi
.mocked(prisma.workout.createManyAndReturn)
.mock.calls.flatMap(([args]) => {
const data = (args as { data: unknown }).data;
return (Array.isArray(data) ? data : [data]) as Array<
Record<string, unknown>
>;
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(64));
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
// The refusal path chains `.catch` on the audit write, so the reset mock
// has to hand back a promise again.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
// One row serves both lookups: the resolver's user fetch and the route's
// source-priority read.
vi.mocked(prisma.user.findUnique).mockResolvedValue(USER as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createManyAndReturn).mockImplementation(
(async (args: { data: Array<Record<string, unknown>> }) =>
args.data.map((row, index) => ({
id: `w-${index}`,
source: row.source,
externalId: row.externalId,
}))) as never,
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("POST /api/workouts/batch — which credentials it admits", () => {
it("admits a narrow workouts:write token", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.inserted).toBe(1);
});
it("still admits a cookie session", async () => {
armSession();
const res = await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(res.status).toBe(200);
});
it("still admits a wildcard token", async () => {
armToken(["*"]);
const res = await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(res.status).toBe(200);
});
it.each([
["measurements:write", [MEASUREMENTS_WRITE_SCOPE]],
["documents:write", [DOCUMENTS_WRITE_SCOPE]],
["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]],
["health:read", ["health:read"]],
])("refuses a %s token with 403 and writes nothing", async (_, perms) => {
armToken(perms);
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
expect(res.status).toBe(403);
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — rate limit buckets", () => {
function lastBucket(): string {
const calls = vi.mocked(checkRateLimit).mock.calls;
return calls[calls.length - 1][0] as string;
}
it("counts a workouts:write token apart from the phone", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
await POST(makeRequest({ workouts: [workout("s-1")] }));
const bridge = lastBucket();
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
);
const phone = lastBucket();
expect(bridge).not.toBe(phone);
expect(bridge).toContain(USER.id);
expect(phone).toContain(USER.id);
});
it("keeps a session and a wildcard token in the same bucket", async () => {
armSession();
await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
const session = lastBucket();
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
);
expect(lastBucket()).toBe(session);
});
});
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
it("attributes its rows to EXTERNAL", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
// Two sessions of the day: EXTERNAL ranks on the workout ladder, so two
// rows of one session would land once.
const res = await POST(
makeRequest({
workouts: [
workout("s-1"),
workout("s-2", {
startedAt: "2026-09-20T18:00:00.000Z",
endedAt: "2026-09-20T18:45:00.000Z",
}),
],
}),
);
expect(res.status).toBe(200);
const rows = insertedRows();
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.source)).toEqual(["EXTERNAL", "EXTERNAL"]);
});
it("probes for duplicates in the EXTERNAL namespace", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
await POST(makeRequest({ workouts: [workout("s-1")] }));
const probe = vi
.mocked(prisma.workout.findMany)
.mock.calls.map(([args]) => args as { where?: { OR?: unknown } })
.find((args) => args.where?.OR !== undefined);
expect(probe?.where?.OR).toEqual([
{ source: "EXTERNAL", externalId: "s-1" },
]);
});
it.each(["APPLE_HEALTH", "MANUAL"])(
"refuses a body naming %s with 422 and writes nothing",
async (source) => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(
makeRequest({
workouts: [workout("s-1"), workout("s-2", { source })],
}),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("workout.batch.source_not_permitted");
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
},
);
it("leaves the source a wildcard caller sends alone", async () => {
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(insertedRows().map((r) => r.source)).toEqual(["APPLE_HEALTH"]);
});
it("leaves the source a session sends alone", async () => {
armSession();
await POST(
makeRequest({ workouts: [workout("s-1", { source: "MANUAL" })] }),
);
expect(insertedRows().map((r) => r.source)).toEqual(["MANUAL"]);
});
});
@@ -1,213 +0,0 @@
/**
* Unit suite for `DELETE /api/workouts/{id}`.
*
* Pins:
* - a workout entered by hand is removed, with the personal records keyed
* on it, and a silent detection pass re-derives the previous best;
* - a synced workout answers 409 and nothing is touched, because the next
* sync would write it back;
* - another user's row and a missing row answer 404;
* - no session answers 401.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const tx = {
$queryRaw: vi.fn(async () => []),
personalRecord: { deleteMany: vi.fn() },
workout: { delete: vi.fn() },
};
vi.mock("@/lib/db", () => ({
prisma: {
workout: { findUnique: vi.fn() },
$transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)),
},
}));
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/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: 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 "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
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 MANUAL_ROW = {
id: "w-1",
userId: "user-1",
source: "MANUAL" as const,
externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666",
startedAt: new Date("2026-09-01T07:00:00Z"),
sportType: "running",
};
function del(id = "w-1") {
return DELETE(
new NextRequest(`http://localhost/api/workouts/${id}`, {
method: "DELETE",
}),
{ params: Promise.resolve({ id }) },
);
}
describe("DELETE /api/workouts/{id}", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 });
tx.workout.delete.mockResolvedValue({ id: "w-1" });
});
it("removes a workout entered by hand", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
const res = await del();
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ data: { deleted: true }, error: null });
expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } });
expect(auditLog).toHaveBeenCalledWith(
"workout.delete",
expect.objectContaining({ userId: "user-1" }),
);
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", {
evict: true,
});
});
it("takes the personal records the workout set with it and re-derives the best", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 });
const res = await del();
expect(res.status).toBe(200);
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
metricSlot: { not: null },
source: "MANUAL",
externalId: MANUAL_ROW.externalId,
},
});
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
it("keys a record without an external id on the session's start", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
externalId: null,
} as never);
await del();
expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
source: "MANUAL",
// Only the running slots: a cycling record set at the same instant
// is not this workout's.
metricSlot: {
in: [
"longest_run_duration",
"longest_distance_run",
"fastest_5km_time",
],
},
externalId: null,
achievedAt: MANUAL_ROW.startedAt,
},
});
// Nothing was removed, so nothing needs re-deriving.
expect(enqueuePrDetection).not.toHaveBeenCalled();
});
it("takes the personal-records lock before it deletes anything", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never);
await del();
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0];
expect(lockOrder).toBeLessThan(
tx.personalRecord.deleteMany.mock.invocationCallOrder[0],
);
expect(lockOrder).toBeLessThan(
tx.workout.delete.mock.invocationCallOrder[0],
);
});
it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])(
"refuses a %s workout with 409 and touches nothing",
async (source) => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
source,
} as never);
const res = await del();
expect(res.status).toBe(409);
const body = await res.json();
expect(body.data).toBeNull();
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.workout.delete).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
},
);
it("answers 404 for another user's workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...MANUAL_ROW,
userId: "someone-else",
} as never);
const res = await del();
expect(res.status).toBe(404);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("answers 404 when there is no such workout", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue(null);
const res = await del("missing");
expect(res.status).toBe(404);
});
it("answers 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await del();
expect(res.status).toBe(401);
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
});
@@ -1,774 +0,0 @@
/**
* Unit suite for `GET /api/workouts/{id}` — single-workout detail.
*
* v1.4.32 — paired with the list endpoint so the iOS detail screen +
* the `/insights/workouts/[id]` web page consume one envelope per
* workout. The test pins:
* - 200 + iOS-contract field names for a row the caller owns;
* - 401 without a session;
* - 404 (existence-channel sealed) when the row belongs to another
* user;
* - 404 when no row exists;
* - `canonicalId` resolves to the cluster winner — the requested id
* when the row is the canonical pick, the winner id when it is a
* non-canonical twin;
* - the optional `route` field flows through GeoJSON geometry when a
* `WorkoutRoute` row is attached, and is null when missing;
* - `aiInsight` is a pure READ: it serves a stored paragraph when one
* exists, null when none does, and never generates one;
* - `dayKey` dates the session in the user's timezone, so a session that
* starts late in the UTC evening lands on the local day the person
* actually trained on.
*/
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
measurement: {
findMany: 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("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
return {
...actual,
resolveModuleMap: vi.fn(async () => ({})),
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
};
});
// The Activity Insight paragraph follows the `workoutInsights` capability.
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() }));
import { GET } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
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 makeRequest(): NextRequest {
return new NextRequest("http://localhost/api/workouts/w-1", {
method: "GET",
});
}
function makeParams(id: string) {
return { params: Promise.resolve({ id }) };
}
/**
* Session day, ten days before the test run. The detail route's HR-curve
* enrichment only reaches the pulse-window fallback inside the 90-day
* dense-intraday retention horizon (`DENSE_INTRADAY_RETENTION_DAYS`), so a
* fixed calendar day silently ages out of that branch and leaves the
* fallback assertions green without exercising it.
*/
const SESSION_DAY = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
const BASE_ROW = {
id: "w-1",
userId: "user-1",
sportType: "RUNNING",
startedAt: new Date(`${SESSION_DAY}T07:00:00Z`),
endedAt: new Date(`${SESSION_DAY}T07:30:00Z`),
durationSec: 1800,
totalDistanceM: 5000,
totalEnergyKcal: 320,
avgHeartRate: 145,
maxHeartRate: 170,
minHeartRate: 110,
stepCount: 5800,
elevationM: 12.5,
pauseDurationSec: null,
source: "APPLE_HEALTH" as const,
externalId: "ext-w-1",
externalSourceVersion: null,
metadata: { hkVersion: "1" },
createdAt: new Date(`${SESSION_DAY}T07:30:00Z`),
updatedAt: new Date(`${SESSION_DAY}T07:30:00Z`),
route: null,
insight: null,
};
describe("GET /api/workouts/{id}", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
} as never);
// #67 — the enrichment reads: the HR-curve pulse-window fallback
// (`measurement.findMany`) and the sport-context average (a SECOND
// `workout.findMany`). Default both to empty; each test's
// `mockResolvedValueOnce` still wins for the cluster call.
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
// clearAllMocks wipes the factory default; re-seed the gate to pass.
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
// The timezone resolver caches per user for 60 s in process; drop the
// entry so a test that pins a zone is not served the previous one.
invalidateUserTimezone(SESSION_OK.user.id);
});
it("returns the workout with iOS-contract field names", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe("w-1");
expect(body.data.distanceM).toBe(5000);
expect(body.data.activeEnergyKcal).toBe(320);
expect(body.data.avgHr).toBe(145);
expect(body.data.maxHr).toBe(170);
expect(body.data.canonicalId).toBe("w-1");
expect(body.data.route).toBeNull();
// Nothing older on record: absence, not a link to nowhere.
expect(body.data.previousWorkoutId).toBeNull();
});
it("points previousWorkoutId at the canonical session before this one", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany)
// 1) the deep-link cluster
.mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never)
// 2) the sport-context baseline
.mockResolvedValueOnce([] as never)
// 3) the older sessions, newest first
.mockResolvedValueOnce([
{
id: "w-0",
source: "APPLE_HEALTH",
startedAt: new Date("2026-05-13T07:00:00Z"),
sportType: "RUNNING",
durationSec: 1800,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
// The same session as seen by the second watch. It must never win
// the link — the list would never show it.
{
id: "w-0-twin",
source: "WITHINGS",
startedAt: new Date("2026-05-13T07:01:00Z"),
sportType: "RUNNING",
durationSec: 1800,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
{
id: "w-older",
source: "APPLE_HEALTH",
startedAt: new Date("2026-05-10T07:00:00Z"),
sportType: "RUNNING",
durationSec: 1800,
avgHeartRate: null,
maxHeartRate: null,
metadata: null,
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.previousWorkoutId).toBe("w-0");
});
it("dates the session by the user's timezone, not by UTC", async () => {
// 20:00 UTC is already the NEXT calendar day in Auckland. A client that
// sliced the ISO timestamp would read the session's UTC day and pull the wrong
// day's pulse, sleep and mood around the session.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
timezone: "Pacific/Auckland",
} as never);
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
startedAt: new Date(`${SESSION_DAY}T20:00:00Z`),
endedAt: new Date(`${SESSION_DAY}T20:30:00Z`),
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: new Date(`${SESSION_DAY}T20:00:00Z`),
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(res.status).toBe(200);
const nextLocalDay = new Date(
new Date(`${SESSION_DAY}T00:00:00Z`).getTime() + 24 * 60 * 60 * 1000,
)
.toISOString()
.slice(0, 10);
expect(body.data.dayKey).toBe(nextLocalDay);
});
it("enriches the Apple winner with only matched WHOOP HR and zones", async () => {
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.01, 49.01],
],
};
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
avgHeartRate: null,
maxHeartRate: null,
metadata: { hkVersion: "1", owner: "apple" },
route: {
id: "wr-apple",
geometry: routeGeometry,
sampleTimestamps: null,
createdAt: BASE_ROW.createdAt,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
avgHeartRate: null,
maxHeartRate: null,
metadata: { hkVersion: "1", owner: "apple" },
},
{
id: "w-whoop",
source: "WHOOP",
startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000),
sportType: "RUNNING",
avgHeartRate: 148,
maxHeartRate: 181,
metadata: {
zoneDurations: {
zone_one_milli: 60_000,
zone_two_milli: 300_000,
},
whoopWorkoutStrain: 12.3,
},
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(body.data).toMatchObject({
id: "w-1",
source: "APPLE_HEALTH",
canonicalId: "w-1",
avgHr: 148,
maxHr: 181,
route: { geometry: routeGeometry },
metadata: { hkVersion: "1", owner: "apple" },
});
expect(body.data.metadata).not.toHaveProperty("whoopWorkoutStrain");
expect(body.data.metadata).not.toHaveProperty("zoneDurations");
expect(body.data.zones.model).toBe("whoop");
expect(body.data.hrSeries).toBeNull();
});
it("does not overwrite the canonical winner's existing HR values", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
BASE_ROW as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
avgHeartRate: BASE_ROW.avgHeartRate,
maxHeartRate: BASE_ROW.maxHeartRate,
metadata: BASE_ROW.metadata,
},
{
id: "w-whoop",
source: "WHOOP",
startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000),
sportType: "RUNNING",
avgHeartRate: 155,
maxHeartRate: 190,
metadata: null,
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(body.data.avgHr).toBe(145);
expect(body.data.maxHr).toBe(170);
});
it("returns 401 without a session", async () => {
vi.mocked(getSession).mockResolvedValueOnce(null);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(401);
});
it("returns 403 module.disabled when the workouts module is off", async () => {
vi.mocked(requireModuleEnabled).mockResolvedValueOnce({
enabled: false,
response: new Response(
JSON.stringify({ data: null, error: "Module disabled" }),
{ status: 403 },
),
} as never);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(403);
// The detail query never runs once the gate refuses.
expect(prisma.workout.findUnique).not.toHaveBeenCalled();
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
userId: "user-2",
} as never);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(404);
});
it("returns 404 when no row exists", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(null);
const res = await GET(makeRequest(), makeParams("w-1"));
expect(res.status).toBe(404);
});
it("resolves canonicalId to the Apple Health twin when the requested row is the Withings sibling", async () => {
// Same 5-minute slot + sportType, but the caller asked for the
// Withings row. The picker's default ladder makes Apple Health win
// the cluster, so canonicalId points at the Apple row.
const withingsRow = {
...BASE_ROW,
id: "w-withings",
source: "WITHINGS" as const,
externalId: "withings-1",
};
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(
withingsRow as never,
);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-apple",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
{
id: "w-withings",
source: "WITHINGS",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-withings"));
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe("w-withings");
expect(body.data.canonicalId).toBe("w-apple");
});
it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => {
const startMs = BASE_ROW.startedAt.getTime();
const storedSamples = Array.from({ length: 40 }, (_, i) => ({
t: new Date(startMs + i * 45_000).toISOString(),
hr: 140 + (i % 6),
}));
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
// WHOOP device zones win → zones render even without profile age.
metadata: {
zoneDurations: {
zone_one_milli: 60_000,
zone_two_milli: 300_000,
zone_three_milli: 600_000,
zone_four_milli: 200_000,
zone_five_milli: 40_000,
},
},
route: {
id: "wr-1",
geometry: {
type: "LineString",
coordinates: [
[11, 49],
[11.01, 49.01],
],
},
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:01:00Z`,
],
createdAt: BASE_ROW.createdAt,
},
samples: { sampleCount: storedSamples.length, samples: storedSamples },
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
method: "GET",
}),
makeParams("w-1"),
);
const body = await res.json();
expect(res.status).toBe(200);
// Stored series wins → provenance is the native workout series.
expect(body.data.hrSeries.source).toBe("workout_series");
expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2);
// WHOOP device zones.
expect(body.data.zones.model).toBe("whoop");
// Additive keys present; reserved seam is null.
expect(body.data).toHaveProperty("splits");
expect(body.data).toHaveProperty("sportContext");
expect(body.data.aiInsight).toBeNull();
// compact=1 drops the raw blobs but keeps count + geometry.
expect(body.data.samples.sampleCount).toBe(40);
expect(body.data.samples.samples).toBeNull();
expect(body.data.route.geometry).not.toBeNull();
expect(body.data.route.sampleTimestamps).toBeNull();
});
it("exposes the WorkoutRoute geometry when present", async () => {
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.01, 49.01],
],
};
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
route: {
id: "wr-1",
geometry: routeGeometry,
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:01:00Z`,
],
createdAt: BASE_ROW.createdAt,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(makeRequest(), makeParams("w-1"));
const body = await res.json();
expect(body.data.route).not.toBeNull();
expect(body.data.route.geometry).toEqual(routeGeometry);
expect(body.data.route.sampleTimestamps).toEqual([
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:01:00Z`,
]);
});
describe("v1.39.4 — sealed track", () => {
const TEST_KEY = "c".repeat(64);
const routeGeometry = {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.005, 49.0],
[11.01, 49.0],
],
};
beforeEach(() => {
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
function mockRoute(route: Record<string, unknown>) {
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
route: {
id: "wr-1",
sampleTimestamps: [
`${SESSION_DAY}T07:00:00Z`,
`${SESSION_DAY}T07:02:00Z`,
`${SESSION_DAY}T07:04:00Z`,
],
createdAt: BASE_ROW.createdAt,
...route,
},
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
}
it("serves a sealed track in the same shape as a readable one, splits included", async () => {
mockRoute({ geometry: routeGeometry, geometryEncrypted: null });
const legacy = (
await (await GET(makeRequest(), makeParams("w-1"))).json()
).data;
mockRoute({
geometry: null,
geometryEncrypted: encryptRouteGeometry(routeGeometry),
});
const sealed = (
await (await GET(makeRequest(), makeParams("w-1"))).json()
).data;
expect(sealed.route).toEqual(legacy.route);
expect(sealed.route.geometry).toEqual(routeGeometry);
expect(sealed.splits).toEqual(legacy.splits);
});
it("prefers the ciphertext over a readable column on the same row", async () => {
mockRoute({
geometry: { type: "LineString", coordinates: [[0, 0]] },
geometryEncrypted: encryptRouteGeometry(routeGeometry),
});
const body = await (await GET(makeRequest(), makeParams("w-1"))).json();
expect(body.data.route.geometry).toEqual(routeGeometry);
});
it("selects the sealed column", async () => {
mockRoute({ geometry: routeGeometry, geometryEncrypted: null });
await GET(makeRequest(), makeParams("w-1"));
const args = vi.mocked(prisma.workout.findUnique).mock.calls.at(-1)?.[0];
expect(
(args as { include: { route: { select: Record<string, boolean> } } })
.include.route.select.geometryEncrypted,
).toBe(true);
});
});
});
/**
* The Activity-Insight read.
*
* The load-bearing property is what is ABSENT: this route has no enqueue, no
* provider resolution and no fallback. A workout with no stored row serves null
* and the page renders nothing — which is every workout that predates the
* feature, every re-synced one, and every one on a provider-less install.
*/
describe("GET /api/workouts/{id} — aiInsight", () => {
// A throwaway test key. The route decrypts a stored paragraph, so the suite
// needs a configured cipher; `_resetCryptoCacheForTests` keeps the stubbed
// key from leaking into the neighbouring describes.
const TEST_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("ENCRYPTION_KEY", TEST_KEY);
_resetCryptoCacheForTests();
vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
});
async function get() {
return GET(
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
method: "GET",
}),
makeParams("w-1"),
);
}
it("serves null when the workout has no stored paragraph", async () => {
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: null,
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
});
it("serves the decrypted paragraph when one exists", async () => {
const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec");
const generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`);
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: encryptToBytes("A steady, aerobic-leaning run."),
generatedAt,
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toEqual({
paragraph: "A steady, aerobic-leaning run.",
generatedAt: generatedAt.toISOString(),
});
expect(aiCapabilityToServe).toHaveBeenCalledWith(
"user-1",
"workoutInsights",
);
expect(body.data.ai).toEqual(AI_AVAILABLE);
});
it.each([
"operator_disabled",
"user_disabled",
"module_disabled",
"consent_required",
"no_provider",
] as const)(
"does not read or expose the stored paragraph while workoutInsights is %s",
async (reason) => {
vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason));
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([1, 2, 3]),
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
},
} as never);
const body = await (await get()).json();
expect(body.data.aiInsight).toBeNull();
expect(body.data.ai).toEqual(aiUnavailable(reason));
expect(prisma.workout.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
include: expect.objectContaining({ insight: false }),
}),
);
},
);
it("degrades to no card rather than failing the page on an undecryptable row", async () => {
// `decrypt` is fail-closed by design. A key rotated away must not turn the
// whole workout-detail page into a 500 over a garnish field.
vi.mocked(prisma.workout.findUnique).mockResolvedValue({
...BASE_ROW,
insight: {
paragraphEncrypted: new Uint8Array([0, 1, 2, 3]),
generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`),
},
} as never);
const res = await get();
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.aiInsight).toBeNull();
// The rest of the payload is untouched — the failure is scoped to the field.
expect(body.data.id).toBe("w-1");
expect(body.data.durationSec).toBe(1800);
});
afterEach(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* Encryption at rest against its laws, on random plaintexts, key maps and
* chunkings.
*
* The hand-written cases pin each refusal once; these say it for every
* input: whatever is sealed opens to the same bytes under the key it names,
* nothing that was tampered with opens at all, a rotation moves the key id
* and nothing else, and the streamed codec does not care how the plaintext
* was cut into chunks.
*/
import { Buffer } from "node:buffer";
import fc from "fast-check";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
_resetCryptoCacheForTests,
createStreamEncryptor,
decrypt,
decryptBytes,
decryptStream,
encrypt,
encryptBytes,
encryptUnderKeyId,
extractKeyId,
extractKeyIdFromBytes,
extractStreamKeyId,
reencryptBytesToActive,
reencryptToActive,
} from "../crypto";
import { assertProperty } from "../../../tests/property/fast-check";
const KEYS = {
v1: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
v2: "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
k2026_b: "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
} as const;
type KeyId = keyof typeof KEYS;
const keyIdArb = fc.constantFrom<KeyId>("v1", "v2", "k2026_b");
function useKeys(active: KeyId): void {
vi.stubEnv("ENCRYPTION_KEYS", JSON.stringify(KEYS));
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", active);
vi.stubEnv("ENCRYPTION_KEY", "");
_resetCryptoCacheForTests();
}
beforeEach(() => useKeys("v2"));
afterAll(() => {
vi.unstubAllEnvs();
_resetCryptoCacheForTests();
});
const textArb = fc.string({ unit: "grapheme", maxLength: 300 });
const bytesArb = fc.uint8Array({ maxLength: 2_000 }).map((a) => Buffer.from(a));
const aadArb = fc.option(fc.string({ maxLength: 40 }), { nil: undefined });
describe("string codec", () => {
it("opens what it sealed, under the active key it names", () => {
assertProperty(
fc.property(keyIdArb, textArb, (active, text) => {
useKeys(active);
const sealed = encrypt(text);
expect(extractKeyId(sealed)).toBe(active);
expect(decrypt(sealed)).toBe(text);
}),
);
});
it("never seals the same text the same way twice", () => {
assertProperty(
fc.property(textArb, (text) => {
expect(encrypt(text)).not.toBe(encrypt(text));
}),
);
});
it("opens a value sealed under any configured key, whichever key is active", () => {
assertProperty(
fc.property(keyIdArb, keyIdArb, textArb, (sealer, active, text) => {
useKeys(active);
const sealed = encryptUnderKeyId(text, sealer);
expect(decrypt(sealed)).toBe(text);
const rotated = reencryptToActive(sealed);
expect(extractKeyId(rotated)).toBe(active);
expect(decrypt(rotated)).toBe(text);
}),
);
});
it("refuses a sealed value with any one character of its payload changed", () => {
assertProperty(
fc.property(textArb, fc.nat(), fc.nat(), (text, at, pick) => {
const sealed = encrypt(text);
const dot = sealed.indexOf(".");
// A payload position, leaving the key id and the base64 padding alone.
const body = sealed.slice(dot + 1).replace(/=+$/, "");
const index = dot + 1 + (at % body.length);
const alphabet =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const original = sealed[index];
const replacement = alphabet.replace(original, "")[pick % 63];
const tampered =
sealed.slice(0, index) + replacement + sealed.slice(index + 1);
// A change confined to the unused low bits of the final base64
// character decodes to the same bytes; anything else must not open.
const sameBytes = Buffer.from(tampered.slice(dot + 1), "base64").equals(
Buffer.from(sealed.slice(dot + 1), "base64"),
);
if (sameBytes) return;
expect(() => decrypt(tampered)).toThrow();
}),
300,
);
});
});
describe("binary codec", () => {
it("opens what it sealed, with the same purpose label, under the key it names", () => {
assertProperty(
fc.property(keyIdArb, bytesArb, aadArb, (active, bytes, aad) => {
useKeys(active);
const sealed = encryptBytes(bytes, aad);
expect(extractKeyIdFromBytes(sealed)).toBe(active);
expect(decryptBytes(sealed, aad).equals(bytes)).toBe(true);
}),
);
});
it("refuses a different purpose label", () => {
assertProperty(
fc.property(
bytesArb,
fc.string({ maxLength: 20 }),
fc.string({ maxLength: 20 }),
(bytes, a, b) => {
fc.pre(a !== b);
const sealed = encryptBytes(bytes, a);
expect(() => decryptBytes(sealed, b)).toThrow();
},
),
);
});
it("never opens a payload with a flipped bit to anything but the original bytes", () => {
assertProperty(
fc.property(
bytesArb,
aadArb,
fc.nat(),
fc.integer({ min: 0, max: 7 }),
(bytes, aad, at, bit) => {
const sealed = encryptBytes(bytes, aad);
const flipped = Buffer.from(sealed);
const index = at % flipped.byteLength;
flipped[index] ^= 1 << bit;
// The ciphertext, IV and tag are authenticated, so a flip there
// throws. The key-id header is not: it only selects the key, and a
// flip of a key-id byte's top bit decodes to the same ASCII id.
// Either way nothing but the sealed bytes can come out.
let opened: Buffer;
try {
opened = decryptBytes(flipped, aad);
} catch {
return;
}
expect(index).toBeLessThan(2 + flipped[1]);
expect(opened.equals(bytes)).toBe(true);
},
),
300,
);
});
it("rotates to the active key without touching the bytes", () => {
assertProperty(
fc.property(
keyIdArb,
keyIdArb,
bytesArb,
aadArb,
(from, to, bytes, aad) => {
useKeys(from);
const sealed = encryptBytes(bytes, aad);
useKeys(to);
const rotated = reencryptBytesToActive(sealed, aad);
expect(extractKeyIdFromBytes(rotated)).toBe(to);
expect(decryptBytes(rotated, aad).equals(bytes)).toBe(true);
},
),
);
});
});
describe("streamed codec", () => {
it("does not care how the plaintext was cut into chunks", () => {
assertProperty(
fc.property(
keyIdArb,
fc.array(fc.uint8Array({ maxLength: 400 }), { maxLength: 12 }),
(active, chunks) => {
useKeys(active);
const encryptor = createStreamEncryptor();
let stored = encryptor.header;
for (const chunk of chunks)
stored += encryptor.update(Buffer.from(chunk));
stored += encryptor.final();
expect(extractStreamKeyId(stored)).toBe(active);
const expected = Buffer.concat(chunks.map((c) => Buffer.from(c)));
expect(decryptStream(stored).equals(expected)).toBe(true);
},
),
);
});
it("refuses a streamed value cut short", () => {
assertProperty(
fc.property(
fc.uint8Array({ minLength: 1, maxLength: 400 }),
fc.integer({ min: 4, max: 40 }),
(bytes, cut) => {
const encryptor = createStreamEncryptor();
const stored =
encryptor.header +
encryptor.update(Buffer.from(bytes)) +
encryptor.final();
const truncated = stored.slice(0, stored.length - cut);
expect(() => decryptStream(truncated)).toThrow();
},
),
);
});
});
@@ -0,0 +1,259 @@
/**
* Medication cadence and compliance against a naive oracle and against the
* laws a rate has to obey.
*
* The oracle walks one local calendar day at a time and asks the only
* question a person asks: is today one of my dose days, and how many doses
* does it hold? The engine answers the same question through recurrence
* rules, eras, bands and the zone's transitions; any disagreement on a random
* schedule, day and zone is a defect in one of the two, and the oracle is
* small enough to be obviously right.
*/
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
buildComplianceMedicationContext,
calculateCompliance,
expectedSlotCountForDay,
type ComplianceSchedule,
type IntakeEvent,
} from "@/lib/analytics/compliance";
import { shiftDateKey, weekdayOfDateKey } from "@/lib/tz/format";
import { localDayWindow } from "@/lib/tz/local-day";
import {
assertProperty,
zoneArb,
} from "../../../../../tests/property/fast-check";
const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const;
const dayKeyArb = fc
.date({
min: new Date("2025-01-01T00:00:00Z"),
max: new Date("2027-12-31T00:00:00Z"),
noInvalidDate: true,
})
.map((d) => d.toISOString().slice(0, 10));
const timeArb = fc
.tuple(fc.integer({ min: 0, max: 23 }), fc.constantFrom(0, 15, 30, 45))
.map(
([h, m]) => `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`,
);
const timesArb = fc.uniqueArray(timeArb, { minLength: 1, maxLength: 3 });
const weekdaysArb = fc.uniqueArray(fc.integer({ min: 0, max: 6 }), {
minLength: 1,
maxLength: 7,
});
function context(tz: string) {
return buildComplianceMedicationContext(
{
startsOn: null,
endsOn: null,
oneShot: false,
createdAt: new Date("2024-06-01T00:00:00Z"),
},
null,
tz,
);
}
function weeklySchedule(
weekdays: number[],
times: string[],
): ComplianceSchedule {
return {
windowStart: times[0],
windowEnd: times[0],
daysOfWeek: null,
rrule: `FREQ=WEEKLY;BYDAY=${weekdays.map((d) => BYDAY[d]).join(",")}`,
timesOfDay: [...times].sort(),
};
}
describe("expected doses per local day", () => {
it("a weekly schedule expects its doses exactly on its weekdays", () => {
assertProperty(
fc.property(
weekdaysArb,
timesArb,
dayKeyArb,
zoneArb,
(weekdays, times, key, tz) => {
const { dayStart, dayEnd } = localDayWindow(key, tz);
const count = expectedSlotCountForDay(
[weeklySchedule(weekdays, times)],
dayStart,
dayEnd,
context(tz),
);
const oracle = weekdays.includes(weekdayOfDateKey(key))
? times.length
: 0;
expect(count).toBe(oracle);
},
),
300,
);
});
it("a daily schedule expects every one of its times on every day", () => {
assertProperty(
fc.property(timesArb, dayKeyArb, zoneArb, (times, key, tz) => {
const { dayStart, dayEnd } = localDayWindow(key, tz);
const count = expectedSlotCountForDay(
[
{
windowStart: times[0],
windowEnd: times[0],
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: [...times].sort(),
},
],
dayStart,
dayEnd,
context(tz),
);
expect(count).toBe(times.length);
}),
300,
);
});
it("an as-needed schedule never expects a dose", () => {
assertProperty(
fc.property(timesArb, dayKeyArb, zoneArb, (times, key, tz) => {
const { dayStart, dayEnd } = localDayWindow(key, tz);
const count = expectedSlotCountForDay(
[
{
windowStart: times[0],
windowEnd: times[0],
daysOfWeek: null,
scheduleType: "PRN",
rrule: "FREQ=DAILY",
timesOfDay: [...times].sort(),
},
],
dayStart,
dayEnd,
context(tz),
);
expect(count).toBe(0);
}),
);
});
it("a week of a weekly schedule holds weekdays × times doses", () => {
assertProperty(
fc.property(
weekdaysArb,
timesArb,
dayKeyArb,
zoneArb,
(weekdays, times, firstKey, tz) => {
let total = 0;
for (let i = 0; i < 7; i++) {
const { dayStart, dayEnd } = localDayWindow(
shiftDateKey(firstKey, i),
tz,
);
total += expectedSlotCountForDay(
[weeklySchedule(weekdays, times)],
dayStart,
dayEnd,
context(tz),
);
}
expect(total).toBe(weekdays.length * times.length);
},
),
80,
);
});
});
/** A daily 08:00 schedule and a 14-day history with random outcomes. */
const historyArb = fc.array(fc.constantFrom("taken", "skipped", "nothing"), {
minLength: 14,
maxLength: 14,
});
const NOW = new Date("2026-03-15T12:00:00Z");
const DAILY: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: null,
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
];
function eventsFrom(outcomes: readonly string[]): IntakeEvent[] {
return outcomes.flatMap((outcome, i): IntakeEvent[] => {
const scheduledFor = new Date(
Date.UTC(2026, 2, 15 - (outcomes.length - 1) + i, 8, 0),
);
if (outcome === "nothing") return [];
return [
{
scheduledFor,
takenAt:
outcome === "taken"
? new Date(scheduledFor.getTime() + 10 * 60_000)
: null,
skipped: outcome === "skipped",
},
];
});
}
function rateOf(outcomes: readonly string[]) {
return calculateCompliance(eventsFrom(outcomes), DAILY, 14, undefined, {
now: NOW,
medicationContext: context("UTC"),
});
}
describe("the compliance rate", () => {
it("stays within 0 to 100 and never counts more doses than expected", () => {
assertProperty(
fc.property(historyArb, (outcomes) => {
const result = rateOf(outcomes);
expect(result.rate).toBeGreaterThanOrEqual(0);
expect(result.rate).toBeLessThanOrEqual(100);
expect(result.taken).toBeLessThanOrEqual(result.totalExpected);
expect(result.taken).toBe(outcomes.filter((o) => o === "taken").length);
}),
);
});
it("never falls when a missed dose is taken", () => {
assertProperty(
fc.property(
historyArb,
fc.integer({ min: 0, max: 13 }),
(outcomes, index) => {
fc.pre(outcomes[index] === "nothing");
const after = [...outcomes];
after[index] = "taken";
expect(rateOf(after).rate).toBeGreaterThanOrEqual(
rateOf(outcomes).rate,
);
},
),
);
});
it("reads 100 when every expected dose was taken and 0 when none was", () => {
expect(rateOf(Array(14).fill("taken")).rate).toBe(100);
expect(rateOf(Array(14).fill("nothing")).rate).toBe(0);
});
});
@@ -0,0 +1,165 @@
/**
* Unit conversions against their round-trip laws.
*
* What a person sees: they type a number in their own unit, it is stored in
* the canonical one, and the screen shows it back. The number shown must be
* the number typed, for every value they can type, in every unit the app
* offers. And a stored value shown in another unit must not wander by more
* than the display grain.
*/
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
applyDisplayTransformUnrounded,
getDisplayTransform,
getReadingTransform,
invertDisplayTransform,
TRANSFORMED_TYPES,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
convertGlucose,
mgdlToMmol,
mmolToMgdl,
toCanonicalMgdl,
} from "@/lib/glucose";
import { assertProperty } from "../../../../tests/property/fast-check";
const typeArb = fc.constantFrom(...[...TRANSFORMED_TYPES].sort());
const preferenceArb = fc.constantFrom<UnitPreference>("metric", "imperial");
const canonicalArb = fc.double({
min: -100,
max: 50_000,
noNaN: true,
noDefaultInfinity: true,
});
/** How the entry surfaces store a typed value: two decimals, canonical unit. */
function storeTyped(display: number, type: string, pref: UnitPreference) {
const raw = invertDisplayTransform(display, getDisplayTransform(type, pref));
return Math.round(raw * 100) / 100;
}
describe("metric and imperial display", () => {
it("inverts the unrounded transform exactly", () => {
assertProperty(
fc.property(typeArb, preferenceArb, canonicalArb, (type, pref, raw) => {
const t = getDisplayTransform(type, pref);
const back = invertDisplayTransform(
applyDisplayTransformUnrounded(raw, t),
t,
);
expect(Math.abs(back - raw)).toBeLessThanOrEqual(
1e-9 * Math.max(1, Math.abs(raw)),
);
}),
400,
);
});
it("rounds a displayed value to within half a display step", () => {
assertProperty(
fc.property(typeArb, preferenceArb, canonicalArb, (type, pref, raw) => {
const t = getDisplayTransform(type, pref);
const shown = applyDisplayTransform(raw, t);
const exact = applyDisplayTransformUnrounded(raw, t);
const step = 10 ** -t.decimals;
expect(Math.abs(shown - exact)).toBeLessThanOrEqual(step / 2 + 1e-9);
}),
400,
);
});
it("shows back exactly the value a person typed in their own unit", () => {
assertProperty(
fc.property(
typeArb,
preferenceArb,
fc.integer({ min: 0, max: 50_000 }),
(type, pref, steps) => {
const t = getDisplayTransform(type, pref);
// A value on the display grain, as a person types it.
const typed = steps / 10 ** t.decimals;
const stored = storeTyped(typed, type, pref);
expect(applyDisplayTransform(stored, t)).toBe(typed);
},
),
400,
);
});
it("converts a difference by the factor alone, never the offset", () => {
assertProperty(
fc.property(
typeArb,
preferenceArb,
canonicalArb,
canonicalArb,
(type, pref, a, b) => {
const t = getDisplayTransform(type, pref);
const viaValues =
applyDisplayTransformUnrounded(a, t) -
applyDisplayTransformUnrounded(b, t);
const step = 10 ** -t.decimals;
expect(
Math.abs(applyDisplayTransformDelta(a - b, t) - viaValues),
).toBeLessThanOrEqual(
step / 2 + 1e-6 * Math.max(1, Math.abs(viaValues)),
);
},
),
);
});
});
describe("blood glucose", () => {
it("shows back exactly the mmol/L value a person typed", () => {
assertProperty(
fc.property(fc.integer({ min: 10, max: 400 }), (tenths) => {
const typed = tenths / 10;
const stored = toCanonicalMgdl(typed, "mmol/L");
expect(Number.isInteger(stored)).toBe(true);
expect(mgdlToMmol(stored)).toBe(typed);
expect(convertGlucose(stored, "mmol/L")).toBe(typed);
expect(mmolToMgdl(typed)).toBe(stored);
}),
);
});
it("moves a stored mg/dL value by at most 1 through a mmol/L round trip", () => {
assertProperty(
fc.property(fc.integer({ min: 18, max: 720 }), (mgdl) => {
expect(
Math.abs(mmolToMgdl(mgdlToMmol(mgdl)) - mgdl),
).toBeLessThanOrEqual(1);
}),
);
});
it("prints the same figure through the reading transform as through convertGlucose", () => {
assertProperty(
fc.property(fc.integer({ min: 18, max: 720 }), (mgdl) => {
const t = getReadingTransform("BLOOD_GLUCOSE", {
system: "metric",
glucoseUnit: "mmol/L",
});
expect(applyDisplayTransform(mgdl, t)).toBe(
convertGlucose(mgdl, "mmol/L"),
);
}),
);
});
it("keeps mg/dL canonical: typing mg/dL stores the whole number typed", () => {
assertProperty(
fc.property(fc.integer({ min: 18, max: 720 }), (mgdl) => {
expect(toCanonicalMgdl(mgdl, "mg/dL")).toBe(mgdl);
expect(convertGlucose(mgdl, "mg/dL")).toBe(mgdl);
}),
);
});
});
+5 -1
View File
@@ -368,7 +368,11 @@ export function transformRescales(transform: DisplayTransform): boolean {
function roundToDecimals(value: number, decimals: number): number { function roundToDecimals(value: number, decimals: number): number {
if (!Number.isFinite(value)) return value; if (!Number.isFinite(value)) return value;
const scale = 10 ** decimals; const scale = 10 ** decimals;
return Math.round(value * scale) / scale; const rounded = Math.round(value * scale) / scale;
// A small negative value rounds to -0, and `Intl.NumberFormat` prints that
// as "-0" ("-0.0 °F" for a deviation of a few hundredths below zero, or
// for 0 °F typed and shown back). Zero has no sign on a screen.
return rounded === 0 ? 0 : rounded;
} }
/** /**
@@ -0,0 +1,340 @@
/**
* The statistics helpers against their invariants, on random samples.
*
* Not a second implementation: each property is a law any correct version
* obeys (order does not matter, shifting the data shifts the answer, a
* spread is never negative), plus agreement between the several medians the
* tree carries, which are separate code that has to say the same number.
*/
import fc from "fast-check";
import { describe, expect, it } from "vitest";
import { fitFromSums, linearFit, sumsOf } from "@/lib/stats/linear-fit";
import { standardDeviation, variance } from "@/lib/stats/spread";
import {
defaultHacLag,
fitSegmentedRegression,
MIN_HAC_LAG,
neweyWestLag,
} from "@/lib/stats/segmented-regression";
import { median as baselineMedian } from "@/lib/insights/derived/baseline";
import { median as calendarMedian } from "@/lib/activity/calendar-math";
import { median as cycleMedian } from "@/lib/cycle/prediction";
import { median as scoreMedian, mean } from "@/lib/analytics/score/shared";
import { percentile } from "@/lib/insights/strain-score";
import { quantile } from "@/lib/analytics/mood-prognosis/validate";
import { assertProperty } from "../../../../tests/property/fast-check";
/**
* Values on the scale and at the precision of health readings: hundredths
* between -1 000 and 10 000. Subnormal doubles are not readings, and near
* them the identities hold only to the last bit.
*/
const valueArb = fc
.integer({ min: -100_000, max: 1_000_000 })
.map((hundredths) => hundredths / 100);
const sampleArb = fc.array(valueArb, { minLength: 1, maxLength: 60 });
function close(a: number, b: number, scale = 1): void {
expect(Math.abs(a - b)).toBeLessThanOrEqual(1e-7 * Math.max(1, scale));
}
function magnitude(values: readonly number[]): number {
return values.reduce((m, v) => Math.max(m, Math.abs(v)), 1);
}
describe("medians and quantiles", () => {
it("every median in the tree says the same number", () => {
assertProperty(
fc.property(sampleArb, (values) => {
const expected = baselineMedian([...values]);
expect(cycleMedian(values)).toBe(expected);
expect(scoreMedian(values)).toBe(expected);
// The interpolating forms compute the middle pair as a + (b − a)/2
// rather than (a + b)/2, which can differ in the last bit.
const scale = magnitude(values);
close(calendarMedian(values)!, expected, scale);
close(percentile(values, 50), expected, scale);
close(quantile(values, 0.5), expected, scale);
}),
);
});
it("a median ignores order, sits between the extremes, and moves with a shift", () => {
assertProperty(
fc.property(
sampleArb,
fc.double({ min: -500, max: 500, noNaN: true }),
(values, shift) => {
const m = scoreMedian(values);
const reversed = [...values].reverse();
expect(scoreMedian(reversed)).toBe(m);
expect(m).toBeGreaterThanOrEqual(Math.min(...values));
expect(m).toBeLessThanOrEqual(Math.max(...values));
close(
scoreMedian(values.map((v) => v + shift)),
m + shift,
magnitude(values) + Math.abs(shift),
);
},
),
);
});
it("a quantile never decreases as the level rises", () => {
assertProperty(
fc.property(
sampleArb,
fc.double({ min: 0, max: 1, noNaN: true }),
fc.double({ min: 0, max: 1, noNaN: true }),
(values, a, b) => {
const [lo, hi] = a <= b ? [a, b] : [b, a];
expect(quantile(values, lo)).toBeLessThanOrEqual(
quantile(values, hi),
);
expect(quantile(values, 0)).toBe(Math.min(...values));
expect(quantile(values, 1)).toBe(Math.max(...values));
},
),
);
});
it("a mean sits between the extremes", () => {
assertProperty(
fc.property(sampleArb, (values) => {
const m = mean(values);
const eps = 1e-9 * magnitude(values);
expect(m).toBeGreaterThanOrEqual(Math.min(...values) - eps);
expect(m).toBeLessThanOrEqual(Math.max(...values) + eps);
}),
);
});
});
describe("spread", () => {
it("is never negative, ignores a shift, and scales with the data", () => {
assertProperty(
fc.property(
fc.array(valueArb, { minLength: 2, maxLength: 60 }),
fc.double({ min: -500, max: 500, noNaN: true }),
fc.double({ min: 0.01, max: 100, noNaN: true }),
(values, shift, scale) => {
const sd = standardDeviation(values, "population")!;
expect(sd).toBeGreaterThanOrEqual(0);
const tolerance = 1e-6 * (magnitude(values) + Math.abs(shift));
expect(
Math.abs(
standardDeviation(
values.map((v) => v + shift),
"population",
)! - sd,
),
).toBeLessThanOrEqual(tolerance);
expect(
Math.abs(
standardDeviation(
values.map((v) => v * scale),
"population",
)! -
sd * scale,
),
).toBeLessThanOrEqual(tolerance * scale);
},
),
);
});
it("puts the sample variance above the population variance by n/(n-1)", () => {
assertProperty(
fc.property(
fc.array(valueArb, { minLength: 2, maxLength: 60 }),
(values) => {
const n = values.length;
const population = variance(values, "population")!;
const sample = variance(values, "sample")!;
close(sample, (population * n) / (n - 1), magnitude(values) ** 2);
},
),
);
});
it("is undefined where the divisor would be zero", () => {
expect(variance([], "population")).toBeNull();
expect(variance([3], "sample")).toBeNull();
expect(variance([3], "population")).toBe(0);
});
});
/** Points with at least two distinct x values, so a slope exists. */
const pointsArb = fc
.array(
fc.record({
x: fc.integer({ min: -400, max: 400 }),
y: valueArb,
}),
{ minLength: 2, maxLength: 50 },
)
.filter((ps) => new Set(ps.map((p) => p.x)).size >= 2);
describe("least-squares line", () => {
it("does not depend on the order of the points", () => {
assertProperty(
fc.property(pointsArb, (points) => {
const fit = linearFit(
points.map((p) => p.x),
points.map((p) => p.y),
)!;
const reversed = [...points].reverse();
const again = linearFit(
reversed.map((p) => p.x),
reversed.map((p) => p.y),
)!;
close(again.slope, fit.slope, magnitude(points.map((p) => p.y)));
close(
again.intercept,
fit.intercept,
magnitude(points.map((p) => p.y)),
);
}),
);
});
it("recovers an exact line, and an affine change of y changes the fit the same way", () => {
assertProperty(
fc.property(
fc.uniqueArray(fc.integer({ min: -400, max: 400 }), {
minLength: 2,
maxLength: 40,
}),
fc.double({ min: -50, max: 50, noNaN: true }),
fc.double({ min: -1000, max: 1000, noNaN: true }),
(xs, slope, intercept) => {
const ys = xs.map((x) => slope * x + intercept);
const fit = linearFit(xs, ys)!;
const scale = Math.abs(intercept) + Math.abs(slope) * 400;
close(fit.slope, slope, scale);
close(fit.intercept, intercept, scale);
// The mean point lies on the fitted line.
close(fit.intercept + fit.slope * fit.meanX, fit.meanY, scale);
},
),
);
});
it("composes from stored sums exactly as from the points", () => {
assertProperty(
fc.property(pointsArb, (points) => {
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
expect(fitFromSums(sumsOf(xs, ys))).toEqual(linearFit(xs, ys));
}),
);
});
it("keeps r² within [0, 1] when y varies", () => {
assertProperty(
fc.property(pointsArb, (points) => {
const fit = linearFit(
points.map((p) => p.x),
points.map((p) => p.y),
)!;
if (fit.r2 === null) {
expect(fit.syy).toBe(0);
return;
}
expect(fit.r2).toBeGreaterThanOrEqual(0);
expect(fit.r2).toBeLessThanOrEqual(1 + 1e-9);
}),
);
});
it("refuses a vertical line instead of inventing a slope", () => {
assertProperty(
fc.property(
fc.integer({ min: -400, max: 400 }),
fc.array(valueArb, { minLength: 2, maxLength: 20 }),
(x, ys) => {
expect(
linearFit(
ys.map(() => x),
ys,
),
).toBeNull();
},
),
);
});
});
describe("segmented regression and its Newey-West lag", () => {
it("never lets the lag rule go down as the series grows, and applies at least a week", () => {
assertProperty(
fc.property(
fc.integer({ min: 5, max: 5000 }),
fc.integer({ min: 0, max: 5000 }),
(n, extra) => {
expect(neweyWestLag(n + extra)).toBeGreaterThanOrEqual(
neweyWestLag(n),
);
expect(defaultHacLag(n)).toBeGreaterThanOrEqual(MIN_HAC_LAG);
expect(defaultHacLag(n)).toBeGreaterThanOrEqual(neweyWestLag(n));
},
),
);
});
it("recovers a noiseless interrupted line with intervals that contain the estimates", () => {
assertProperty(
fc.property(
fc.integer({ min: 12, max: 60 }),
fc.integer({ min: 12, max: 60 }),
fc.double({ min: -5, max: 5, noNaN: true }),
fc.double({ min: -5, max: 5, noNaN: true }),
fc.double({ min: -20, max: 20, noNaN: true }),
fc.double({ min: -2, max: 2, noNaN: true }),
(before, after, intercept, preSlope, levelChange, slopeChange) => {
const points = [];
for (let t = -before; t < after; t++) {
const d = t >= 0 ? 1 : 0;
points.push({
t,
y:
intercept +
preSlope * t +
levelChange * d +
slopeChange * t * d,
});
}
const fit = fitSegmentedRegression(points)!;
expect(fit).not.toBeNull();
const scale = 100;
close(fit.intercept.estimate, intercept, scale);
close(fit.preSlope.estimate, preSlope, scale);
close(fit.levelChange.estimate, levelChange, scale);
close(fit.slopeChange.estimate, slopeChange, scale);
for (const c of [
fit.intercept,
fit.preSlope,
fit.levelChange,
fit.slopeChange,
]) {
expect(c.ciLow).toBeLessThanOrEqual(c.estimate);
expect(c.estimate).toBeLessThanOrEqual(c.ciHigh);
expect(c.standardError).toBeGreaterThanOrEqual(0);
}
},
),
60,
);
});
it("refuses a design with no points after the change", () => {
assertProperty(
fc.property(fc.array(valueArb, { minLength: 6, maxLength: 30 }), (ys) => {
const points = ys.map((y, i) => ({ t: -(i + 1), y }));
expect(fitSegmentedRegression(points)).toBeNull();
}),
);
});
});
@@ -1,59 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const findUnique = vi.fn();
const updateMany = vi.fn();
const getStravaCredentials = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: (...args: unknown[]) => findUnique(...args),
updateMany: (...args: unknown[]) => updateMany(...args),
},
},
}));
vi.mock("@/lib/crypto", () => ({
decrypt: (cipher: string) => `dec(${cipher})`,
encrypt: (plain: string) => `enc(${plain})`,
}));
vi.mock("../client", () => ({
getStravaCredentials: () => getStravaCredentials(),
}));
import { getStravaClientCredentials } from "../credentials";
describe("getStravaClientCredentials — DB-first then env", () => {
beforeEach(() => {
findUnique.mockReset();
getStravaCredentials.mockReset();
});
it("returns the per-user BYO pair when both columns are set", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: "enc-id",
stravaClientSecretEncrypted: "enc-secret",
});
const creds = await getStravaClientCredentials("user-1");
expect(creds).toEqual({
clientId: "dec(enc-id)",
clientSecret: "dec(enc-secret)",
});
expect(getStravaCredentials).not.toHaveBeenCalled();
});
it("falls back to the shared env app when the user has no BYO pair", async () => {
findUnique.mockResolvedValue({
stravaClientIdEncrypted: null,
stravaClientSecretEncrypted: null,
});
getStravaCredentials.mockReturnValue({
clientId: "env-id",
clientSecret: "env-secret",
});
const creds = await getStravaClientCredentials("user-1");
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
expect(getStravaCredentials).toHaveBeenCalledOnce();
});
});
@@ -1,211 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StravaWorkoutRow } from "../client";
const mocks = vi.hoisted(() => ({
fetchActivities: vi.fn(),
fetchActivityById: vi.fn(),
mapActivity: vi.fn(),
findUnique: vi.fn(),
updateMany: vi.fn(),
updateUser: vi.fn(),
createManyAndReturn: vi.fn(),
updateWorkout: vi.fn(),
getStravaConnection: vi.fn(),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: mocks.findUnique,
updateMany: mocks.updateMany,
update: mocks.updateUser,
},
workout: {
createManyAndReturn: mocks.createManyAndReturn,
update: mocks.updateWorkout,
},
},
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ addWarning: vi.fn() }),
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncFailure: mocks.recordSyncFailure,
recordSyncSuccess: mocks.recordSyncSuccess,
isReauthRequired: vi.fn(async () => false),
toFailureKind: vi.fn(() => "transient"),
markSyncFailureRecorded: <T>(err: T) => err,
}));
vi.mock("../credentials", () => ({
getStravaConnection: mocks.getStravaConnection,
getStravaClientCredentials: vi.fn(),
storeStravaTokens: vi.fn(),
}));
vi.mock("../client", () => ({
fetchActivities: mocks.fetchActivities,
fetchActivityById: mocks.fetchActivityById,
mapActivity: mocks.mapActivity,
refreshAccessToken: vi.fn(),
summaryHasHeartRate: vi.fn(() => true),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { syncUserStrava } from "../sync";
const previousCursor = new Date("2026-07-01T08:00:00.000Z");
const firstStart = new Date("2026-07-18T08:00:00.000Z");
const secondStart = new Date("2026-07-19T08:00:00.000Z");
const thirdStart = new Date("2026-07-20T08:00:00.000Z");
function workout(externalId: string, startedAt: Date): StravaWorkoutRow {
return {
externalId,
sportType: "running",
startedAt,
endedAt: new Date(startedAt.getTime() + 3_600_000),
durationSec: 3_600,
totalEnergyKcal: 500,
totalDistanceM: 10_000,
avgHeartRate: 145,
maxHeartRate: 170,
elevationM: 80,
metadata: {},
};
}
function activity(id: number, row: StravaWorkoutRow) {
return {
id,
start_date: row.startedAt.toISOString(),
row,
};
}
describe("syncUserStrava cursor durability", () => {
let cursor: Date | null;
let failExternalId: string | null;
let workouts: Map<string, StravaWorkoutRow>;
let backfillCompletedAt: Date | null;
beforeEach(() => {
vi.clearAllMocks();
cursor = previousCursor;
failExternalId = null;
workouts = new Map();
backfillCompletedAt = null;
const rows = [
workout("strava-1", firstStart),
workout("strava-2", secondStart),
workout("strava-3", thirdStart),
];
mocks.getStravaConnection.mockResolvedValue({
accessToken: "access-token",
refreshToken: "refresh-token",
refreshTokenCiphertext: Buffer.from("ciphertext"),
});
mocks.findUnique.mockImplementation(async () => ({
stravaLastActivityAt: cursor,
}));
mocks.fetchActivities.mockResolvedValue(
rows.map((row, index) => activity(index + 1, row)),
);
mocks.fetchActivityById.mockResolvedValue({});
mocks.mapActivity.mockImplementation((source) => source.row);
mocks.createManyAndReturn.mockImplementation(async ({ data }) => {
if (data.externalId === failExternalId) {
throw new Error("database unavailable");
}
if (workouts.has(data.externalId)) return [];
workouts.set(data.externalId, data);
return [{ id: `workout-${data.externalId}`, startedAt: data.startedAt }];
});
mocks.updateWorkout.mockImplementation(async ({ where, data }) => {
const externalId = where.userId_source_externalId.externalId;
const existing = workouts.get(externalId);
if (!existing) throw new Error("missing workout");
workouts.set(externalId, { ...existing, ...data });
return { id: `workout-${externalId}` };
});
mocks.updateMany.mockImplementation(async ({ data }) => {
cursor = data.stravaLastActivityAt;
return { count: 1 };
});
mocks.updateUser.mockImplementation(async ({ data }) => {
backfillCompletedAt = data.stravaBackfillCompletedAt;
return { id: "user-1" };
});
mocks.recordSyncFailure.mockResolvedValue(undefined);
mocks.recordSyncSuccess.mockResolvedValue(undefined);
});
it("leaves the previous cursor unchanged and rejects when a workout write fails", async () => {
failExternalId = "strava-2";
await expect(syncUserStrava("user-1")).rejects.toThrow(
"database unavailable",
);
expect(cursor).toEqual(previousCursor);
expect(mocks.updateMany).not.toHaveBeenCalled();
expect(workouts.has("strava-3")).toBe(true);
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
integration: "strava",
message: "database unavailable",
}),
);
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
});
it("leaves the backfill watermark unchanged when a workout write fails", async () => {
failExternalId = "strava-2";
await expect(runStravaBackfillForUser("user-1")).rejects.toThrow(
"database unavailable",
);
expect(backfillCompletedAt).toBeNull();
expect(mocks.updateUser).not.toHaveBeenCalled();
});
it("advances the backfill watermark after every workout is persisted", async () => {
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
imported: 3,
continued: false,
});
expect(workouts.size).toBe(3);
expect(backfillCompletedAt).toBeInstanceOf(Date);
expect(mocks.updateUser).toHaveBeenCalledTimes(1);
});
it("advances the cursor only after every fetched workout is persisted", async () => {
await expect(syncUserStrava("user-1")).resolves.toBe(3);
expect(workouts.size).toBe(3);
expect(cursor).toEqual(thirdStart);
expect(mocks.updateMany).toHaveBeenCalledTimes(1);
expect(mocks.recordSyncSuccess).toHaveBeenCalledWith("user-1", "strava");
});
it("replays by external identity without creating duplicate workouts", async () => {
await expect(syncUserStrava("user-1")).resolves.toBe(3);
await expect(syncUserStrava("user-1")).resolves.toBe(3);
expect(workouts.size).toBe(3);
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(6);
expect(mocks.updateWorkout).toHaveBeenCalledTimes(3);
expect(cursor).toEqual(thirdStart);
});
});
@@ -1,295 +0,0 @@
/**
* The Strava activity walk runs through `walkPages`, and the backfill reads
* its verdict (`integrations/page-walk.ts`): a walk that reaches its page
* ceiling with a full page last is truncated, and the backfill leaves its
* marker unset instead of stamping a history it did not finish. Before, the
* loop stopped at forty pages and the backfill stamped the account complete.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetchActivities: vi.fn(),
annotate: vi.fn(),
updateUser: vi.fn(),
findUser: vi.fn(),
isReauthRequired: vi.fn(),
send: vi.fn(),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: mocks.findUser,
updateMany: vi.fn(async () => ({ count: 1 })),
update: mocks.updateUser,
},
workout: {
createManyAndReturn: vi.fn(async ({ data }) => [
{ id: `w-${data.externalId}`, startedAt: data.startedAt },
]),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: mocks.annotate,
getEvent: () => ({ addWarning: vi.fn() }),
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
isReauthRequired: mocks.isReauthRequired,
toFailureKind: vi.fn(() => "transient"),
markSyncFailureRecorded: <T>(err: T) => err,
}));
vi.mock("../credentials", () => ({
getStravaConnection: vi.fn(async () => ({
accessToken: "access-token",
refreshToken: "refresh-token",
refreshTokenCiphertext: Buffer.from("ciphertext"),
})),
getStravaClientCredentials: vi.fn(),
storeStravaTokens: vi.fn(),
}));
vi.mock("../client", async (importOriginal) => ({
...(await importOriginal<typeof import("../client")>()),
fetchActivities: mocks.fetchActivities,
// Summaries carry HR; the detail fetch only fills calories.
fetchActivityById: vi.fn(async () => ({})),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => ({ send: mocks.send })),
}));
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { syncUserStrava } from "../sync";
const PER_PAGE = 100;
const BACKFILL_MAX_PAGES = 40;
const HOUR = 3_600_000;
const T0 = Date.UTC(2020, 0, 1);
/** Activity `id` of a history; a higher id started later. */
function activity(id: number) {
return {
id,
sport_type: "Run",
start_date: new Date(T0 + id * HOUR).toISOString(),
moving_time: 1800,
elapsed_time: 1900,
average_heartrate: 140,
};
}
/**
* A history of `total` activities served as Strava serves a walk without
* `after`: newest first, `before` (epoch seconds) cutting off the newer ones.
*/
function history(total: number) {
mocks.fetchActivities.mockImplementation(
async (
_t: string,
q: { page: number; perPage: number; before?: number },
) => {
const ids = Array.from({ length: total }, (_, i) => total - i).filter(
(id) => q.before === undefined || T0 + id * HOUR < q.before * 1000,
);
const from = (q.page - 1) * q.perPage;
return ids.slice(from, from + q.perPage).map(activity);
},
);
}
function truncatedAnnotations() {
return mocks.annotate.mock.calls.filter(
([e]) => e?.action?.name === "integration.page_walk.truncated",
);
}
/** The resume point the user row holds, as the backfill writes it. */
let resumeBefore: Date | null = null;
beforeEach(() => {
vi.clearAllMocks();
resumeBefore = null;
mocks.isReauthRequired.mockResolvedValue(false);
mocks.findUser.mockImplementation(async () => ({
stravaLastActivityAt: null,
stravaBackfillResumeBefore: resumeBefore,
}));
mocks.updateUser.mockImplementation(
async ({
data,
}: {
data: { stravaBackfillResumeBefore?: Date | null };
}) => {
if ("stravaBackfillResumeBefore" in data) {
resumeBefore = data.stravaBackfillResumeBefore ?? null;
}
return { id: "user-1" };
},
);
mocks.send.mockResolvedValue("job-next");
});
describe("Strava activity walk", () => {
it("reads pages until a short one and stops there", async () => {
history(2 * PER_PAGE + 7);
await expect(syncUserStrava("user-1", { fullSync: true })).resolves.toBe(
207,
);
expect(mocks.fetchActivities.mock.calls.map(([, q]) => q.page)).toEqual([
1, 2, 3,
]);
expect(truncatedAnnotations()).toEqual([]);
});
it("ends on an empty page without calling it truncated", async () => {
history(PER_PAGE);
await expect(syncUserStrava("user-1", { fullSync: true })).resolves.toBe(
PER_PAGE,
);
expect(mocks.fetchActivities).toHaveBeenCalledTimes(2);
expect(truncatedAnnotations()).toEqual([]);
});
it("reports a walk cut off at its ceiling", async () => {
history((BACKFILL_MAX_PAGES + 5) * PER_PAGE);
await syncUserStrava("user-1", { fullSync: true });
expect(mocks.fetchActivities).toHaveBeenCalledTimes(BACKFILL_MAX_PAGES);
expect(truncatedAnnotations()).toEqual([
[
expect.objectContaining({
meta: {
walk: "strava.activities",
pages: BACKFILL_MAX_PAGES,
reason: "ceiling",
},
}),
],
]);
});
});
describe("Strava backfill verdict", () => {
it("stamps the marker when the walk reached the start of the history", async () => {
history(3 * PER_PAGE - 1);
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
imported: 3 * PER_PAGE - 1,
continued: false,
});
expect(mocks.updateUser).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
stravaBackfillCompletedAt: expect.any(Date),
stravaBackfillResumeBefore: null,
},
});
expect(mocks.send).not.toHaveBeenCalled();
});
it("records where a cut-off walk got to and sends the next run after the rate window", async () => {
history((BACKFILL_MAX_PAGES + 1) * PER_PAGE);
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
imported: BACKFILL_MAX_PAGES * PER_PAGE,
continued: true,
});
// The oldest activity read is id 101; the next run asks for those before.
expect(resumeBefore).toEqual(new Date(T0 + 101 * HOUR + 1000));
expect(mocks.updateUser).not.toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stravaBackfillCompletedAt: expect.any(Date),
}),
}),
);
expect(mocks.send).toHaveBeenCalledWith(
"integration-backfill-admission",
expect.objectContaining({
kind: "strava-backfill",
data: expect.objectContaining({ userId: "user-1", slice: 1 }),
}),
expect.objectContaining({ startAfter: 15 * 60 }),
);
expect(mocks.annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: expect.objectContaining({ name: "strava.backfill.truncated" }),
}),
);
});
it("goes on from the recorded place instead of the newest page, and finishes", async () => {
const total = (BACKFILL_MAX_PAGES + 10) * PER_PAGE;
history(total);
await runStravaBackfillForUser("user-1");
const firstRun = mocks.fetchActivities.mock.calls.length;
mocks.fetchActivities.mockClear();
await expect(runStravaBackfillForUser("user-1", 1)).resolves.toEqual({
imported: 10 * PER_PAGE + 1,
continued: false,
});
expect(firstRun).toBe(BACKFILL_MAX_PAGES);
// Every page of the second run asked for activities before the place
// the first one recorded (the oldest it read was id 1001), and the walk
// ended at the oldest activity.
const queries = mocks.fetchActivities.mock.calls.map(([, q]) => q);
expect(new Set(queries.map((q) => q.before))).toEqual(
new Set([(T0 + 1001 * HOUR) / 1000 + 1]),
);
expect(queries.length).toBe(11);
expect(resumeBefore).toBeNull();
expect(mocks.updateUser).toHaveBeenLastCalledWith({
where: { id: "user-1" },
data: {
stravaBackfillCompletedAt: expect.any(Date),
stravaBackfillResumeBefore: null,
},
});
});
it("records nothing and sends nothing when a run got no further than the last", async () => {
history((BACKFILL_MAX_PAGES + 1) * PER_PAGE);
// A place older than anything the walk can read past: the provider kept
// handing back the same newest pages.
mocks.fetchActivities.mockImplementation(async (_t, q) =>
Array.from({ length: PER_PAGE }, (_, i) =>
activity(5_000 - (q.page - 1) * PER_PAGE - i),
),
);
resumeBefore = new Date(T0);
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
imported: BACKFILL_MAX_PAGES * PER_PAGE,
continued: false,
});
expect(resumeBefore).toEqual(new Date(T0));
expect(mocks.send).not.toHaveBeenCalled();
});
it("returns without walking or stamping while the connection waits for re-authorisation", async () => {
mocks.isReauthRequired.mockResolvedValue(true);
history(PER_PAGE);
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
imported: 0,
continued: false,
});
expect(mocks.fetchActivities).not.toHaveBeenCalled();
expect(mocks.updateUser).not.toHaveBeenCalled();
});
});
@@ -1,247 +1,14 @@
/** /**
* Strava's rate limit and its detail budget. * How long to wait after a Strava 429, read from the response's own headers.
* *
* - A 429 mid-walk used to discard everything the run had read: the error * What a rate limit does to a running sync (the activities read before it
* threw out of the walk before the upsert. The activities read before it * land, the cursor moves only as far as an oldest-first walk proves, the
* land now, the cursor moves only as far as an oldest-first walk proves * backfill waits out the window before its next run) is pinned against
* safe, the failure is booked as transient, and the backfill waits for * Postgres in `tests/integration/strava-sync.test.ts`.
* the window Strava's headers name before it goes on.
* - An update mapped from the summary alone (detail budget spent, or a
* detail read that failed) wrote `totalEnergyKcal: null` over the
* calories an earlier detail read had stored. The fields only the detail
* carries are kept when this run did not read it.
*/ */
import { beforeEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
const mocks = vi.hoisted(() => ({
fetchActivities: vi.fn(),
fetchActivityById: vi.fn(),
annotate: vi.fn(),
findUser: vi.fn(),
updateUser: vi.fn(),
updateManyUser: vi.fn(async (_arg: unknown) => ({ count: 1 })),
createManyAndReturn: vi.fn(),
updateWorkout: vi.fn(async (_arg: { data: Record<string, unknown> }) => ({})),
findWorkout: vi.fn(),
recordSyncFailure: vi.fn(async () => {}),
recordSyncSuccess: vi.fn(async () => {}),
isReauthRequired: vi.fn(async () => false),
send: vi.fn(async (..._args: unknown[]) => "job-next"),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: mocks.findUser,
updateMany: mocks.updateManyUser,
update: mocks.updateUser,
},
workout: {
createManyAndReturn: mocks.createManyAndReturn,
update: mocks.updateWorkout,
findUnique: mocks.findWorkout,
},
},
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: mocks.annotate,
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
}));
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
recordSyncFailure: mocks.recordSyncFailure,
recordSyncSuccess: mocks.recordSyncSuccess,
isReauthRequired: mocks.isReauthRequired,
}));
vi.mock("../credentials", () => ({
getStravaConnection: vi.fn(async () => ({
accessToken: "access-token",
refreshToken: "refresh-token",
refreshTokenCiphertext: Buffer.from("ciphertext"),
})),
getStravaClientCredentials: vi.fn(),
storeStravaTokens: vi.fn(),
}));
vi.mock("../client", async (importOriginal) => ({
...(await importOriginal<typeof import("../client")>()),
fetchActivities: mocks.fetchActivities,
fetchActivityById: mocks.fetchActivityById,
}));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => ({ send: mocks.send })),
}));
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
import { StravaApiError } from "../response-classifier";
import { stravaRateLimitRetryAfterSeconds } from "../client"; import { stravaRateLimitRetryAfterSeconds } from "../client";
import { syncUserStrava } from "../sync";
const HOUR = 3_600_000;
const T0 = Date.UTC(2026, 9, 1);
function activity(id: number) {
return {
id,
sport_type: "Run",
start_date: new Date(T0 + id * HOUR).toISOString(),
moving_time: 1800,
elapsed_time: 1900,
average_heartrate: 140,
};
}
function rateLimited(retryAfterSeconds: number): StravaApiError {
return new StravaApiError({
verb: "fetchActivities",
classification: "transient",
httpStatus: 429,
reason: "http_429",
retryAfterSeconds,
});
}
beforeEach(() => {
vi.clearAllMocks();
mocks.findUser.mockResolvedValue({
stravaLastActivityAt: new Date(T0),
stravaBackfillResumeBefore: null,
});
mocks.createManyAndReturn.mockImplementation(
async ({ data }: { data: { externalId: string; startedAt: Date } }) => [
{ id: `w-${data.externalId}`, startedAt: data.startedAt },
],
);
mocks.fetchActivityById.mockImplementation(
async (_t: string, id: number) => ({
...activity(id),
calories: 300,
}),
);
});
describe("a 429 in the middle of a walk", () => {
it("keeps what was read, moves the cursor an oldest-first walk proves, and books it transient", async () => {
// An incremental walk (`after` set): Strava answers oldest first. Three
// activities read, then the detail read of the fourth is refused.
mocks.fetchActivities.mockResolvedValue([1, 2, 3, 4, 5].map(activity));
mocks.fetchActivityById.mockImplementation(
async (_t: string, id: number) => {
if (id === 4) throw rateLimited(600);
return { ...activity(id), calories: 300 };
},
);
await expect(syncUserStrava("user-1")).resolves.toBe(3);
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(3);
expect(mocks.updateManyUser).toHaveBeenCalledWith(
expect.objectContaining({
data: { stravaLastActivityAt: new Date(T0 + 3 * HOUR) },
}),
);
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
expect.objectContaining({ kind: "transient", errorCode: "429" }),
);
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
});
it("does not move the cursor past a newest-first walk it did not finish", async () => {
mocks.fetchActivities.mockResolvedValue([9, 8, 7].map(activity));
mocks.fetchActivityById.mockImplementation(
async (_t: string, id: number) => {
if (id === 7) throw rateLimited(600);
return { ...activity(id), calories: 300 };
},
);
await expect(syncUserStrava("user-1")).resolves.toBe(2);
expect(mocks.updateManyUser).not.toHaveBeenCalled();
});
it("sends the next backfill run after the window Strava named", async () => {
// A history walk (no `after`): newest first, refused on the second page.
mocks.fetchActivities.mockImplementation(
async (_t: string, q: { page: number }) => {
if (q.page === 2) throw rateLimited(5400);
return Array.from({ length: 100 }, (_, i) => activity(500 - i));
},
);
const run = await runStravaBackfillForUser("user-1");
expect(run).toEqual({ imported: 100, continued: true });
expect(mocks.updateUser).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
stravaBackfillResumeBefore: new Date(T0 + 401 * HOUR + 1000),
},
});
const [, , options] = mocks.send.mock.calls[0] as unknown as [
string,
unknown,
{ startAfter?: number },
];
expect(options.startAfter).toBe(5400);
expect(mocks.updateUser).not.toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stravaBackfillCompletedAt: expect.any(Date),
}),
}),
);
});
});
describe("an update without the detail read", () => {
it("keeps the stored calories, heart rate and description", async () => {
// The row exists already; this run reads only the summary (no HR on it)
// because the detail read failed.
mocks.createManyAndReturn.mockResolvedValue([]);
mocks.fetchActivities.mockResolvedValue([
{ ...activity(1), average_heartrate: undefined },
]);
mocks.fetchActivityById.mockRejectedValue(new Error("detail down"));
mocks.findWorkout.mockResolvedValue({
metadata: { stravaName: "old", stravaDescription: "Hill reps" },
});
await syncUserStrava("user-1");
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
string,
unknown
>;
expect(data).not.toHaveProperty("totalEnergyKcal");
expect(data).not.toHaveProperty("avgHeartRate");
expect(data).not.toHaveProperty("maxHeartRate");
expect(data.metadata).toMatchObject({ stravaDescription: "Hill reps" });
expect(data.durationSec).toBe(1800);
});
it("writes what a detail read returned, nulls included", async () => {
mocks.createManyAndReturn.mockResolvedValue([]);
mocks.fetchActivities.mockResolvedValue([activity(1)]);
mocks.fetchActivityById.mockResolvedValue({
...activity(1),
calories: null,
});
await syncUserStrava("user-1");
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
string,
unknown
>;
expect(data.totalEnergyKcal).toBeNull();
expect(mocks.findWorkout).not.toHaveBeenCalled();
});
});
describe("stravaRateLimitRetryAfterSeconds", () => { describe("stravaRateLimitRetryAfterSeconds", () => {
const at = new Date("2026-10-21T10:07:30Z"); const at = new Date("2026-10-21T10:07:30Z");
@@ -1,121 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StravaWorkoutRow } from "../client";
const { createManyAndReturn, update, emitInsertedWorkoutArrival } = vi.hoisted(
() => ({
createManyAndReturn: vi.fn(),
update: vi.fn(),
emitInsertedWorkoutArrival: vi.fn(async () => {}),
}),
);
vi.mock("@/lib/db", () => ({
prisma: { workout: { createManyAndReturn, update } },
}));
vi.mock("@/lib/arrivals/workout-emit", () => ({
emitInsertedWorkoutArrival,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ addWarning: vi.fn() }),
}));
import { upsertStravaWorkouts } from "../sync";
const startedAt = new Date("2026-07-19T08:00:00.000Z");
const row: StravaWorkoutRow = {
externalId: "strava-1",
sportType: "running",
startedAt,
endedAt: new Date("2026-07-19T09:00:00.000Z"),
durationSec: 3600,
totalEnergyKcal: 500,
totalDistanceM: 10_000,
avgHeartRate: 145,
maxHeartRate: 170,
elevationM: 80,
metadata: {},
};
beforeEach(() => {
vi.clearAllMocks();
update.mockResolvedValue({ id: "existing" });
});
describe("upsertStravaWorkouts — exact inserted identity", () => {
it("emits the exact row returned by the insert statement", async () => {
const inserted = { id: "new-workout", startedAt };
createManyAndReturn.mockResolvedValue([inserted]);
await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1);
expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith(
"user-1",
inserted,
"strava",
);
expect(update).not.toHaveBeenCalled();
});
it("updates and counts an existing workout without emitting", async () => {
createManyAndReturn.mockResolvedValue([]);
await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1);
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId_source_externalId: {
userId: "user-1",
source: "STRAVA",
externalId: "strava-1",
},
},
data: expect.objectContaining({
sportType: "running",
startedAt,
metadata: {},
}),
}),
);
expect(emitInsertedWorkoutArrival).not.toHaveBeenCalled();
});
it("reconciles every short-return loser and emits only returned insert rows", async () => {
const second = { ...row, externalId: "strava-2" };
const inserted = { id: "new-workout", startedAt };
createManyAndReturn
.mockResolvedValueOnce([inserted])
.mockResolvedValueOnce([]);
await expect(upsertStravaWorkouts("user-1", [row, second])).resolves.toBe(
2,
);
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(
expect.objectContaining({
where: {
userId_source_externalId: {
userId: "user-1",
source: "STRAVA",
externalId: "strava-2",
},
},
}),
);
expect(emitInsertedWorkoutArrival).toHaveBeenCalledTimes(1);
expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith(
"user-1",
inserted,
"strava",
);
});
it("keeps a successful insert when arrival dispatch fails", async () => {
createManyAndReturn.mockResolvedValue([{ id: "new-workout", startedAt }]);
emitInsertedWorkoutArrival.mockRejectedValueOnce(new Error("queue down"));
await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1);
});
});
+233
View File
@@ -0,0 +1,233 @@
/**
* Time-zone and day-boundary helpers against their laws, on random instants
* in zones chosen for their awkward offsets and transitions.
*
* The hand-written cases next door pin the transitions somebody thought of;
* these generate the ones nobody did. Each property states something a
* person would notice if it broke: a reading filed under the wrong day, a
* reminder an hour off on a transition day, a day that does not contain its
* own instants.
*/
import fc from "fast-check";
import { describe, it, expect } from "vitest";
import {
daysBetweenDateKeys,
shiftDateKey,
tzOffsetMinutes,
userDayKey,
weekdayOfDateKey,
} from "@/lib/tz/format";
import { localDayWindow, startOfLocalDayInTz } from "@/lib/tz/local-day";
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
import { startOfUtcDay } from "@/lib/tz/start-of-utc-day";
import { dateOnlyAtNoonUtc, dateOnlyKey } from "@/lib/tz/date-only";
import {
assertProperty,
instantArb,
zoneArb,
} from "../../../../tests/property/fast-check";
/** The oracle: what `Intl` itself reads off the wall clock. */
function intlWallClock(date: Date, tz: string) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
hourCycle: "h23",
weekday: "short",
}).formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour: Number(get("hour")) % 24,
minute: Number(get("minute")),
second: Number(get("second")),
weekday: weekdays.indexOf(get("weekday")),
};
}
const dayKeyArb = fc
.date({
min: new Date("1996-01-01T00:00:00Z"),
max: new Date("2039-12-31T00:00:00Z"),
noInvalidDate: true,
})
.map((d) => d.toISOString().slice(0, 10));
describe("wall clock in a zone", () => {
it("reads the same wall clock as Intl for any instant and zone", () => {
assertProperty(
fc.property(instantArb, zoneArb, (date, tz) => {
expect(wallClockInTz(date, tz)).toEqual(intlWallClock(date, tz));
}),
400,
);
});
it("maps a wall clock that exists back to an instant showing that wall clock", () => {
assertProperty(
fc.property(instantArb, zoneArb, (date, tz) => {
const wall = wallClockInTz(date, tz);
const back = zonedWallClockToUtc(wall, tz);
const again = wallClockInTz(back, tz);
expect(again).toEqual(wall);
// Inside a repeated hour the inverse may pick the other occurrence;
// it never lands further away than the largest DST shift.
const truncated = Math.floor(date.getTime() / 1000) * 1000;
expect(Math.abs(back.getTime() - truncated)).toBeLessThanOrEqual(
2 * 3_600_000,
);
}),
400,
);
});
it("resolves a wall clock inside a skipped hour forward, on the same day", () => {
assertProperty(
fc.property(
instantArb,
zoneArb,
fc.integer({ min: 0, max: 23 }),
fc.integer({ min: 0, max: 59 }),
(date, tz, hour, minute) => {
const day = wallClockInTz(date, tz);
const wanted = { ...day, hour, minute, second: 0 };
const resolved = zonedWallClockToUtc(wanted, tz);
const shown = wallClockInTz(resolved, tz);
const asIfUtc = (w: typeof wanted) =>
Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
const drift = asIfUtc(shown) - asIfUtc(wanted);
// Either the wall clock exists and is hit exactly, or it falls in a
// gap and is read with the offset before the gap: later, never
// earlier, by at most the size of the gap.
expect(drift).toBeGreaterThanOrEqual(0);
expect(drift).toBeLessThanOrEqual(2 * 3_600_000);
},
),
400,
);
});
it("agrees with the offset helper on the zone offset", () => {
assertProperty(
fc.property(instantArb, zoneArb, (date, tz) => {
const wall = intlWallClock(date, tz);
const asIfUtc = Date.UTC(
wall.year,
wall.month - 1,
wall.day,
wall.hour,
wall.minute,
wall.second,
);
const expected = Math.round((asIfUtc - date.getTime()) / 60_000);
expect(tzOffsetMinutes(date, tz)).toBe(expected);
}),
300,
);
});
});
describe("local days", () => {
it("files an instant under a day whose window contains it", () => {
assertProperty(
fc.property(instantArb, zoneArb, (date, tz) => {
const key = userDayKey(date, tz);
const { dayStart, dayEnd } = localDayWindow(key, tz);
expect(dayStart.getTime()).toBeLessThanOrEqual(date.getTime());
expect(date.getTime()).toBeLessThan(dayEnd.getTime());
expect(startOfLocalDayInTz(date, tz).getTime()).toBe(
dayStart.getTime(),
);
}),
400,
);
});
it("never files a later instant under an earlier day", () => {
assertProperty(
fc.property(instantArb, instantArb, zoneArb, (a, b, tz) => {
const [early, late] = a <= b ? [a, b] : [b, a];
expect(userDayKey(early, tz) <= userDayKey(late, tz)).toBe(true);
}),
400,
);
});
it("makes every local day between 22 and 26 hours long and contiguous with the next", () => {
assertProperty(
fc.property(dayKeyArb, zoneArb, (key, tz) => {
const today = localDayWindow(key, tz);
const tomorrow = localDayWindow(shiftDateKey(key, 1), tz);
const hours =
(today.dayEnd.getTime() - today.dayStart.getTime()) / 3_600_000;
expect(hours).toBeGreaterThanOrEqual(22);
expect(hours).toBeLessThanOrEqual(26);
expect(tomorrow.dayStart.getTime()).toBe(today.dayEnd.getTime());
expect(userDayKey(today.dayStart, tz)).toBe(key);
}),
300,
);
});
});
describe("day keys", () => {
it("shifts and counts days as inverses of each other", () => {
assertProperty(
fc.property(
dayKeyArb,
fc.integer({ min: -2000, max: 2000 }),
(key, delta) => {
const shifted = shiftDateKey(key, delta);
expect(daysBetweenDateKeys(key, shifted)).toBe(delta);
expect(shiftDateKey(shifted, -delta)).toBe(key);
},
),
);
});
it("keeps the weekday across whole weeks and advances it by one per day", () => {
assertProperty(
fc.property(
dayKeyArb,
fc.integer({ min: -300, max: 300 }),
(key, weeks) => {
const weekday = weekdayOfDateKey(key);
expect(weekdayOfDateKey(shiftDateKey(key, weeks * 7))).toBe(weekday);
expect(weekdayOfDateKey(shiftDateKey(key, 1))).toBe(
(weekday + 1) % 7,
);
},
),
);
});
it("stores a date-only value at noon UTC and reads the same date back", () => {
assertProperty(
fc.property(dayKeyArb, (key) => {
expect(dateOnlyKey(dateOnlyAtNoonUtc(key))).toBe(key);
}),
);
});
it("floors an instant to its UTC day, idempotently", () => {
assertProperty(
fc.property(instantArb, (date) => {
const floor = startOfUtcDay(date);
expect(floor.getTime()).toBeLessThanOrEqual(date.getTime());
expect(date.getTime() - floor.getTime()).toBeLessThan(86_400_000);
expect(startOfUtcDay(floor).getTime()).toBe(floor.getTime());
expect(floor.toISOString().endsWith("T00:00:00.000Z")).toBe(true);
}),
);
});
});
+63
View File
@@ -0,0 +1,63 @@
/**
* Mutation testing for the core libraries: the modules where a wrong answer
* is silent and costly (encryption at rest, medication compliance, rollups,
* source ranking, unit conversion, backup validation).
*
* Not on pull requests: the full run takes the better part of an hour. It
* runs nightly and on demand (`.github/workflows/mutation.yml`), and locally
* with `pnpm exec stryker run`. The score of each module is checked against
* the floor in `stryker.floors.json` by `node scripts/mutation-floors.mjs`,
* so a floor is per module rather than one number for the whole run.
*
* The tests that run are the unit tests next to these modules (see
* `vitest.mutation.config.mts`), not the whole suite: a mutant that only a
* route test or an integration test would catch counts as surviving here,
* so a score is a lower bound on what the full suite catches.
*
* Vitest 5 names a test "describe > test" where @stryker-mutator/vitest-runner
* 10.0.0 still built "describe test", so every test inside a describe block
* was filtered out of the mutant runs and nearly every mutant survived.
* `patches/@stryker-mutator__vitest-runner@10.0.0.patch` builds the name the
* Vitest 5 way; drop it once a runner release does the same.
*/
const config = {
testRunner: "vitest",
vitest: { configFile: "vitest.mutation.config.mts" },
plugins: ["@stryker-mutator/vitest-runner"],
mutate: [
"src/lib/crypto.ts",
"src/lib/analytics/compliance/*.ts",
"src/lib/rollups/measurement-rollups.ts",
"src/lib/rollups/tiered-context.ts",
"src/lib/analytics/source-priority.ts",
"src/lib/analytics/source-rank-sql.ts",
"src/lib/measurements/display-transform.ts",
"src/lib/glucose.ts",
"src/lib/validations/backup.ts",
],
coverageAnalysis: "perTest",
incremental: true,
incrementalFile: "reports/mutation/stryker-incremental.json",
reporters: ["clear-text", "progress", "html", "json"],
htmlReporter: { fileName: "reports/mutation/index.html" },
jsonReporter: { fileName: "reports/mutation/mutation.json" },
// The per-module floors live in stryker.floors.json; the run itself never
// fails on a score, only on an error.
thresholds: { high: 80, low: 60, break: null },
timeoutMS: 20_000,
tempDirName: ".stryker-tmp",
cleanTempDir: "always",
ignorePatterns: [
"/.next",
"/e2e",
"/docs",
"/public",
"/assets",
"/reports",
"/playwright-report",
"/test-results",
"/tests/integration",
],
};
export default config;
+35
View File
@@ -0,0 +1,35 @@
{
"$comment": "Per-module mutation-score floors, checked by scripts/mutation-floors.mjs against reports/mutation/mutation.json. Each floor sits just below the score measured when it was set; raise it when a change raises the score, never lower it to let a run pass. See docs/contributing/testing.md#mutation-testing.",
"modules": {
"crypto": {
"paths": ["src/lib/crypto.ts"],
"floor": 45
},
"compliance": {
"paths": ["src/lib/analytics/compliance/"],
"floor": 65
},
"rollups": {
"paths": ["src/lib/rollups/"],
"floor": 40
},
"source-ranking": {
"paths": [
"src/lib/analytics/source-priority.ts",
"src/lib/analytics/source-rank-sql.ts"
],
"floor": 55
},
"units": {
"paths": [
"src/lib/measurements/display-transform.ts",
"src/lib/glucose.ts"
],
"floor": 85
},
"backup-validation": {
"paths": ["src/lib/validations/backup.ts"],
"floor": 24
}
}
}
+448
View File
@@ -0,0 +1,448 @@
{
"source": "vitest JSON report of 443 files, written 2026-10-22",
"files": {
"tests/integration/account-grant-lifecycle.test.ts": 46.8,
"tests/integration/achievements-expansion.test.ts": 2.4,
"tests/integration/acting-account-resolver.test.ts": 25.6,
"tests/integration/activity-calendar-read.test.ts": 0,
"tests/integration/admin-backups-audit.test.ts": 2.9,
"tests/integration/admin-backups-canonical-roundtrip.test.ts": 10.9,
"tests/integration/admin-backups-download.test.ts": 2.1,
"tests/integration/admin-backups-restore.test.ts": 5.7,
"tests/integration/admin-backups-upload-too-large.test.ts": 1.6,
"tests/integration/admin-backups-upload.test.ts": 5.5,
"tests/integration/admin-data-wipe.test.ts": 2.6,
"tests/integration/admin-drain-cumulative-endpoint.test.ts": 3.2,
"tests/integration/admin-host-metrics.test.ts": 4.9,
"tests/integration/admin-reset-password.test.ts": 5.5,
"tests/integration/admin-support-report.test.ts": 2.4,
"tests/integration/ai-capability-switches-migration.test.ts": 5.1,
"tests/integration/ai-consent-explicit.test.ts": 2,
"tests/integration/ai-extraction-capability.test.ts": 17.2,
"tests/integration/ai-optional-actions.test.ts": 9.7,
"tests/integration/ai-optional-data-routes.test.ts": 24.3,
"tests/integration/ai-optional-jobs.test.ts": 20.6,
"tests/integration/ai-optional-mixed-routes.test.ts": 19.7,
"tests/integration/ai-provider-key-removal.test.ts": 4.4,
"tests/integration/ai-usage-levels.test.ts": 8.2,
"tests/integration/all-time-extremes-tier-parity.test.ts": 1.4,
"tests/integration/analytics-bp-aggregate-paged.test.ts": 13,
"tests/integration/analytics-health-score.test.ts": 5.5,
"tests/integration/analytics-sleep-stages.test.ts": 2.9,
"tests/integration/analytics-summaries-slice.test.ts": 3.4,
"tests/integration/analytics-weekday-tz.test.ts": 2.2,
"tests/integration/apns-dispatch.test.ts": 8.4,
"tests/integration/apple-health-distance-repair.test.ts": 7.3,
"tests/integration/apple-health-import-ecg.test.ts": 8.6,
"tests/integration/apple-health-import-natural-key.test.ts": 3,
"tests/integration/apple-health-import-own-samples.test.ts": 26.8,
"tests/integration/apple-health-import-reconcile.test.ts": 9.8,
"tests/integration/apple-health-import-rollup-window.test.ts": 1.1,
"tests/integration/apple-health-import-worker-failures.test.ts": 4.2,
"tests/integration/arrival-reaction-claim.test.ts": 14.8,
"tests/integration/auth-flow.test.ts": 9,
"tests/integration/auth-ledger-cleanup.test.ts": 1.7,
"tests/integration/auth-me-ai-capabilities.test.ts": 12.3,
"tests/integration/auth-me-coach-reasoning.test.ts": 6.1,
"tests/integration/auth-password-change.test.ts": 6.3,
"tests/integration/backfill-workout-sport.test.ts": 3.2,
"tests/integration/background-switch-freshness.test.ts": 1.1,
"tests/integration/backup-chunked-storage.test.ts": 54.4,
"tests/integration/backup-custom-cycle-symptom-restore.test.ts": 1.2,
"tests/integration/backup-cycle-cross-key-restore.test.ts": 2,
"tests/integration/backup-cycle-symptom-severity-restore.test.ts": 2.8,
"tests/integration/backup-document-streaming.test.ts": 7.5,
"tests/integration/backup-key-needs.test.ts": 1.3,
"tests/integration/backup-legacy-rmssd-restore.test.ts": 1.2,
"tests/integration/backup-missing-section-restore.test.ts": 2.7,
"tests/integration/backup-mood-label-cross-key-restore.test.ts": 2,
"tests/integration/backup-portable-file-restore.test.ts": 1.1,
"tests/integration/backup-preview-memory.test.ts": 2.7,
"tests/integration/backup-preview.test.ts": 5,
"tests/integration/backup-qualitative-lab-restore.test.ts": 1.1,
"tests/integration/backup-restore-boundaries.test.ts": 3,
"tests/integration/backup-restore-job.test.ts": 10.2,
"tests/integration/backup-restore-memory.test.ts": 10.4,
"tests/integration/backup-restore-statement-timeout.test.ts": 39.7,
"tests/integration/backup-round-trip.test.ts": 19,
"tests/integration/backup-streaming-memory.test.ts": 3.9,
"tests/integration/backup-unknown-catalogue-key-restore.test.ts": 1.5,
"tests/integration/backup-visits-cross-key-restore.test.ts": 1.9,
"tests/integration/bearer-scope-enforcement.test.ts": 57.3,
"tests/integration/biomarker-backup-completeness.test.ts": 2.2,
"tests/integration/biomarker-detail.test.ts": 6.8,
"tests/integration/biomarker-merge.test.ts": 10.4,
"tests/integration/body-sites.test.ts": 14.5,
"tests/integration/boot-discovery-scans.test.ts": 4,
"tests/integration/bp-in-target.test.ts": 23,
"tests/integration/briefing-cache-locale.test.ts": 3.1,
"tests/integration/bulk-delete-source-filter.test.ts": 5.8,
"tests/integration/cache-invalidation-coverage.test.ts": 3.9,
"tests/integration/cascade-delete.test.ts": 1,
"tests/integration/changepoint-extended.test.ts": 2.2,
"tests/integration/chart-all-range-meaning.test.ts": 3.2,
"tests/integration/chart-overlay-prefs.test.ts": 2.2,
"tests/integration/chart-range-dense.test.ts": 0.5,
"tests/integration/checkup-stays-due.test.ts": 9.7,
"tests/integration/ci-environment.test.ts": 0.1,
"tests/integration/coach-availability-probe.test.ts": 5.1,
"tests/integration/coach-budget-owner.test.ts": 9.8,
"tests/integration/coach-budget-reservation.test.ts": 12,
"tests/integration/coach-chat.test.ts": 10.6,
"tests/integration/coach-conversation-delete.test.ts": 3.2,
"tests/integration/coach-conversation-documents.test.ts": 5,
"tests/integration/coach-conversation-updated-at-repair.test.ts": 2.9,
"tests/integration/coach-conversations.test.ts": 5.4,
"tests/integration/coach-feedback.test.ts": 3.6,
"tests/integration/coach-history-reach.test.ts": 5.9,
"tests/integration/coach-memory-loop.test.ts": 12.5,
"tests/integration/coach-metric-table.test.ts": 7.1,
"tests/integration/coach-prefs.test.ts": 2.9,
"tests/integration/coach-reminder-surfacing.test.ts": 5,
"tests/integration/coach-series-long-window.test.ts": 1.1,
"tests/integration/coach-stale-rollup-tail.test.ts": 1.1,
"tests/integration/coach-summary-keeps-updated-at.test.ts": 3.8,
"tests/integration/coach-trail.test.ts": 4.7,
"tests/integration/codex-credential-park.test.ts": 7.2,
"tests/integration/compaction-tombstones.test.ts": 15.7,
"tests/integration/consent-withdrawal-purge.test.ts": 8.5,
"tests/integration/consolidate-daily-mean.test.ts": 4.4,
"tests/integration/consolidate-legacy-steps.test.ts": 5.6,
"tests/integration/correlation-extended-channels.test.ts": 3.5,
"tests/integration/correlation-findings-before-seasonal-adjustment.test.ts": 8.6,
"tests/integration/correlations-rollup-swap.test.ts": 1,
"tests/integration/credential-enrollment-proof.test.ts": 8.5,
"tests/integration/csv-import-contextless-glucose.test.ts": 4.9,
"tests/integration/csv-reimport-idempotent.test.ts": 2.9,
"tests/integration/cumulative-pr-rederive-atomic.test.ts": 2.3,
"tests/integration/cycle-absorbed-restored.test.ts": 6.2,
"tests/integration/cycle-crosstab-day-aggregates.test.ts": 0.2,
"tests/integration/cycle-flow-opens-cycle.test.ts": 17.1,
"tests/integration/cycle-import-export.test.ts": 3.5,
"tests/integration/cycle-insights-dense-reads.test.ts": 0.9,
"tests/integration/cycle-insights-scoped-delegate.test.ts": 6.2,
"tests/integration/cycle-routes.test.ts": 24.6,
"tests/integration/cycle-start-round-trip.test.ts": 8.9,
"tests/integration/daily-digest-today-overview.test.ts": 3.5,
"tests/integration/daily-series-user-tz.test.ts": 9.1,
"tests/integration/dashboard-chart-overlay-concurrency.test.ts": 3,
"tests/integration/dashboard-glucose-untagged.test.ts": 3.5,
"tests/integration/dashboard-pulse-latest-day.test.ts": 0.5,
"tests/integration/dashboard-snapshot-pre-window-types.test.ts": 2.7,
"tests/integration/dashboard-summary-reads.test.ts": 22.4,
"tests/integration/dashboard-widgets-save.test.ts": 11.5,
"tests/integration/dawarich-context.test.ts": 15.1,
"tests/integration/day-aggregates-pulse-hourly.test.ts": 0.1,
"tests/integration/day-view.test.ts": 50.1,
"tests/integration/day-weight-unanalysed-scale.test.ts": 3,
"tests/integration/delegated-intake-notification.test.ts": 3.4,
"tests/integration/demo-mode-profile-write.test.ts": 3.3,
"tests/integration/dense-glucose-raw-window-series.test.ts": 0.2,
"tests/integration/dense-intraday-hourly-rebuild.test.ts": 7.4,
"tests/integration/dense-intraday-retention.test.ts": 17.9,
"tests/integration/dense-pulse-raw-window-reads.test.ts": 0.4,
"tests/integration/dense-read-bounded.test.ts": 0.9,
"tests/integration/dense-series-hrv-spo2.test.ts": 0.6,
"tests/integration/dense-targets-reads.test.ts": 0.6,
"tests/integration/device-register.test.ts": 8.9,
"tests/integration/doctor-report-dense-data.test.ts": 1.2,
"tests/integration/doctor-report-fixed-window.test.ts": 1.9,
"tests/integration/doctor-report-pulse-hourly-mean.test.ts": 0,
"tests/integration/doctor-report-sections.test.ts": 9.8,
"tests/integration/document-ai-runs.test.ts": 13.3,
"tests/integration/document-sources.test.ts": 41.2,
"tests/integration/document-thumbnail-backfill.test.ts": 3,
"tests/integration/documents-content-index.test.ts": 4.1,
"tests/integration/documents-import.test.ts": 12.4,
"tests/integration/documents-vault.test.ts": 21.1,
"tests/integration/dose-history-pre-creation-slots.test.ts": 4.4,
"tests/integration/drain-per-sample-cumulative.test.ts": 5.9,
"tests/integration/drop-plaintext-location-migration.test.ts": 0.1,
"tests/integration/ecg-ingest-roundtrip.test.ts": 11.7,
"tests/integration/ecg-recording-identity-migration.test.ts": 4.3,
"tests/integration/emergency-profile-migration.test.ts": 0.1,
"tests/integration/emergency-profile-route.test.ts": 2.9,
"tests/integration/encounter-appointment-reminder.test.ts": 44.9,
"tests/integration/encounter-phase2-surfaces.test.ts": 15.5,
"tests/integration/encounter-preparation.test.ts": 4.8,
"tests/integration/encounter-procedures.test.ts": 31,
"tests/integration/encounter-scoped-delegate.test.ts": 8.4,
"tests/integration/encryption-corpus-cursor-row-deleted.test.ts": 0.9,
"tests/integration/encryption-key-check.test.ts": 33.2,
"tests/integration/encryption-key-rotation-blobs.test.ts": 6.6,
"tests/integration/encryption-key-rotation-contact-and-route.test.ts": 0.9,
"tests/integration/environment-air-quality-history.test.ts": 17,
"tests/integration/environment-coarse-location.test.ts": 2.4,
"tests/integration/environment-location-encryption.test.ts": 6.5,
"tests/integration/environment-location-rotation.test.ts": 1,
"tests/integration/export-per-type.test.ts": 7.6,
"tests/integration/feature-blocks-pulse-day-mean.test.ts": 0,
"tests/integration/fhir-series-and-medication-effect.test.ts": 0.1,
"tests/integration/fitbit-activity-sleep-workout-sync.test.ts": 4.6,
"tests/integration/fitbit-metrics-sync.test.ts": 3.7,
"tests/integration/fitbit-retirement.test.ts": 2.1,
"tests/integration/fold-complete-windows.test.ts": 9,
"tests/integration/fold-horizon.test.ts": 5.5,
"tests/integration/fold-leftover-provenance.test.ts": 17.7,
"tests/integration/free-text-encryption-backfill.test.ts": 8.4,
"tests/integration/full-backup-nutrients.test.ts": 2.4,
"tests/integration/geo-backfill-attempt.test.ts": 1.3,
"tests/integration/google-health-backfill-multi-year.test.ts": 86,
"tests/integration/google-health-backfill-progress-bound.test.ts": 9.9,
"tests/integration/google-health-backfill-rate-limit.test.ts": 14.9,
"tests/integration/google-health-dense-backfill-memory.test.ts": 60.9,
"tests/integration/google-health-sleep-overlap.test.ts": 4.1,
"tests/integration/google-health-sync-progress.test.ts": 3,
"tests/integration/google-health-workout-terminal.test.ts": 1.8,
"tests/integration/graded-series-pulse-day-mean.test.ts": 0,
"tests/integration/guardian-notification-fanout.test.ts": 12.2,
"tests/integration/health-connect-import.test.ts": 66.6,
"tests/integration/health-profile-backup-roundtrip.test.ts": 2.8,
"tests/integration/health-score-configured-flag.test.ts": 9.2,
"tests/integration/health-score-labs-pillars.test.ts": 2.9,
"tests/integration/health-score-record.test.ts": 14.4,
"tests/integration/health-status-implausible-values.test.ts": 1.3,
"tests/integration/healthkit-type-sync-migration.test.ts": 0,
"tests/integration/healthkit-type-sync.test.ts": 9.6,
"tests/integration/heavy-lane-defer.test.ts": 10.1,
"tests/integration/hour-buckets-and-backfill.test.ts": 10.1,
"tests/integration/hrv-rmssd-fitbit-google-migration.test.ts": 0,
"tests/integration/hrv-rmssd-ingest.test.ts": 2.5,
"tests/integration/idempotency-body-fingerprint.test.ts": 6.4,
"tests/integration/idempotency-record-scoped-cell.test.ts": 3.1,
"tests/integration/idempotency-replay.test.ts": 7.3,
"tests/integration/index-hygiene.test.ts": 4.1,
"tests/integration/insight-status-cache-migration.test.ts": 1.9,
"tests/integration/insights-comprehensive-cache.test.ts": 4.7,
"tests/integration/insights-feedback.test.ts": 4.1,
"tests/integration/insights-provider-chain-put.test.ts": 2.2,
"tests/integration/insights-provider-chain.test.ts": 3.3,
"tests/integration/insights-recent-readings.test.ts": 1.6,
"tests/integration/insights-status-real-text.test.ts": 2.9,
"tests/integration/intake-backfill-missed-slot.test.ts": 2.8,
"tests/integration/intake-late-take-future-slot.test.ts": 2.7,
"tests/integration/intake-scheduled-for-targeting.test.ts": 2.3,
"tests/integration/intake-slot-dedup.test.ts": 6.8,
"tests/integration/integration-backfill-lane-expiry.test.ts": 21.4,
"tests/integration/integration-failing-legs-backfill.test.ts": 2.4,
"tests/integration/integration-status.test.ts": 8.4,
"tests/integration/integration-sync-escalation.test.ts": 22.4,
"tests/integration/integration-watchdog.test.ts": 12.5,
"tests/integration/inventory-expiry-container-type-repair.test.ts": 9.7,
"tests/integration/job-failures.test.ts": 0.3,
"tests/integration/job-success-outcome.test.ts": 0.5,
"tests/integration/lab-biomarker-backfill-budget.test.ts": 4.9,
"tests/integration/lab-source-reference-range.test.ts": 6.3,
"tests/integration/lab-unit-mismatch.test.ts": 14.9,
"tests/integration/labs-analyte-key.test.ts": 0.6,
"tests/integration/labs-overview-long-history.test.ts": 4.2,
"tests/integration/labs-unit-conversion.test.ts": 0.7,
"tests/integration/large-account-jobs.test.ts": 5.4,
"tests/integration/large-backup-round-trip.test.ts": 5.5,
"tests/integration/latest-canonical-read.test.ts": 0,
"tests/integration/life-circumstances.test.ts": 18.4,
"tests/integration/link-service.test.ts": 34.6,
"tests/integration/list-pagination-tiebreaker.test.ts": 4.1,
"tests/integration/local-day-keys.test.ts": 1.8,
"tests/integration/login-insecure-transport.test.ts": 6.5,
"tests/integration/managed-profile-edit.test.ts": 8.5,
"tests/integration/managed-profile-guardian-concurrency.test.ts": 1,
"tests/integration/managed-profile-handover-sso.test.ts": 18.2,
"tests/integration/managed-profile-handover-stepup-sso.test.ts": 4.5,
"tests/integration/managed-profile-handover.test.ts": 30.9,
"tests/integration/managed-profile-lifecycle.test.ts": 32.9,
"tests/integration/managed-profile-oauth-connect.test.ts": 7.4,
"tests/integration/managed-profile-passkey-step-up.test.ts": 2.5,
"tests/integration/managed-profile-surface.test.ts": 55.2,
"tests/integration/managed-record-capabilities.test.ts": 3.3,
"tests/integration/managed-record-modules.test.ts": 11.2,
"tests/integration/manual-workout-edit.test.ts": 2.9,
"tests/integration/mcp-metric-series-source-snapshot.test.ts": 3.4,
"tests/integration/mcp-oauth-refresh-race.test.ts": 1.9,
"tests/integration/mcp-output-schema-conformance.test.ts": 1,
"tests/integration/mcp-write-and-range-read.test.ts": 8.7,
"tests/integration/measurement-bulk-insert.test.ts": 6.6,
"tests/integration/measurement-hot-update.test.ts": 4.1,
"tests/integration/measurement-identity-reconcile.test.ts": 5.4,
"tests/integration/measurement-rollups.test.ts": 16.6,
"tests/integration/measurement-soft-delete.test.ts": 4.7,
"tests/integration/measurement-source-write-smoke.test.ts": 1.4,
"tests/integration/measurements-aggregate-daily.test.ts": 4.8,
"tests/integration/measurements-batch-delete.test.ts": 5,
"tests/integration/measurements-batch-perf.test.ts": 4.2,
"tests/integration/measurements-batch-set-insert-golden.test.ts": 1.6,
"tests/integration/measurements-batch.test.ts": 25.5,
"tests/integration/measurements-day-grouped-paging.test.ts": 4.2,
"tests/integration/measurements-duplicate-conflict.test.ts": 10.3,
"tests/integration/measurements-named-sleep-stage.test.ts": 8.5,
"tests/integration/measurements-sleep-list.test.ts": 12,
"tests/integration/medication-administrations.test.ts": 1,
"tests/integration/medication-auto-mark-taken.test.ts": 9.2,
"tests/integration/medication-cadence-worker.test.ts": 10.3,
"tests/integration/medication-courses.test.ts": 6,
"tests/integration/medication-custom-categories.test.ts": 9.2,
"tests/integration/medication-dose-history-import.test.ts": 21,
"tests/integration/medication-extra-dose-apple-health.test.ts": 6.2,
"tests/integration/medication-intake-import-job.test.ts": 1.6,
"tests/integration/medication-intake-import-reported-export.test.ts": 3.1,
"tests/integration/medication-intake-tracking.test.ts": 8.4,
"tests/integration/medication-per-slot-consumption.test.ts": 1,
"tests/integration/medication-put-course-race.test.ts": 1.5,
"tests/integration/medication-reminder-cross-midnight.test.ts": 1.9,
"tests/integration/medication-rolling-overdue-consumers.test.ts": 4.8,
"tests/integration/medication-weekly-compliance.test.ts": 2.4,
"tests/integration/medication-write-idempotency.test.ts": 4.4,
"tests/integration/medications-intake-bulk.test.ts": 9.7,
"tests/integration/mfa-login-flow.test.ts": 3.6,
"tests/integration/migrate-deploy-lock-retry.test.ts": 10,
"tests/integration/migrate-deploy-utc.test.ts": 0.9,
"tests/integration/migration-0430-insights-stamp.test.ts": 3.8,
"tests/integration/mood-a1-backfill.test.ts": 3.2,
"tests/integration/mood-backup-round-trip.test.ts": 12.7,
"tests/integration/mood-context-linkage.test.ts": 18.4,
"tests/integration/mood-context.test.ts": 13.2,
"tests/integration/mood-dimension-features.test.ts": 5.9,
"tests/integration/mood-entries-bulk.test.ts": 6.7,
"tests/integration/mood-entries-external-id.test.ts": 5.3,
"tests/integration/mood-entries-note-column.test.ts": 3.5,
"tests/integration/mood-insights-crosstab.test.ts": 1.9,
"tests/integration/mood-patterns.test.ts": 2.3,
"tests/integration/mood-prognosis-ladder.test.ts": 23,
"tests/integration/mood-tag-groups.test.ts": 7.7,
"tests/integration/mood-writers-agree.test.ts": 7.5,
"tests/integration/note-encryption-backfill.test.ts": 2,
"tests/integration/notification-action-mood.test.ts": 6.2,
"tests/integration/notification-attribution.test.ts": 21.5,
"tests/integration/notification-event-defaults.test.ts": 2.9,
"tests/integration/notification-global-channel-switch.test.ts": 3.1,
"tests/integration/nutrients-batch.test.ts": 9.4,
"tests/integration/oauth-callback-single-use.test.ts": 5.6,
"tests/integration/offhost-backup-ledger.test.ts": 5.6,
"tests/integration/offhost-backup-streaming-memory.test.ts": 3.7,
"tests/integration/offhost-purge-on-deletion.test.ts": 4.9,
"tests/integration/oidc-pending-link.test.ts": 8.4,
"tests/integration/onboarding-needs.test.ts": 20.3,
"tests/integration/openapi-get-contract.test.ts": 5.7,
"tests/integration/openapi-response-shape.test.ts": 3,
"tests/integration/oura-backfill.test.ts": 31.6,
"tests/integration/oura-refresh-serialization.test.ts": 2,
"tests/integration/oura-resilience-recovery.test.ts": 2.4,
"tests/integration/oura-retired-sleep.test.ts": 1.5,
"tests/integration/oura-scope-denied.test.ts": 1.2,
"tests/integration/partial-unique-live-rows.test.ts": 1.2,
"tests/integration/passkey-register.test.ts": 13.7,
"tests/integration/pending-migrations-notice.test.ts": 0.4,
"tests/integration/pr-detection-cumulative-paged.test.ts": 2.9,
"tests/integration/pr-detection-end-to-end.test.ts": 4,
"tests/integration/practitioner-contact-encryption.test.ts": 6.3,
"tests/integration/practitioner-specialty-search.test.ts": 2.3,
"tests/integration/prisma-schema-name-drift.test.ts": 2.6,
"tests/integration/profile-display-name.test.ts": 4.3,
"tests/integration/provider-pause.test.ts": 6.6,
"tests/integration/provider-token-refresh-serialization.test.ts": 6.2,
"tests/integration/pulse-comparison-availability.test.ts": 0,
"tests/integration/pulse-day-mean-method.test.ts": 25.3,
"tests/integration/pulse-day-mean-zones.test.ts": 0.4,
"tests/integration/pulse-hourly-mean-day.test.ts": 0.5,
"tests/integration/pulse-hourly-mean-migration.test.ts": 0,
"tests/integration/pulse-window-mean-readers.test.ts": 0.4,
"tests/integration/queue-singleton-policy.integration.test.ts": 0.3,
"tests/integration/rate-limit-429-headers.test.ts": 4.1,
"tests/integration/rate-limit.test.ts": 2,
"tests/integration/recent-proof.test.ts": 4.9,
"tests/integration/record-csv-import.test.ts": 4,
"tests/integration/record-search.test.ts": 9.2,
"tests/integration/record-session-epoch-trigger.test.ts": 7.4,
"tests/integration/record-session-fence-idempotency.test.ts": 3.1,
"tests/integration/record-session-fence.test.ts": 13.5,
"tests/integration/record-settings-authorization.test.ts": 0,
"tests/integration/record-settings-integrations-health.test.ts": 1.7,
"tests/integration/refresh-rotation-access-sunset.test.ts": 3.3,
"tests/integration/refresh-rotation-revoke-race.test.ts": 2.4,
"tests/integration/relation-links-portable-restore.test.ts": 2.3,
"tests/integration/relation-links.test.ts": 15.5,
"tests/integration/reset-password-revocation.test.ts": 3,
"tests/integration/rest-mode-chronic.test.ts": 15.6,
"tests/integration/restore-consolidation-deadlock.test.ts": 5.1,
"tests/integration/retired-index-drop.test.ts": 2.5,
"tests/integration/rollup-concurrent-recompute.test.ts": 2.4,
"tests/integration/rollup-coverage-probe.test.ts": 1.2,
"tests/integration/rollup-keyed-refold-session-tz.test.ts": 2.1,
"tests/integration/rollup-per-day-canonical.test.ts": 6.6,
"tests/integration/rollup-regression-parity.integration.test.ts": 3.1,
"tests/integration/score-history.test.ts": 7.8,
"tests/integration/second-factor-ends-other-sessions.test.ts": 3.1,
"tests/integration/series-source-ladder.test.ts": 0.6,
"tests/integration/server-cache-routes.test.ts": 3.8,
"tests/integration/session-cookie-validation.test.ts": 4.2,
"tests/integration/session-time-zone.test.ts": 6.9,
"tests/integration/sessions-and-known-devices.test.ts": 9.3,
"tests/integration/settings-account-delete.test.ts": 10.7,
"tests/integration/settings-data-wipe.test.ts": 4.3,
"tests/integration/settings-erasure-bearer-step-up.test.ts": 8.3,
"tests/integration/settings-prefs-routes.test.ts": 6.7,
"tests/integration/share-link-documents.test.ts": 3,
"tests/integration/sharing-account-access.test.ts": 21.3,
"tests/integration/sharing-audit-actor.test.ts": 9.9,
"tests/integration/sharing-delegable-routes.test.ts": 203.5,
"tests/integration/sharing-delegable-writes.test.ts": 116.8,
"tests/integration/sharing-front-door.test.ts": 33.5,
"tests/integration/sharing-idempotency-revocation.test.ts": 8.6,
"tests/integration/sharing-import-attribution.test.ts": 2.1,
"tests/integration/sharing-lifecycle.test.ts": 36.4,
"tests/integration/sharing-manage-handler-matrix.test.ts": 424.4,
"tests/integration/sharing-provider-origin.test.ts": 7.1,
"tests/integration/sharing-scope-fence.test.ts": 65.9,
"tests/integration/sharing-scope-routes.test.ts": 1.1,
"tests/integration/sleep-entry-hours-round-trip.test.ts": 4.6,
"tests/integration/sleep-night-sql-parity.test.ts": 10.1,
"tests/integration/soft-delete-copy-parity.test.ts": 2.3,
"tests/integration/soft-delete-purge.test.ts": 9.1,
"tests/integration/source-aware-rollups.integration.test.ts": 9.4,
"tests/integration/source-ladder-new-sources.test.ts": 0,
"tests/integration/source-priority-two-axis.test.ts": 3.5,
"tests/integration/source-provenance.test.ts": 0.1,
"tests/integration/sources-in-use.test.ts": 2.4,
"tests/integration/step-consolidation-repair.test.ts": 3.3,
"tests/integration/step-up-elevation.test.ts": 57,
"tests/integration/summaries-pre-window-buckets.test.ts": 3.3,
"tests/integration/supplement-nutrients.test.ts": 19.8,
"tests/integration/switched-session-destructive-refusal.test.ts": 2.9,
"tests/integration/symptoms.test.ts": 16.3,
"tests/integration/sync-changes.test.ts": 10.1,
"tests/integration/sync-reset-after-restore.test.ts": 3,
"tests/integration/sync-state.test.ts": 3.4,
"tests/integration/tiered-context-local-day.test.ts": 0,
"tests/integration/timeline-rollup-boundary-zone.test.ts": 1.8,
"tests/integration/timeline-sleep-nights.test.ts": 2.7,
"tests/integration/timezone-per-user.test.ts": 12.9,
"tests/integration/tombstone-cleanup-restore-lock.test.ts": 5.6,
"tests/integration/user-avatar.test.ts": 1.4,
"tests/integration/vaccination-booster-mint.test.ts": 3.3,
"tests/integration/vaccination-booster-satisfy.test.ts": 10.7,
"tests/integration/vaccination-booster-scoped-delegate.test.ts": 4.7,
"tests/integration/vaccination-custom.test.ts": 7.9,
"tests/integration/vaccination-managed-profile.test.ts": 2.8,
"tests/integration/vaccination-renewal.test.ts": 5.2,
"tests/integration/visit-link-picker-long-history.test.ts": 5.1,
"tests/integration/visit-link-picker-period-search.test.ts": 9.9,
"tests/integration/webauthn-challenge-claim.test.ts": 5.3,
"tests/integration/whoop-connect-ticket.test.ts": 5.5,
"tests/integration/whoop-owner-identity.test.ts": 1.8,
"tests/integration/withings-activity-sync.test.ts": 3.1,
"tests/integration/withings-api-plan.test.ts": 4.2,
"tests/integration/withings-oauth-flow.test.ts": 5.9,
"tests/integration/withings-oauth.test.ts": 4.8,
"tests/integration/withings-sleep-stage-composite.test.ts": 4.6,
"tests/integration/withings-sleep-sync.test.ts": 2.2,
"tests/integration/withings-sync-volume.test.ts": 2.4,
"tests/integration/workout-batch-create.test.ts": 18.5,
"tests/integration/workout-batch-enrich.test.ts": 7.1,
"tests/integration/workout-batch-race.test.ts": 2,
"tests/integration/workout-detail-hr-series.test.ts": 5.2,
"tests/integration/workout-insight-claim.test.ts": 14,
"tests/integration/workout-manual-entry.test.ts": 16.3,
"tests/integration/workout-routes-opt-in-migration.test.ts": 3.2,
"tests/integration/write-meter.test.ts": 5.8
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Shard and order test files by how long they take, not by how many there are.
*
* Vitest's own `--shard` cuts the sorted file list into slices of equal
* length. With files ranging from a tenth of a second to several minutes,
* equal counts gave the integration shards 954 s to 1 449 s: the run waited
* on the slowest slice while the fastest sat idle for eight minutes.
*
* This sequencer reads the measured duration of every file from a committed
* timing file (`tests/durations/<suite>.json`, regenerated with
* `node scripts/test-durations.mjs`) and deals the files out longest first,
* each to the shard with the least work so far (the classic longest-
* processing-time rule, within 4/3 of the best possible split). Every shard
* job computes the same partition independently, because the input is the
* same commit. A file with no measurement yet counts as the median, so a new
* file lands somewhere sensible and the next regeneration fixes it.
*
* Inside a shard, files start longest first, so the slow ones are not left
* for the end of a parallel run. `--sequence.shuffle` replaces that order
* with a seeded shuffle; that is how the nightly run looks for order
* dependence.
*/
import { readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import { BaseSequencer, type TestSpecification } from "vitest/node";
export interface DurationFile {
/** What produced the numbers, for whoever wonders whether they are stale. */
source: string;
/** Seconds per file, keyed by the path relative to the repository root. */
files: Record<string, number>;
}
export function readDurations(path: string): Map<string, number> {
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as DurationFile;
return new Map(Object.entries(parsed.files));
} catch {
return new Map();
}
}
function median(values: number[]): number {
if (values.length === 0) return 1;
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.floor(sorted.length / 2)];
}
/**
* Split `items` into `count` groups of near-equal total weight. Pure, so the
* partition can be checked without a Vitest run.
*/
export function partitionByDuration<T>(
items: readonly T[],
count: number,
weight: (item: T) => number,
key: (item: T) => string,
): T[][] {
const groups: { total: number; items: T[] }[] = Array.from(
{ length: count },
() => ({ total: 0, items: [] }),
);
const ordered = [...items].sort(
(a, b) => weight(b) - weight(a) || key(a).localeCompare(key(b)),
);
for (const item of ordered) {
let target = groups[0];
for (const group of groups) {
if (group.total < target.total) target = group;
}
target.items.push(item);
target.total += weight(item);
}
return groups.map((g) => g.items);
}
/**
* `perFileSeconds` is what a file costs beyond its own reported duration:
* the worker importing the module graph and running the setup file, which
* the JSON report does not count. It dominates the unit suite (most files
* finish in milliseconds and spend half a second importing), so without it
* the split would hand one shard the thousands of quick files. Measured as
* (wall time × workers − the sum of file durations) / files.
*/
export function createDurationSequencer(
durationFile: string,
perFileSeconds: number,
) {
return class DurationSequencer extends BaseSequencer {
private durations: Map<string, number> | null = null;
private weightOf(spec: TestSpecification): number {
if (!this.durations) {
this.durations = readDurations(
resolve(this.ctx.config.root, durationFile),
);
}
const known = this.durations.get(this.pathOf(spec));
return (known ?? median([...this.durations.values()])) + perFileSeconds;
}
private pathOf(spec: TestSpecification): string {
return relative(this.ctx.config.root, spec.moduleId)
.split("\\")
.join("/");
}
override async shard(
files: TestSpecification[],
): Promise<TestSpecification[]> {
const { index, count } = this.ctx.config.shard ?? { index: 1, count: 1 };
const groups = partitionByDuration(
files,
count,
(spec) => this.weightOf(spec),
(spec) => `${spec.project.name}:${this.pathOf(spec)}`,
);
return groups[index - 1] ?? [];
}
override async sort(
files: TestSpecification[],
): Promise<TestSpecification[]> {
const { sequence } = this.ctx.config;
if (sequence.shuffle) return seededShuffle(files, sequence.seed);
return [...files].sort((a, b) => {
if (a.project.name !== b.project.name) {
return a.project.name < b.project.name ? -1 : 1;
}
return (
this.weightOf(b) - this.weightOf(a) ||
this.pathOf(a).localeCompare(this.pathOf(b))
);
});
}
};
}
/** Fisher-Yates driven by a small deterministic generator (mulberry32). */
function seededShuffle<T>(items: readonly T[], seed: number): T[] {
let state = seed >>> 0;
const next = () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const out = [...items];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(next() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,319 @@
/**
* A randomly generated account survives a disaster-recovery backup and
* restore unchanged.
*
* `backup-round-trip.test.ts` proves one hand-seeded row of every model and
* one value of every column. This file generates whole accounts instead:
* measurements of mixed types and sources, medications with weekly and daily
* schedules and intake histories, mood entries, and a handful of settings, in
* combinations nobody wrote down. Each account is exported the way the weekly
* worker exports it, the account is deleted and recreated under the same id,
* the file is restored through the operator's restore route, and a canonical
* dump of every generated table must come back identical.
*
* Seeded like every property test (tests/property/fast-check.ts); a failure
* prints the seed and the smallest account that still fails.
*/
import fc from "fast-check";
import { beforeEach, describe, expect, it } from "vitest";
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
import type { Prisma } from "@/generated/prisma/client";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { assertAsyncProperty } from "../property/fast-check";
import { callRoute } from "../kit/call-route";
import { makeAdmin, makeSession, makeUser } from "../kit/factories";
import { POST as restoreBackup } from "./restore-job-driver";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
const OWNER_ID = "random-account-owner";
const MEASUREMENTS: ReadonlyArray<{
type: Prisma.MeasurementCreateManyInput["type"];
unit: string;
min: number;
max: number;
}> = [
{ type: "WEIGHT", unit: "kg", min: 40, max: 160 },
{ type: "BLOOD_PRESSURE_SYS", unit: "mmHg", min: 90, max: 190 },
{ type: "BLOOD_PRESSURE_DIA", unit: "mmHg", min: 50, max: 120 },
{ type: "PULSE", unit: "bpm", min: 40, max: 180 },
{ type: "BLOOD_GLUCOSE", unit: "mg/dL", min: 50, max: 300 },
{ type: "ACTIVITY_STEPS", unit: "steps", min: 0, max: 30_000 },
];
const SOURCES = ["MANUAL", "WITHINGS", "APPLE_HEALTH", "IMPORT"] as const;
const instant = fc
.integer({
min: Date.parse("2024-01-01T00:00:00Z") / 60_000,
max: Date.parse("2026-09-30T00:00:00Z") / 60_000,
})
.map((minutes) => new Date(minutes * 60_000));
const measurementArb = fc.record({
kind: fc.nat({ max: MEASUREMENTS.length - 1 }),
source: fc.constantFrom(...SOURCES),
measuredAt: instant,
fraction: fc.double({ min: 0, max: 1, noNaN: true }),
externalId: fc.option(fc.uuid(), { nil: null }),
});
const scheduleArb = fc.oneof(
fc.record({
windowStart: fc.constantFrom("07:00", "08:00", "20:00"),
daysOfWeek: fc.constantFrom(null, "1,3,5", "0,6"),
rrule: fc.constant(null),
timesOfDay: fc.constant<string[]>([]),
}),
fc.record({
windowStart: fc.constantFrom("06:30", "12:00"),
daysOfWeek: fc.constant(null),
rrule: fc.constantFrom("FREQ=DAILY", "FREQ=WEEKLY;BYDAY=MO,TH"),
timesOfDay: fc.uniqueArray(fc.constantFrom("06:30", "12:00", "21:15"), {
minLength: 1,
maxLength: 2,
}),
}),
);
const intakeArb = fc.record({
scheduledFor: instant,
outcome: fc.constantFrom("taken", "skipped", "missed"),
});
const medicationArb = fc.record({
name: fc.constantFrom("Ramipril", "Metformin", "Vitamin D", "Levothyroxin"),
dose: fc.constantFrom("5mg", "500mg", "1000 IE", "50µg"),
asNeeded: fc.boolean(),
active: fc.boolean(),
schedules: fc.array(scheduleArb, { minLength: 1, maxLength: 2 }),
intakes: fc.uniqueArray(intakeArb, {
maxLength: 5,
selector: (i) => i.scheduledFor.getTime(),
}),
});
const MOODS = [
["SUPER_GUT", 5],
["GUT", 4],
["OKAY", 3],
["SCHLECHT", 2],
["LAUSIG", 1],
] as const;
const moodArb = fc.record({
loggedAt: instant,
level: fc.nat({ max: 4 }),
dims: fc.option(fc.tuple(fc.nat({ max: 10 }), fc.nat({ max: 10 })), {
nil: null,
}),
});
const settingsArb = fc.record(
{
unitPreference: fc.constantFrom("metric", "imperial"),
glucoseUnit: fc.constantFrom("mg/dL", "mmol/L"),
timezone: fc.constantFrom(
"Europe/Berlin",
"America/New_York",
"Asia/Kolkata",
),
heightCm: fc.integer({ min: 140, max: 210 }),
hasDiabetes: fc.boolean(),
},
{ requiredKeys: [] },
);
const accountArb = fc.record({
measurements: fc.uniqueArray(measurementArb, {
maxLength: 25,
selector: (m) => `${m.kind}|${m.source}|${m.measuredAt.getTime()}`,
}),
medications: fc.array(medicationArb, { maxLength: 3 }),
moods: fc.uniqueArray(moodArb, {
maxLength: 6,
selector: (m) => m.loggedAt.toISOString().slice(0, 10),
}),
settings: settingsArb,
});
type Account = typeof accountArb extends fc.Arbitrary<infer T> ? T : never;
async function seed(account: Account): Promise<void> {
await makeUser({ id: OWNER_ID, ...account.settings });
await prisma.measurement.createMany({
data: account.measurements.map((m) => {
const kind = MEASUREMENTS[m.kind];
const value =
Math.round((kind.min + m.fraction * (kind.max - kind.min)) * 10) / 10;
return {
userId: OWNER_ID,
type: kind.type,
unit: kind.unit,
value,
source: m.source,
measuredAt: m.measuredAt,
externalId: m.externalId,
};
}),
});
for (const med of account.medications) {
const created = await prisma.medication.create({
data: {
userId: OWNER_ID,
name: med.name,
dose: med.dose,
asNeeded: med.asNeeded,
active: med.active,
schedules: {
create: med.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowStart,
daysOfWeek: s.daysOfWeek,
rrule: s.rrule,
timesOfDay: s.timesOfDay,
})),
},
},
});
await prisma.medicationIntakeEvent.createMany({
data: med.intakes.map((i) => ({
userId: OWNER_ID,
medicationId: created.id,
scheduledFor: i.scheduledFor,
takenAt:
i.outcome === "taken"
? new Date(i.scheduledFor.getTime() + 600_000)
: null,
skipped: i.outcome === "skipped",
})),
});
}
await prisma.moodEntry.createMany({
data: account.moods.map((m) => {
const [mood, score] = MOODS[m.level];
return {
userId: OWNER_ID,
date: m.loggedAt.toISOString().slice(0, 10),
mood,
score,
moodLoggedAt: m.loggedAt,
tz: "UTC",
moodA1: m.dims?.[0] ?? null,
stressA2: m.dims?.[1] ?? null,
};
}),
});
}
/**
* Columns a restore legitimately rewrites: the bookkeeping Prisma stamps on
* every write, and the per-row sync counters the restore bumps so a paired
* client re-reads the rows.
*/
const VOLATILE = new Set(["updatedAt", "syncedAt", "syncVersion"]);
function canonical(rows: ReadonlyArray<object>): string[] {
return rows
.map((row) =>
JSON.stringify(
Object.fromEntries(
Object.entries(row)
.filter(([key]) => !VOLATILE.has(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => [
key,
value instanceof Date ? value.toISOString() : value,
]),
),
),
)
.sort();
}
async function dump(): Promise<Record<string, string[]>> {
const where = { userId: OWNER_ID };
const user = await prisma.user.findUniqueOrThrow({
where: { id: OWNER_ID },
select: {
unitPreference: true,
glucoseUnit: true,
timezone: true,
heightCm: true,
hasDiabetes: true,
},
});
return {
user: canonical([user]),
measurements: canonical(await prisma.measurement.findMany({ where })),
medications: canonical(await prisma.medication.findMany({ where })),
schedules: canonical(
await prisma.medicationSchedule.findMany({
where: { medication: where },
}),
),
intakes: canonical(await prisma.medicationIntakeEvent.findMany({ where })),
moods: canonical(await prisma.moodEntry.findMany({ where })),
};
}
beforeEach(async () => {
await truncateAllTables(prisma);
});
describe("a generated account", () => {
it("comes back from a disaster-recovery backup exactly as it left", async () => {
await assertAsyncProperty(
fc.asyncProperty(accountArb, async (account) => {
await truncateAllTables(prisma);
await seed(account);
const before = await dump();
let json = "";
await streamFullBackupJson(
prisma,
OWNER_ID,
(chunk) => {
json += chunk;
},
{
purpose: "disaster-recovery",
exportedAt: new Date("2026-10-01T00:00:00.000Z"),
},
);
await prisma.user.delete({ where: { id: OWNER_ID } });
await makeUser({ id: OWNER_ID });
const backup = await prisma.dataBackup.create({
data: {
userId: OWNER_ID,
type: "RANDOM_ACCOUNT_ROUND_TRIP",
data: await legacyStreamedBlobFrom(async (write) => {
await write(json);
}),
},
});
const operator = await makeSession(await makeAdmin());
const restored = await callRoute(restoreBackup, {
method: "POST",
route: "/api/admin/backups/{id}/restore",
params: { id: backup.id },
body: { confirm: "RESTORE" },
auth: operator,
contract: {
skip: "the restore driver answers 200 with the job's report in place of the route's queued 202",
},
});
expect(
restored.status,
JSON.stringify(restored.body).slice(0, 300),
).toBe(200);
expect(await dump()).toEqual(before);
}),
12,
);
});
});
+5 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getPrismaClient } from "./setup"; import { getPrismaClient } from "./setup";
import { TEMPLATE_DATABASE, workerDatabaseName } from "./worker-database";
const TEST_ENCRYPTION_KEY = const TEST_ENCRYPTION_KEY =
"0000000000000000000000000000000000000000000000000000000000000000"; "0000000000000000000000000000000000000000000000000000000000000000";
@@ -22,6 +23,9 @@ describe("integration worker environment", () => {
>` >`
SELECT current_database() AS database_name SELECT current_database() AS database_name
`; `;
expect(rows).toEqual([{ database_name: "healthlog_test" }]); // Each worker runs against its own clone, never the migrated template
// the clones are copied from.
expect(rows).toEqual([{ database_name: workerDatabaseName() }]);
expect(rows[0].database_name).not.toBe(TEMPLATE_DATABASE);
}); });
}); });
+51 -7
View File
@@ -1,12 +1,15 @@
import { afterEach, inject, vi } from "vitest"; import { afterEach, beforeEach, inject, vi } from "vitest";
declare module "vitest" { import { quarantineEntryFor } from "../quarantine/quarantine";
export interface ProvidedContext { import { prepareWorkerDatabase } from "./worker-database";
integrationDatabaseUrl: string;
}
}
process.env.DATABASE_URL = inject("integrationDatabaseUrl"); // Each worker runs against its own clone of the migrated template (see
// `worker-database.ts`). The URL is final before any application module is
// imported, so the Prisma singleton is built against the clone.
process.env.DATABASE_URL = await prepareWorkerDatabase(
inject("integrationDatabaseUrl"),
inject("integrationDatabasePlan"),
);
process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY =
"0000000000000000000000000000000000000000000000000000000000000000"; "0000000000000000000000000000000000000000000000000000000000000000";
delete process.env.ENCRYPTION_KEYS; delete process.env.ENCRYPTION_KEYS;
@@ -33,6 +36,47 @@ afterEach(async () => {
await settleBackgroundTasks(); await settleBackgroundTasks();
}); });
/** The flaky-test quarantine, as in the unit setup (`vitest.setup.ts`). */
beforeEach((context) => {
const entry = quarantineEntryFor(context.task);
if (entry) {
context.skip(`quarantined until ${entry.expires} (${entry.issue})`);
}
});
/**
* Route handlers read cookies and headers through `next/headers`, which only
* works inside a Next.js request scope. Every file gets the same stand-in,
* backed by the shared jars in `mock-next-headers.ts`, and the same no-op for
* the boot-time compatibility probe. A file that needs different behaviour
* still declares its own `vi.mock`, which takes precedence over this one.
*/
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
has: (name: string) => headerJar.has(name.toLowerCase()),
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
/** /**
* The same pin the unit setup applies (see `vitest.setup.ts`): the Fitbit * The same pin the unit setup applies (see `vitest.setup.ts`): the Fitbit
* suites run before the Web API turndown, whatever the real date, and a test * suites run before the Web API turndown, whatever the real date, and a test
+158 -13
View File
@@ -1,47 +1,84 @@
/** /**
* Vitest globalSetup — boots ONE Postgres testcontainer for the whole * Vitest globalSetup — boots ONE Postgres testcontainer for the whole
* integration run, applies migrations, and exposes the connection URL * integration run, migrates ONE template database, and hands the workers
* via process.env.DATABASE_URL. The teardown returned at the end stops * what they need to clone it. The teardown returned at the end stops the
* the container after all test files have finished. * container after all test files have finished.
* *
* Why globalSetup instead of beforeAll/afterAll per file: * Why one container and a template:
* - Applying the migration history takes seconds; copying a migrated
* database with `CREATE DATABASE … TEMPLATE` takes a fraction of one.
* Each Vitest worker therefore gets its own clone (see
* `worker-database.ts`), cloned once per worker rather than once per
* file or test, and files run in parallel without seeing each other's
* rows, advisory locks or queue schema.
* - The application's Prisma singleton in `src/lib/db.ts` is built at * - The application's Prisma singleton in `src/lib/db.ts` is built at
* module load time using the current `process.env.DATABASE_URL`. If * module load time from `process.env.DATABASE_URL`. A worker's URL is
* the container is rotated between test files, the singleton keeps * fixed before any application module loads (`environment-setup.ts`),
* pointing at the dead container and queries fail. Booting once, * so the singleton always points at that worker's clone.
* keeping the URL stable, and truncating between tests is both *
* faster and safer. * What the template carries besides the schema is recorded here as well:
* - Truncate-between-tests still gives per-test isolation; see * the rows the migrations seed (the mood-tag and symptom catalogues, the
* `setup.ts -> truncateAllTables`. * settings singleton). `setup.ts -> truncateAllTables` empties every table
* and puts exactly these rows back, so a test starts from the state a fresh
* install has, and the truncate list is the catalogue rather than a list
* somebody has to remember to extend.
*/ */
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { Client } from "pg";
import type { TestProject } from "vitest/node"; import type { TestProject } from "vitest/node";
import { import {
PostgreSqlContainer, PostgreSqlContainer,
type StartedPostgreSqlContainer, type StartedPostgreSqlContainer,
} from "@testcontainers/postgresql"; } from "@testcontainers/postgresql";
import {
TEMPLATE_DATABASE,
type IntegrationDatabasePlan,
type ReferenceTable,
} from "./worker-database";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const PROJECT_ROOT = resolve(__dirname, "..", ".."); const PROJECT_ROOT = resolve(__dirname, "..", "..");
let container: StartedPostgreSqlContainer | null = null; let container: StartedPostgreSqlContainer | null = null;
declare module "vitest" {
export interface ProvidedContext {
integrationDatabaseUrl: string;
integrationDatabasePlan: IntegrationDatabasePlan;
}
}
export default async function setup( export default async function setup(
project: TestProject, project: TestProject,
): Promise<() => Promise<void>> { ): Promise<() => Promise<void>> {
container = await new PostgreSqlContainer("postgres:16-alpine") container = await new PostgreSqlContainer("postgres:16-alpine")
.withDatabase("healthlog_test") .withDatabase(TEMPLATE_DATABASE)
.withUsername("healthlog") .withUsername("healthlog")
.withPassword("healthlog") .withPassword("healthlog")
// A throwaway cluster: durability buys nothing here and costs every
// commit a flush. Several workers each hold a Prisma pool and, in the
// queue tests, a pg-boss pool, so the default of 100 connections is
// raised well past what parallel workers can open.
.withCommand([
"postgres",
"-c",
"fsync=off",
"-c",
"synchronous_commit=off",
"-c",
"full_page_writes=off",
"-c",
"max_connections=400",
])
.start(); .start();
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false"; const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
process.env.DATABASE_URL = url; process.env.DATABASE_URL = url;
project.provide("integrationDatabaseUrl", url);
// The explicit child environment keeps Prisma CLI on the same container. // The explicit child environment keeps Prisma CLI on the same container.
execSync("pnpm db:migrate:deploy", { execSync("pnpm db:migrate:deploy", {
@@ -50,6 +87,9 @@ export default async function setup(
env: { ...process.env, DATABASE_URL: url }, env: { ...process.env, DATABASE_URL: url },
}); });
project.provide("integrationDatabaseUrl", url);
project.provide("integrationDatabasePlan", await readTemplate(url));
return async () => { return async () => {
if (container) { if (container) {
await container.stop({ remove: true }); await container.stop({ remove: true });
@@ -57,3 +97,108 @@ export default async function setup(
} }
}; };
} }
/**
* Read what a fresh install holds: its schemas, its tables, and the rows the
* migrations seed, in an order that satisfies the foreign keys between them.
* The connection is closed before any worker clones the template, because
* Postgres refuses to copy a database somebody is connected to.
*/
async function readTemplate(url: string): Promise<IntegrationDatabasePlan> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const schemas = (
await client.query<{ nspname: string }>(
`SELECT nspname FROM pg_namespace
WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema'
ORDER BY nspname`,
)
).rows.map((r) => r.nspname);
const tables = (
await client.query<{ tablename: string }>(
`SELECT tablename FROM pg_tables
WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'
ORDER BY tablename`,
)
).rows.map((r) => r.tablename);
if (tables.length < 100) {
throw new Error(
`the migrated template has only ${tables.length} tables; the migration step did not run`,
);
}
const seeded: string[] = [];
for (const table of tables) {
const { rows } = await client.query<{ present: boolean }>(
`SELECT EXISTS (SELECT 1 FROM "${table}") AS present`,
);
if (rows[0]?.present) seeded.push(table);
}
// A seeded table whose key comes from a sequence would collide with the
// next insert once `RESTART IDENTITY` rewinds it. None does today; the
// check keeps it that way rather than leaving a silent collision.
const sequenced = await client.query<{ table: string }>(
`SELECT DISTINCT c.relname AS table
FROM pg_depend d
JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S'
JOIN pg_class c ON c.oid = d.refobjid
WHERE c.relname = ANY($1::text[])`,
[seeded],
);
if (sequenced.rows.length > 0) {
throw new Error(
`seeded tables with sequence-backed keys are not supported by the reseed: ${sequenced.rows.map((r) => r.table).join(", ")}`,
);
}
const edges = (
await client.query<{ child: string; parent: string }>(
`SELECT conrelid::regclass::text AS child, confrelid::regclass::text AS parent
FROM pg_constraint
WHERE contype = 'f'
AND conrelid::regclass::text = ANY($1::text[])
AND confrelid::regclass::text = ANY($1::text[])
AND conrelid <> confrelid`,
[seeded],
)
).rows;
const reference: ReferenceTable[] = [];
for (const table of topologicalOrder(seeded, edges)) {
const { rows } = await client.query<{ rows: string }>(
`SELECT COALESCE(json_agg(t), '[]'::json)::text AS rows FROM "${table}" t`,
);
reference.push({ table, rowsJson: rows[0].rows });
}
return { templateSchemas: schemas, tables, reference };
} finally {
await client.end();
}
}
function topologicalOrder(
tables: readonly string[],
edges: ReadonlyArray<{ child: string; parent: string }>,
): string[] {
const ordered: string[] = [];
const pending = new Set(tables);
while (pending.size > 0) {
const ready = [...pending].filter(
(t) => !edges.some((e) => e.child === t && pending.has(e.parent)),
);
if (ready.length === 0) {
throw new Error(
`seeded tables reference each other in a cycle: ${[...pending].join(", ")}`,
);
}
for (const t of ready.sort()) {
ordered.push(t);
pending.delete(t);
}
}
return ordered;
}
+10 -13
View File
@@ -1,20 +1,17 @@
/** /**
* Shared `next/headers` mock state for the integration suite. * Shared `next/headers` mock state for the integration suite.
* *
* Background: vitest runs the integration suite with `isolate: false` * `environment-setup.ts` mocks `next/headers` for every file with a stand-in
* (one worker, one container). The factory passed to * that reads these jars, so a test sets a cookie or a header here and the
* `vi.mock("next/headers", ...)` is resolved ONCE per worker — * route handler under test sees it. Files are isolated (`isolate: true`), so
* whichever test file loads first wins. If two files each declare a * each file gets fresh jars; clear them in `beforeEach` to keep cases
* top-level `const cookieJar = new Map()` and reference it from their * independent. The route test kit (`tests/kit/`) fills them from the `auth`
* own `vi.mock("next/headers", ...)` factory, only the first file's * option of `callRoute`, so a kit test never touches them directly.
* Map is ever read; the second file's writes silently disappear and
* its tests flake based on import order.
* *
* Fix: every integration file imports `cookieJar` + `headerJar` from * Many files still declare their own identical `vi.mock("next/headers")`.
* THIS module (a singleton) and clears them in `beforeEach`. The * That predates the shared one, is harmless (a file's own mock wins), and is
* `vi.mock` factory inside each test file resolves the Maps via * only needed where a file wants different behaviour, such as consuming
* `await import()` so it sees the same Map instances regardless of * `queuedSessionIds` below.
* which file's mock factory ran first.
*/ */
export const cookieJar = new Map<string, string>(); export const cookieJar = new Map<string, string>();
export const headerJar = new Map<string, string>(); export const headerJar = new Map<string, string>();
@@ -153,7 +153,8 @@ async function untilHeldInTrigger() {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
for (let i = 0; i < 100; i++) { for (let i = 0; i < 100; i++) {
const rows = await prisma.$queryRaw<Array<{ n: bigint }>>` const rows = await prisma.$queryRaw<Array<{ n: bigint }>>`
SELECT count(*) AS n FROM pg_stat_activity WHERE wait_event = 'PgSleep' SELECT count(*) AS n FROM pg_stat_activity
WHERE wait_event = 'PgSleep' AND datname = current_database()
`; `;
if (Number(rows[0]?.n ?? 0) > 0) return; if (Number(rows[0]?.n ?? 0) > 0) return;
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
@@ -68,6 +68,7 @@ async function waitForRollupLockWaiter(timeoutMs = 5_000): Promise<boolean> {
SELECT count(*)::bigint AS n SELECT count(*)::bigint AS n
FROM pg_stat_activity FROM pg_stat_activity
WHERE wait_event_type = 'Lock' WHERE wait_event_type = 'Lock'
AND datname = current_database()
AND query ILIKE '%measurement_rollups%' AND query ILIKE '%measurement_rollups%'
`; `;
if (Number(rows[0]?.n ?? 0) > 0) return true; if (Number(rows[0]?.n ?? 0) > 0) return true;
@@ -0,0 +1,236 @@
/**
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level, within the
* operator's ceiling, against the real database and through the real API kit.
*
* Pinned here:
* - a level within the ceiling is stored, audited, and answered as the
* resolved state, with the per-option estimates scaled from the measured
* daily tokens of the last week;
* - a level above the ceiling is refused with 422 `ai.usage.aboveCeiling`
* and the ceiling named, never capped silently, and accepted once the
* operator raises the ceiling;
* - `null` returns to the instance default; fewer than three measured days
* give no estimate;
* - a malformed body is 422 `ai.usage.invalid`; the 31st change in an hour
* is 429 `ai.usage.rateLimited`; an anonymous caller is 401.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { PUT } from "@/app/api/auth/me/ai-usage/route";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/ai-usage";
type UsageOption = {
level: string;
allowed: boolean;
estimatedTokensPerDay: number | null;
};
type AiUsageState = {
level: string;
preference: string | null;
defaultLevel: string;
maxLevel: string;
source: string;
measuredTokensPerDay: number | null;
measuredDays: number;
options: UsageOption[];
};
beforeEach(() => truncateAllTables(prisma));
async function storedLevel(userId: string) {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { aiUsageLevel: true },
});
return row.aiUsageLevel;
}
/** One `coach_usage` row per past day, yesterday first, in UTC. */
async function seedDailyTokens(userId: string, perDay: number[]) {
const today = userDayKey(new Date(), "UTC");
await prisma.coachUsage.createMany({
data: perDay.map((totalTokens, i) => ({
userId,
dateKey: shiftDateKey(today, -(i + 1)),
totalTokens,
})),
});
}
describe("PUT /api/auth/me/ai-usage", () => {
it("stores a level within the ceiling and answers the resolved state", async () => {
const user = await makeUser({ timezone: "UTC" });
await seedDailyTokens(user.id, [90_000, 110_000, 100_000]);
const res = await callRoute<AiUsageState>(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await storedLevel(user.id)).toBe("FRUGAL");
expect(res.data).toMatchObject({
level: "frugal",
preference: "frugal",
defaultLevel: "standard",
maxLevel: "standard",
source: "user",
measuredTokensPerDay: 100_000,
measuredDays: 3,
});
expect(res.data.options).toEqual([
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
]);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.aiUsage.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: null,
next: "frugal",
});
});
it("refuses a level above the ceiling with the ceiling named", async () => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "full" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta).toMatchObject({
errorCode: "ai.usage.aboveCeiling",
maxLevel: "standard",
});
expect(await storedLevel(user.id)).toBeNull();
});
it("accepts full once the operator allows it", async () => {
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", aiUsageMaxLevel: "FULL" },
update: { aiUsageMaxLevel: "FULL" },
});
const user = await makeUser();
const res = await callRoute<AiUsageState>(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "full" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.level).toBe("full");
expect(await storedLevel(user.id)).toBe("FULL");
});
it("returns to the instance default on null", async () => {
const user = await makeUser({ timezone: "UTC", aiUsageLevel: "FRUGAL" });
// Two measured days: fewer than the three an estimate needs.
await seedDailyTokens(user.id, [100_000, 100_000]);
const res = await callRoute<AiUsageState>(PUT, {
method: "PUT",
route: ROUTE,
body: { level: null },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await storedLevel(user.id)).toBeNull();
expect(res.data).toMatchObject({
level: "standard",
preference: null,
source: "default",
});
expect(res.data.options.map((o) => o.estimatedTokensPerDay)).toEqual([
null,
null,
null,
]);
});
it.each([
[{ level: "lavish" }],
[{}],
[{ level: "frugal", extra: true }],
[{ level: 1 }],
])("refuses %j as invalid and stores nothing", async (body) => {
const user = await makeUser({ aiUsageLevel: "STANDARD" });
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("ai.usage.invalid");
expect(await storedLevel(user.id)).toBe("STANDARD");
});
it("allows 30 changes an hour and refuses the 31st", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 30; i++) {
const ok = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: i % 2 === 0 ? "frugal" : "standard" },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: session,
});
expect(refused.status).toBe(429);
expect(refused.meta?.errorCode).toBe("ai.usage.rateLimited");
// The 30th change set standard; the refused one did not land.
expect(await storedLevel(user.id)).toBe("STANDARD");
// The window is an hour: the account's bucket in Postgres resets about
// sixty minutes after the first change, not after one.
const [bucket] = await prisma.$queryRaw<Array<{ secondsLeft: number }>>`
SELECT EXTRACT(EPOCH FROM reset_at - (NOW() AT TIME ZONE 'UTC'))::float8
AS "secondsLeft"
FROM rate_limits WHERE key = ${`ai-usage:${user.id}`}
`;
expect(bucket?.secondsLeft).toBeGreaterThan(55 * 60);
expect(bucket?.secondsLeft).toBeLessThanOrEqual(60 * 60);
const neighbour = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { level: "frugal" },
auth: anonymous,
});
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,244 @@
/**
* `/api/auth/me/coach-prefs` — the Coach prompt-tuning preferences, against
* the real database and through the real API kit.
*
* Pinned here:
* - a saved account reads its prefs plus the `updatedAt` token; a
* never-saved one reads the pure defaults and no token, because there is
* nothing to guard yet;
* - the PUT stores the defaulted form and mirrors `excludeMetrics` onto
* `insightsExcludeMetrics` in the same write; with a base token the write
* is conditional, and a stale token answers 409 and leaves both columns
* as they were; without one the write is unconditional; a malformed token
* is 422;
* - the thinking depth is stored as chosen, and the wide event names it,
* an absent level as the default; a level outside the set is 422.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PUT } from "@/app/api/auth/me/coach-prefs/route";
import { readLogBuffer } from "@/lib/logging/in-memory-buffer";
import { anonymous, callRoute, type RouteResult } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/coach-prefs";
type CoachPrefs = {
tone: string;
excludeMetrics: string[];
reasoning?: string;
updatedAt?: string;
};
beforeEach(() => truncateAllTables(prisma));
async function storedColumns(userId: string) {
return prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
coachPrefsJson: true,
insightsExcludeMetrics: true,
updatedAt: true,
},
});
}
/** The `meta` the route put on its wide event, read from the log ring. */
function putEventMeta(res: RouteResult): Record<string, unknown> | undefined {
const requestId = res.headers.get("x-request-id");
if (!requestId) throw new Error("the response carries no x-request-id");
const [event] = readLogBuffer({
requestId,
action: "auth.me.coach-prefs.put",
});
return event?.meta;
}
describe("GET /api/auth/me/coach-prefs", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
});
it("answers a saved account's prefs plus the optimistic-concurrency token", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "concise" } });
const res = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.tone).toBe("concise");
const row = await storedColumns(user.id);
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
});
it("answers the pure defaults with no token for a never-saved account", async () => {
const user = await makeUser();
const res = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.tone).toBe("warm");
expect(res.data.excludeMetrics).toEqual([]);
expect(res.data).not.toHaveProperty("updatedAt");
});
});
describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => {
it("writes both columns under a current token and echoes the fresh one", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
const session = await makeSession(user);
const read = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
const res = await callRoute<CoachPrefs>(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"], baseUpdatedAt: read.data.updatedAt },
auth: session,
});
expect(res.status).toBe(200);
const row = await storedColumns(user.id);
expect(row.coachPrefsJson).toMatchObject({
tone: "warm",
excludeMetrics: ["weight"],
});
expect(row.insightsExcludeMetrics).toEqual(["weight"]);
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
expect(res.data.updatedAt).not.toBe(read.data.updatedAt);
});
it("answers a stale token with 409 and leaves both columns untouched", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
const session = await makeSession(user);
const read = await callRoute<CoachPrefs>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
// Another device saves first, with the same token.
const first = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["mood"], baseUpdatedAt: read.data.updatedAt },
auth: session,
});
expect(first.status).toBe(200);
const afterFirst = await storedColumns(user.id);
const stale = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"], baseUpdatedAt: read.data.updatedAt },
auth: session,
});
expect(stale.status).toBe(409);
expect(stale.meta?.errorCode).toBe("coach_prefs_conflict");
expect(await storedColumns(user.id)).toEqual(afterFirst);
expect(afterFirst.insightsExcludeMetrics).toEqual(["mood"]);
});
it("writes unconditionally when the token is omitted", async () => {
const user = await makeUser({ coachPrefsJson: { tone: "neutral" } });
const res = await callRoute<CoachPrefs>(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"] },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
const row = await storedColumns(user.id);
expect(row.insightsExcludeMetrics).toEqual(["weight"]);
expect(row.coachPrefsJson).toMatchObject({ excludeMetrics: ["weight"] });
expect(res.data.updatedAt).toBe(row.updatedAt.toISOString());
});
it("answers a malformed token with 422 and leaves the row alone", async () => {
const user = await makeUser();
const before = await storedColumns(user.id);
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("invalid_base_updated_at");
expect(await storedColumns(user.id)).toEqual(before);
});
it("answers a body that is not JSON with 400 and its error code", async () => {
const user = await makeUser();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
});
expect(res.status).toBe(400);
expect(res.meta?.errorCode).toBe("coach-prefs.body.invalid_json");
});
});
describe("PUT /api/auth/me/coach-prefs — thinking depth", () => {
it("stores the chosen level and names it on the wide event", async () => {
const user = await makeUser();
const res = await callRoute<CoachPrefs>(PUT, {
method: "PUT",
route: ROUTE,
body: { reasoning: "high" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data.reasoning).toBe("high");
const row = await storedColumns(user.id);
expect(row.coachPrefsJson).toMatchObject({ reasoning: "high" });
expect(putEventMeta(res)).toMatchObject({ reasoning: "high" });
});
it("reads an absent level as the default on the wide event", async () => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { tone: "warm" },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(putEventMeta(res)).toMatchObject({ reasoning: "medium" });
});
it.each(["xhigh", "none", 2, null])(
"refuses %s as a level with 422 and stores nothing",
async (value) => {
const user = await makeUser();
const res = await callRoute(PUT, {
method: "PUT",
route: ROUTE,
body: { reasoning: value },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.meta?.errorCode).toBe("coach-prefs.body.invalid_shape");
const row = await storedColumns(user.id);
expect(row.coachPrefsJson).toBeNull();
},
);
});
@@ -0,0 +1,323 @@
/**
* `/api/auth/me/devices` and `/api/auth/me/devices/{id}`: the registered
* device list and the device revoke, against the real database and through
* the real API kit.
*
* What a person relies on here:
* - the list shows the account's own devices, most recently seen first,
* with their delivery channels, and nothing that could be replayed to
* send a notification (no push token, no APNs identifier);
* - the "this device" badge follows `X-Device-Id` only when that device
* holds a live refresh token of this account, so a forged header cannot
* move it;
* - the revoke reaches only the caller's own devices, removes the row,
* revokes every live refresh token bound to it and the access tokens they
* pair with, and those access tokens stop authenticating at once.
*/
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it } from "vitest";
import { GET } from "@/app/api/auth/me/devices/route";
import { DELETE } from "@/app/api/auth/me/devices/[id]/route";
import { hashToken } from "@/lib/auth/hmac";
import type { Prisma, User } from "@/generated/prisma/client";
import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route";
import {
makeSession,
makeToken,
makeUser,
uniqueSuffix,
type BearerFixture,
} from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
type DeviceRow = {
id: string;
label: string;
platform: string;
appVersion: string | null;
locale: string | null;
channels: Array<"web_push" | "apns">;
lastSeenAt: string;
createdAt: string;
isCurrent: boolean;
};
type RevokeResult = {
id: string;
revoked: boolean;
refreshTokensRevoked: number;
accessTokensRevoked: number;
};
beforeEach(async () => {
await truncateAllTables(prisma);
});
async function makeDevice(
user: Pick<User, "id">,
overrides: Partial<Omit<Prisma.DeviceUncheckedCreateInput, "userId">> = {},
) {
return prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token: `device-token-${uniqueSuffix()}`,
bundleId: "io.healthlog.app",
...overrides,
},
});
}
/**
* A refresh token bound to a device, paired with an access token the way
* the native sign-in pairs them. `access: null` stores a refresh row with no
* access-token pairing.
*/
async function makeRefresh(
user: Pick<User, "id">,
deviceId: string,
options: { access?: BearerFixture | null; revokedAt?: Date } = {},
) {
return prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: createHash("sha256")
.update(`refresh-${uniqueSuffix()}`)
.digest("hex"),
deviceId,
accessTokenHash: options.access ? hashToken(options.access.token) : null,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
revokedAt: options.revokedAt ?? null,
},
});
}
async function listDevices(auth: RouteAuth, deviceIdHeader?: string) {
return callRoute<{ devices: DeviceRow[] }>(GET, {
method: "GET",
route: "/api/auth/me/devices",
auth,
headers: deviceIdHeader ? { "x-device-id": deviceIdHeader } : undefined,
});
}
describe("GET /api/auth/me/devices", () => {
it("answers 401 without a credential", async () => {
expect((await listDevices(anonymous)).status).toBe(401);
});
it("lists the caller's devices most recently seen first, with channels and no push identifiers", async () => {
const owner = await makeUser();
const other = await makeUser();
const phone = await makeDevice(owner, {
model: "iPhone 14 Pro",
locale: "en-US",
appVersion: "1.0",
apnsToken: "apns-secret-value",
apnsEnvironment: "production",
lastSeen: new Date("2026-05-10T12:00:00Z"),
});
const tablet = await makeDevice(owner, {
model: null,
lastSeen: new Date("2026-05-09T12:00:00Z"),
});
await makeDevice(other, { model: "Not yours" });
const res = await listDevices(await makeSession(owner));
expect(res.status).toBe(200);
expect(res.data.devices).toEqual([
{
id: phone.id,
label: "iPhone 14 Pro",
platform: "ios",
appVersion: "1.0",
locale: "en-US",
channels: ["web_push", "apns"],
lastSeenAt: "2026-05-10T12:00:00.000Z",
createdAt: phone.createdAt.toISOString(),
isCurrent: false,
},
expect.objectContaining({
id: tablet.id,
// No model: the bundle id labels the row rather than a blank.
label: "io.healthlog.app",
channels: ["web_push"],
isCurrent: false,
}),
]);
const serialised = JSON.stringify(res.body);
expect(serialised).not.toContain(phone.token);
expect(serialised).not.toContain("apns-secret-value");
});
it("marks the device named by X-Device-Id when it holds a live refresh token of this account", async () => {
const owner = await makeUser();
const phone = await makeDevice(owner);
const tablet = await makeDevice(owner);
const access = await makeToken(owner);
await makeRefresh(owner, tablet.id, { access });
const res = await listDevices(access, tablet.id);
expect(res.status).toBe(200);
expect(res.data.devices.map((d) => [d.id, d.isCurrent]).sort()).toEqual(
[
[phone.id, false],
[tablet.id, true],
].sort(),
);
});
it("ignores an X-Device-Id that no live refresh token of this account anchors", async () => {
const owner = await makeUser();
const other = await makeUser();
const forged = await makeDevice(owner);
const revoked = await makeDevice(owner);
const foreignAnchor = await makeDevice(owner);
await makeRefresh(owner, revoked.id, { revokedAt: new Date() });
// Another account's refresh row naming this device id anchors nothing.
await makeRefresh(other, foreignAnchor.id);
const access = await makeToken(owner);
for (const header of [forged.id, revoked.id, foreignAnchor.id]) {
const res = await listDevices(access, header);
expect(res.status).toBe(200);
expect(res.data.devices.some((d) => d.isCurrent)).toBe(false);
}
});
});
describe("DELETE /api/auth/me/devices/{id}", () => {
it("answers 401 without a credential and leaves the device", async () => {
const owner = await makeUser();
const device = await makeDevice(owner);
const res = await callRoute(DELETE, {
method: "DELETE",
route: "/api/auth/me/devices/{id}",
params: { id: device.id },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(await prisma.device.count({ where: { id: device.id } })).toBe(1);
});
it("answers 404 for an id that does not exist", async () => {
const owner = await makeUser();
const res = await callRoute(DELETE, {
method: "DELETE",
route: "/api/auth/me/devices/{id}",
params: { id: "no-such-device" },
auth: await makeSession(owner),
});
expect(res.status).toBe(404);
});
it("answers 404 for another account's device and leaves it and its credentials alone", async () => {
const owner = await makeUser();
const other = await makeUser();
const theirs = await makeDevice(other);
const theirAccess = await makeToken(other);
const theirRefresh = await makeRefresh(other, theirs.id, {
access: theirAccess,
});
const res = await callRoute(DELETE, {
method: "DELETE",
route: "/api/auth/me/devices/{id}",
params: { id: theirs.id },
auth: await makeSession(owner),
});
expect(res.status).toBe(404);
expect(await prisma.device.count({ where: { id: theirs.id } })).toBe(1);
const refresh = await prisma.refreshToken.findUniqueOrThrow({
where: { id: theirRefresh.id },
});
expect(refresh.revokedAt).toBeNull();
expect((await listDevices(theirAccess)).status).toBe(200);
});
it("deletes the device, revokes its refresh tokens and their paired access tokens, and those stop authenticating", async () => {
const owner = await makeUser();
const session = await makeSession(owner);
const phone = await makeDevice(owner, { model: "iPhone" });
const tablet = await makeDevice(owner);
const accessA = await makeToken(owner);
const accessB = await makeToken(owner);
const tabletAccess = await makeToken(owner);
const refreshA = await makeRefresh(owner, phone.id, { access: accessA });
const refreshB = await makeRefresh(owner, phone.id, { access: accessB });
const unpaired = await makeRefresh(owner, phone.id, { access: null });
const earlier = new Date("2026-01-01T00:00:00Z");
const alreadyRevoked = await makeRefresh(owner, phone.id, {
revokedAt: earlier,
});
const tabletRefresh = await makeRefresh(owner, tablet.id, {
access: tabletAccess,
});
const res = await callRoute<RevokeResult>(DELETE, {
method: "DELETE",
route: "/api/auth/me/devices/{id}",
params: { id: phone.id },
auth: session,
});
expect(res.status).toBe(200);
expect(res.data).toEqual({
id: phone.id,
revoked: true,
refreshTokensRevoked: 3,
accessTokensRevoked: 2,
});
expect(await prisma.device.count({ where: { id: phone.id } })).toBe(0);
const refreshRows = await prisma.refreshToken.findMany({
where: { userId: owner.id },
});
const byId = new Map(refreshRows.map((r) => [r.id, r]));
for (const id of [refreshA.id, refreshB.id, unpaired.id]) {
expect(byId.get(id)?.revokedAt).not.toBeNull();
}
// An earlier revocation keeps its own timestamp.
expect(byId.get(alreadyRevoked.id)?.revokedAt).toEqual(earlier);
// The other device is untouched.
expect(byId.get(tabletRefresh.id)?.revokedAt).toBeNull();
expect(await prisma.device.count({ where: { id: tablet.id } })).toBe(1);
expect((await listDevices(accessA)).status).toBe(401);
expect((await listDevices(accessB)).status).toBe(401);
expect((await listDevices(tabletAccess)).status).toBe(200);
// Browser sessions live on their own rows and are not touched.
expect((await listDevices(session)).status).toBe(200);
const audit = await prisma.auditLog.findFirstOrThrow({
where: { userId: owner.id, action: "devices.revoke" },
});
expect(JSON.parse(audit.details ?? "{}")).toEqual({
deviceId: phone.id,
label: "iPhone",
refreshTokensRevoked: 3,
accessTokensRevoked: 2,
});
// A retry after a successful revoke finds nothing.
const retry = await callRoute(DELETE, {
method: "DELETE",
route: "/api/auth/me/devices/{id}",
params: { id: phone.id },
auth: session,
});
expect(retry.status).toBe(404);
});
});
@@ -0,0 +1,175 @@
/**
* `/api/auth/me/diabetes` — the declared diabetes flag, against the real
* database and through the real API kit.
*
* The flag switches every glucose surface from the general normal bands to
* the glycemic goal bands and reaches the Coach snapshot; the web toggle in
* the thresholds section rides this route. Pinned here:
* - the read answers false for an account that never declared it and true
* once it is stored;
* - the write stores the flag both ways, answers it and audits previous and
* next; a missing, non-boolean or unknown field is 422 and leaves the
* column alone;
* - the write bucket allows 60 a minute per account.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/diabetes/route";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/diabetes";
type Diabetes = { hasDiabetes: boolean };
beforeEach(() => truncateAllTables(prisma));
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { hasDiabetes: true },
});
return row.hasDiabetes;
}
describe("GET /api/auth/me/diabetes", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers false for an account that never declared the flag", async () => {
const user = await makeUser();
const res = await callRoute<Diabetes>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: false });
});
it("answers true once the flag is stored", async () => {
const user = await makeUser({ hasDiabetes: true });
const res = await callRoute<Diabetes>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: true });
});
});
describe("PATCH /api/auth/me/diabetes", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("turns the flag on, answers it, audits the transition and reads back", async () => {
const user = await makeUser();
const session = await makeSession(user);
const res = await callRoute<Diabetes>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: session,
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: true });
expect(await storedFlag(user.id)).toBe(true);
const audit = await prisma.auditLog.findMany({
where: { userId: user.id, action: "user.diabetes.update" },
});
expect(audit).toHaveLength(1);
expect(JSON.parse(audit[0]?.details ?? "null")).toEqual({
previous: false,
next: true,
});
const read = await callRoute<Diabetes>(GET, {
method: "GET",
route: ROUTE,
auth: session,
});
expect(read.data).toEqual({ hasDiabetes: true });
});
it("turns the flag off and answers the resolved state", async () => {
const user = await makeUser({ hasDiabetes: true });
const res = await callRoute<Diabetes>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: false },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ hasDiabetes: false });
expect(await storedFlag(user.id)).toBe(false);
});
it.each([
["a missing hasDiabetes field", { otherField: true }],
["a non-boolean hasDiabetes", { hasDiabetes: "true" }],
["an undeclared key beside it", { hasDiabetes: true, extra: 1 }],
])("refuses %s with 422 and stores nothing", async (_, body) => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: i % 2 === 0 },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: session,
});
expect(refused.status).toBe(429);
// The 60th write set false; the refused one did not land.
expect(await storedFlag(user.id)).toBe(false);
const neighbour = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { hasDiabetes: true },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
});
@@ -0,0 +1,276 @@
/**
* `/api/auth/me/disable-coach` — the per-account Coach opt-out, against the
* real database and through the real API kit.
*
* Pinned here:
* - the read answers the stored flag, false on a fresh account;
* - the write stores the flag, answers the next state, and audits it under
* an enable or disable action with previous and next, also when the value
* does not change (the audit row mirrors the call);
* - a missing or non-boolean field, an undeclared key beside a valid one,
* and a body that is not JSON are refused without touching the row; the
* 422 names the undeclared key without reflecting a hostile one verbatim;
* - the write bucket allows 60 a minute per account.
*/
import { beforeEach, describe, expect, it } from "vitest";
import { GET, PATCH } from "@/app/api/auth/me/disable-coach/route";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/disable-coach";
type DisableCoach = { disableCoach: boolean };
beforeEach(() => truncateAllTables(prisma));
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { disableCoach: true },
});
return row.disableCoach;
}
async function auditRows(userId: string) {
const rows = await prisma.auditLog.findMany({
where: { userId, action: { startsWith: "user.disable-coach." } },
orderBy: { createdAt: "asc" },
});
return rows.map((r) => ({
action: r.action,
details: JSON.parse(r.details ?? "null"),
}));
}
describe("GET /api/auth/me/disable-coach", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers false for a fresh account", async () => {
const user = await makeUser();
const res = await callRoute<DisableCoach>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: false });
});
it("answers true for an account that opted out", async () => {
const user = await makeUser({ disableCoach: true });
const res = await callRoute<DisableCoach>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: true });
});
});
describe("PATCH /api/auth/me/disable-coach", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: anonymous,
});
expect(res.status).toBe(401);
});
it("turns the flag on and audits the enable", async () => {
const user = await makeUser();
const res = await callRoute<DisableCoach>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: true });
expect(await storedFlag(user.id)).toBe(true);
expect(await auditRows(user.id)).toEqual([
{
action: "user.disable-coach.enable",
details: { previous: false, next: true },
},
]);
});
it("turns the flag off and audits the disable", async () => {
const user = await makeUser({ disableCoach: true });
const res = await callRoute<DisableCoach>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: false },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(res.data).toEqual({ disableCoach: false });
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toEqual([
{
action: "user.disable-coach.disable",
details: { previous: true, next: false },
},
]);
});
it("writes through and audits when the flag already holds the value", async () => {
const user = await makeUser({ disableCoach: true });
const res = await callRoute<DisableCoach>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: await makeSession(user),
});
expect(res.status).toBe(200);
expect(await storedFlag(user.id)).toBe(true);
expect(await auditRows(user.id)).toEqual([
{
action: "user.disable-coach.enable",
details: { previous: true, next: true },
},
]);
});
it.each([
["a missing field", { otherField: "ignored" }],
["a non-boolean field", { disableCoach: "true" }],
])("refuses %s with 422 and stores nothing", async (_label, body) => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body,
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toEqual([]);
});
// 400 with a machine code in `meta.errorCode`, like every route that reads
// through `safeJson`; this one used to answer 422.
it("refuses a body that is not JSON with 400 and its error code", async () => {
const user = await makeUser();
// The kit serialises every body as JSON, so the unparseable body here is
// the empty one, sent with a JSON content type.
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
headers: { "content-type": "application/json" },
auth: await makeSession(user),
contract: {
skip: "the registry declares no 400 for this PATCH, which answers one for a body that is not JSON",
},
});
expect(res.status).toBe(400);
expect(res.error).toBe("Invalid JSON body");
expect(res.meta?.errorCode).toBe("disable-coach.body.invalid_json");
expect(await storedFlag(user.id)).toBe(false);
});
it("allows 60 writes a minute per account and refuses the 61st with 429", async () => {
const user = await makeUser();
const other = await makeUser();
const session = await makeSession(user);
for (let i = 0; i < 60; i++) {
const ok = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: i % 2 === 0 },
auth: session,
});
expect(ok.status).toBe(200);
}
const refused = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: session,
});
expect(refused.status).toBe(429);
// The 60th write turned it off; the refused one did not land.
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toHaveLength(60);
const neighbour = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true },
auth: await makeSession(other),
});
expect(neighbour.status).toBe(200);
});
});
/**
* The body schema is `.strict()`: a key the route does not declare is refused
* rather than trimmed away. The body worth pinning is the one that used to
* succeed while doing something other than what it said:
* `{ disableCoach: true, disableCoachh: false }` wrote `true` and answered 200.
*/
describe("PATCH /api/auth/me/disable-coach — unknown keys", () => {
it("refuses an undeclared key beside a valid one and writes nothing", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true, disableCoachh: false },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await auditRows(user.id)).toEqual([]);
});
it("names the offending key in the 422", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true, disableCoachh: false },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(res.body).toMatchObject({
details: {
issues: expect.arrayContaining([
expect.objectContaining({
code: "unrecognized_keys",
keys: ["disableCoachh"],
message: expect.stringContaining("disableCoachh"),
}),
]),
},
});
});
it("does not reflect a hostile key name back verbatim", async () => {
const user = await makeUser();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { disableCoach: true, "<script>alert(1)</script>": 1 },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
const raw = JSON.stringify(res.body);
expect(raw).not.toContain("<script>");
expect(raw).toContain("script_alert_1");
});
});
@@ -0,0 +1,354 @@
/**
* `/api/auth/me/documents-auto-ai-read` — the opt-in to read documents with
* AI on upload, against the real database, the real capability resolver and
* a real pg-boss queue.
*
* Turning the switch on is the standing consent act, so the write mints an
* `ai_extraction` receipt (document reading only, never the master `ai_full`
* that would also open the Coach and the AI analysis). And because the
* summary job is enqueued at upload time and does nothing while the switch is
* off, a genuine off-to-on flip schedules one catch-up pass over the vault;
* without it a person who filled the vault first saw the switch do nothing.
* Pinned here:
* - the flip stores the flag, mints exactly one extraction receipt and
* queues exactly one catch-up, and the capability is read after the mint,
* so the receipt the same request wrote opens it;
* - re-saving on, or turning it off, queues nothing, and off mints nothing;
* - an active receipt that already covers document reads is not doubled;
* - a receipt minted concurrently by another request is a success, not a
* 500;
* - with document reading closed by the operator the flag is still saved
* and no catch-up is queued.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { PgBoss } from "pg-boss";
import type { Prisma, User } from "@/generated/prisma/client";
import { GET, PATCH } from "@/app/api/auth/me/documents-auto-ai-read/route";
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
import {
DOCUMENT_SUMMARY_CATCHUP_QUEUE,
enqueueSummaryCatchUp,
} from "@/lib/jobs/document-summary-catchup";
import { anonymous, callRoute } from "../../kit/call-route";
import { makeSession, makeUser, uniqueSuffix } from "../../kit/factories";
import { getPrismaClient, truncateAllTables } from "../setup";
const prisma = getPrismaClient();
const ROUTE = "/api/auth/me/documents-auto-ai-read";
type AutoRead = { documentsAutoAiRead: boolean };
let boss: PgBoss;
beforeAll(async () => {
boss = new PgBoss({ connectionString: process.env.DATABASE_URL });
await boss.start();
await boss.createQueue(DOCUMENT_SUMMARY_CATCHUP_QUEUE);
setGlobalBoss(boss);
}, 120_000);
afterAll(async () => {
setGlobalBoss(null);
await boss?.stop({ graceful: false });
});
beforeEach(() => truncateAllTables(prisma));
/**
* An account with its own provider key, so the only thing between it and
* document reading is the consent receipt the switch mints.
*/
async function makeReader(
overrides: Partial<Prisma.UserUncheckedCreateInput> = {},
): Promise<User> {
return makeUser({
aiProvider: "ANTHROPIC",
aiModel: "claude-sonnet-4-6",
// Presence only: the capability probe never decrypts it.
aiAnthropicKeyEncrypted: "v1:presence-only",
...overrides,
});
}
async function catchUpJobs(userId: string): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pgboss.job
WHERE name = ${DOCUMENT_SUMMARY_CATCHUP_QUEUE}
AND singleton_key = ${`document-summary-catchup|${userId}`}
`;
return rows[0]?.n ?? 0;
}
/** The catch-up is fire-and-forget; wait for its job to land on the queue. */
async function waitForCatchUp(userId: string): Promise<void> {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if ((await catchUpJobs(userId)) > 0) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`no catch-up job for ${userId}`);
}
/**
* Before asserting that a write queued nothing: send a catch-up of our own
* through the same queue and let it land, so a send the route had started
* before returning has had the same time to land too.
*/
async function settleQueue(): Promise<void> {
const probe = `probe-${uniqueSuffix()}`;
await enqueueSummaryCatchUp(probe);
await waitForCatchUp(probe);
await new Promise((resolve) => setTimeout(resolve, 200));
}
async function storedFlag(userId: string): Promise<boolean> {
const row = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { documentsAutoAiRead: true },
});
return row.documentsAutoAiRead;
}
async function activeReceipts(userId: string): Promise<string[]> {
const rows = await prisma.consentReceipt.findMany({
where: { userId, revokedAt: null },
select: { kind: true },
orderBy: { kind: "asc" },
});
return rows.map((r) => r.kind);
}
async function auditDetails(userId: string): Promise<unknown[]> {
const rows = await prisma.auditLog.findMany({
where: { userId, action: "user.documentsAutoAiRead.update" },
orderBy: { createdAt: "asc" },
});
return rows.map((r) => JSON.parse(r.details ?? "null"));
}
function patch(user: Pick<User, "id">, documentsAutoAiRead: boolean) {
return makeSession(user).then((auth) =>
callRoute<AutoRead>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { documentsAutoAiRead },
auth,
}),
);
}
describe("GET /api/auth/me/documents-auto-ai-read", () => {
it("refuses an anonymous caller with 401", async () => {
const res = await callRoute(GET, {
method: "GET",
route: ROUTE,
auth: anonymous,
});
expect(res.status).toBe(401);
expect(res.meta?.errorCode).toBe("auth.missing");
});
it("answers off for a fresh account and on once it is stored", async () => {
const fresh = await makeUser();
const opted = await makeUser({ documentsAutoAiRead: true });
const off = await callRoute<AutoRead>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(fresh),
});
const on = await callRoute<AutoRead>(GET, {
method: "GET",
route: ROUTE,
auth: await makeSession(opted),
});
expect(off.data).toEqual({ documentsAutoAiRead: false });
expect(on.data).toEqual({ documentsAutoAiRead: true });
});
});
describe("PATCH /api/auth/me/documents-auto-ai-read", () => {
it("on an off-to-on flip stores the flag, mints the extraction receipt and queues one catch-up", async () => {
const user = await makeReader();
// Without a receipt, document reading is closed for this account: the
// catch-up below can only be queued because the capability is read after
// the receipt this request mints.
expect((await aiCapabilityForJob(user.id, "documentAi")).available).toBe(
false,
);
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(res.data).toEqual({ documentsAutoAiRead: true });
expect(await storedFlag(user.id)).toBe(true);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
await waitForCatchUp(user.id);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(1);
expect(await auditDetails(user.id)).toEqual([
{ previous: false, next: true, consentMinted: true },
]);
});
it("queues no second catch-up when the switch was already on", async () => {
// Re-saving an already-on setting must not queue a second pass over the
// same documents.
const user = await makeReader({ documentsAutoAiRead: true });
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "earlier flip",
signedAt: new Date(),
},
});
const res = await patch(user, true);
expect(res.status).toBe(200);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(0);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
});
it("turning it off stores off, queues nothing and mints nothing", async () => {
const user = await makeReader({ documentsAutoAiRead: true });
const res = await patch(user, false);
expect(res.status).toBe(200);
expect(res.data).toEqual({ documentsAutoAiRead: false });
expect(await storedFlag(user.id)).toBe(false);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(0);
expect(await activeReceipts(user.id)).toEqual([]);
});
it.each(["ai_extraction", "ai_full"])(
"mints no second receipt when an active %s already covers document reads",
async (kind) => {
const user = await makeReader();
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind,
artefact: "earlier grant",
signedAt: new Date(),
},
});
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(await activeReceipts(user.id)).toEqual([kind]);
expect(await auditDetails(user.id)).toEqual([
{ previous: false, next: true, consentMinted: false },
]);
},
);
it("mints a fresh receipt when the earlier one was revoked", async () => {
const user = await makeReader();
await prisma.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "earlier grant",
signedAt: new Date("2026-01-01T00:00:00Z"),
revokedAt: new Date("2026-02-01T00:00:00Z"),
},
});
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
});
it("treats a receipt minted concurrently by another request as success", async () => {
// Another request inserts the receipt and has not committed yet: this
// request's lookup cannot see it, its insert waits on the active-receipt
// unique index, and fails once the other commits.
const user = await makeReader();
const session = await makeSession(user);
// Wrapped in an object so the transaction does not wait on the request,
// which itself waits on the transaction's commit.
const { pending } = await prisma.$transaction(
async (tx) => {
await tx.consentReceipt.create({
data: {
userId: user.id,
kind: "ai_extraction",
artefact: "concurrent flip",
signedAt: new Date(),
},
});
const request = callRoute<AutoRead>(PATCH, {
method: "PATCH",
route: ROUTE,
body: { documentsAutoAiRead: true },
auth: session,
});
const deadline = Date.now() + 10_000;
for (;;) {
const waiting = await prisma.$queryRaw<Array<{ n: number }>>`
SELECT count(*)::int AS n FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND query ILIKE '%INSERT INTO%consent_receipts%'
`;
if ((waiting[0]?.n ?? 0) > 0) break;
if (Date.now() > deadline) {
throw new Error("the route's receipt insert never waited");
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
return { pending: request };
},
{ timeout: 20_000 },
);
const res = await pending;
expect(res.status).toBe(200);
expect(res.data).toEqual({ documentsAutoAiRead: true });
expect(await storedFlag(user.id)).toBe(true);
expect(await activeReceipts(user.id)).toEqual(["ai_extraction"]);
expect(await auditDetails(user.id)).toEqual([
{ previous: false, next: true, consentMinted: false },
]);
});
it("saves the flag but queues no catch-up while the operator has document reading closed", async () => {
// Every summary the pass would enqueue could only be refused, so the pass
// is not queued at all.
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", assistantDocumentAiEnabled: false },
update: { assistantDocumentAiEnabled: false },
});
const user = await makeReader();
const res = await patch(user, true);
expect(res.status).toBe(200);
expect(await storedFlag(user.id)).toBe(true);
await settleQueue();
expect(await catchUpJobs(user.id)).toBe(0);
});
it("refuses a non-boolean flag with 422 and stores nothing", async () => {
const user = await makeReader();
const res = await callRoute(PATCH, {
method: "PATCH",
route: ROUTE,
body: { documentsAutoAiRead: "yes" },
auth: await makeSession(user),
});
expect(res.status).toBe(422);
expect(await storedFlag(user.id)).toBe(false);
expect(await activeReceipts(user.id)).toEqual([]);
});
});
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more