mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'release/v1.45.0' into feat/v145-runtime
This commit is contained in:
commit
a859adb59c
146 files changed
+24075
-13836
No files matched your search
@@ -10,20 +10,23 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# The suite outgrew a single runner, then a half-suite outgrew a slow one,
|
||||
# and then a third of it did too. Three shards of about 68 files cleared in
|
||||
# 13 minutes on an ordinary runner, but a slow runner day ran every test at
|
||||
# twice its usual duration and the first shard was still working through
|
||||
# its list when the 25-minute guard cut it off: three of four pushes to main
|
||||
# between 2026-08-31 and 2026-09-01 failed that way, with green tests logged
|
||||
# up to the last second. Four shards keep each slice around 50 files, and
|
||||
# the guard sits at three times the ordinary run rather than under two. A
|
||||
# wedged test still surfaces through vitest's own 60-second budget, so the
|
||||
# job timeout only needs to catch a container that never boots. Each shard
|
||||
# boots its own Postgres testcontainer.
|
||||
# Four shards of about equal duration, each running its files in parallel
|
||||
# against one database per worker. The shards used to be four slices of
|
||||
# equal file count, run serially: 954 s to 1 449 s, because a slice holding
|
||||
# the sharing matrix and the backup files took half again as long as one
|
||||
# holding small files. `--shard` now goes through the duration sequencer
|
||||
# (`tests/durations/sequencer.ts`, timings in
|
||||
# `tests/durations/integration.json`), and every file in a shard runs on
|
||||
# one of three workers, each against its own clone of the migrated template
|
||||
# (`tests/integration/worker-database.ts`). Three workers leave a core of
|
||||
# the four-core runner to the Postgres container. The job guard stays well
|
||||
# above an ordinary run; a wedged test still surfaces through vitest's own
|
||||
# 60-second budget, so the guard only needs to catch a container that never
|
||||
# boots. Each shard boots its own Postgres testcontainer and uploads its
|
||||
# JSON report, which `scripts/test-durations.mjs` turns into the timing file.
|
||||
shard:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -48,8 +51,13 @@ jobs:
|
||||
# vitest a stray positional argument and the shard flag never applies —
|
||||
# both "shards" then run the full suite. Proven by a paired local run
|
||||
# before this workflow shipped.
|
||||
- run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/4
|
||||
- run: >-
|
||||
pnpm exec vitest run --config vitest.integration.config.mts
|
||||
--shard=${{ matrix.shard }}/4
|
||||
--reporter=default --reporter=json
|
||||
--outputFile.json=integration-report-${{ matrix.shard }}.json
|
||||
env:
|
||||
HEALTHLOG_INTEGRATION_WORKERS: "3"
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
# These env vars satisfy code paths that read them at module load.
|
||||
@@ -63,6 +71,15 @@ jobs:
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
SESSION_SECRET: "integration-tests"
|
||||
|
||||
- name: Upload the timing report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: integration-report-${{ matrix.shard }}
|
||||
path: integration-report-${{ matrix.shard }}.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# Branch protection requires a check named exactly "integration". This job
|
||||
# carries that name and answers for every shard: it fails when any shard
|
||||
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
|
||||
|
||||
@@ -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
|
||||
@@ -15,11 +15,72 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
name: Lint, Typecheck & Test
|
||||
# The static checks: everything in the old single quality job except the
|
||||
# unit suite, which runs sharded below.
|
||||
static:
|
||||
name: Lint, Typecheck & OpenAPI
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name != 'schedule'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
# Blocking since v1.15.20 — the settings-split refactor that kept
|
||||
# this warn-only is long done and the tree lints clean.
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
|
||||
# Prettier formatting gate. The format pass excludes generator-owned
|
||||
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
|
||||
# the OpenAPI drift check below enforces byte-for-byte).
|
||||
- name: Prettier format check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
|
||||
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
|
||||
#
|
||||
# The Zod registry is the source of truth for the public API contract.
|
||||
# Any route signature change must be reflected in `docs/api/openapi.yaml`
|
||||
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
|
||||
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
|
||||
# commit the regenerated spec.
|
||||
- name: OpenAPI spec drift check
|
||||
run: |
|
||||
pnpm openapi:generate
|
||||
if ! git diff --exit-code docs/api/openapi.yaml; then
|
||||
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::OpenAPI spec in sync with Zod source schemas."
|
||||
|
||||
# The unit suite in three slices of about equal duration. `--shard` goes
|
||||
# through the duration sequencer in `vitest.config.mts`, which deals files
|
||||
# out by their measured time (`tests/durations/unit.json`) instead of by
|
||||
# count. Each slice uploads its JSON report, which is what
|
||||
# `scripts/test-durations.mjs` regenerates the timing file from.
|
||||
unit:
|
||||
runs-on: ubuntu-24.04
|
||||
if: github.event_name != 'schedule'
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
name: unit shard ${{ matrix.shard }}/3
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -38,7 +99,8 @@ jobs:
|
||||
# real browser and measures the boxes. Without a browser here it caught
|
||||
# its own launch failure and passed anyway, so from its first day until
|
||||
# v1.38 it measured nothing on any run of this job. The browser is part
|
||||
# of the gate now; the test refuses to skip when CI is set.
|
||||
# of the gate now; the test refuses to skip when CI is set. Every slice
|
||||
# installs it, because the sequencer decides which slice the file lands in.
|
||||
#
|
||||
# Headless shell only: `chromium.launch()` resolves to
|
||||
# chromium_headless_shell, which is what the failing runs named, and the
|
||||
@@ -54,38 +116,40 @@ jobs:
|
||||
|
||||
- run: pnpm exec playwright install --only-shell chromium
|
||||
|
||||
# Blocking since v1.15.20 — the settings-split refactor that kept
|
||||
# this warn-only is long done and the tree lints clean.
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
|
||||
# Prettier formatting gate. The format pass excludes generator-owned
|
||||
# artifacts via .prettierignore (notably docs/api/openapi.yaml, which
|
||||
# the OpenAPI drift check below enforces byte-for-byte).
|
||||
- name: Prettier format check
|
||||
run: pnpm format:check
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
|
||||
# vitest directly, not `pnpm test --`: pnpm hands the separator through
|
||||
# literally and the shard flag would never reach vitest.
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
run: >-
|
||||
pnpm exec vitest run --shard=${{ matrix.shard }}/3
|
||||
--reporter=default --reporter=json
|
||||
--outputFile.json=unit-report-${{ matrix.shard }}.json
|
||||
|
||||
# OpenAPI spec drift gate — hard-fail (flipped from warn-only in v1.4.25).
|
||||
#
|
||||
# The Zod registry is the source of truth for the public API contract.
|
||||
# Any route signature change must be reflected in `docs/api/openapi.yaml`
|
||||
# so downstream consumers (notably the v1.5 iOS Swift codegen) stay in
|
||||
# lockstep. If this step fails, run `pnpm openapi:generate` locally and
|
||||
# commit the regenerated spec.
|
||||
- name: OpenAPI spec drift check
|
||||
- name: Upload the timing report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: unit-report-${{ matrix.shard }}
|
||||
path: unit-report-${{ matrix.shard }}.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
# Branch protection requires a check named exactly "Lint, Typecheck & Test".
|
||||
# That used to be one job running everything in turn; it is now this one,
|
||||
# which answers for the static checks and every unit slice, so a half-green
|
||||
# set cannot satisfy the gate.
|
||||
quality:
|
||||
name: Lint, Typecheck & Test
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [static, unit]
|
||||
if: always() && github.event_name != 'schedule'
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: The static checks and every unit slice finished green
|
||||
run: |
|
||||
pnpm openapi:generate
|
||||
if ! git diff --exit-code docs/api/openapi.yaml; then
|
||||
echo "::error::OpenAPI drift detected — regenerate the spec via 'pnpm openapi:generate' and commit the result. The Zod registry under src/lib/openapi/ is the source of truth for the public API contract."
|
||||
if [ "${{ needs.static.result }}" != "success" ] || [ "${{ needs.unit.result }}" != "success" ]; then
|
||||
echo "static: ${{ needs.static.result }}, unit: ${{ needs.unit.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::OpenAPI spec in sync with Zod source schemas."
|
||||
|
||||
# Not on pull requests. `pnpm audit` answers a question about the published
|
||||
# advisory database, not about the diff: the same lockfile passes on Monday
|
||||
|
||||
@@ -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
|
||||
@@ -98,3 +98,6 @@ next-env.d.ts
|
||||
# same reason in a different tool.
|
||||
/.wt-*/
|
||||
/.worktrees/
|
||||
# Mutation testing (stryker.config.mjs): reports and the sandbox copy.
|
||||
/reports/mutation
|
||||
/.stryker-tmp
|
||||
@@ -4,6 +4,9 @@ RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# The dependency patches pnpm-workspace.yaml names; the frozen install
|
||||
# refuses to run without them.
|
||||
COPY patches patches
|
||||
COPY scripts/prepare.mjs scripts/prepare.mjs
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
- [`contributing/benchmarks.md`](./contributing/benchmarks.md) — the
|
||||
large-account benchmark (latency and statements per request) and the
|
||||
statement budgets on the hot routes.
|
||||
- [`contributing/testing.md`](./contributing/testing.md) — which suite a
|
||||
test belongs in, the route test kit, the integration database, sharding,
|
||||
property and mutation tests, and the flaky-test quarantine.
|
||||
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
|
||||
it disagrees with the UI standards, the standards win.
|
||||
|
||||
|
||||
@@ -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>`.
|
||||
@@ -108,6 +108,8 @@
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@next/bundle-analyzer": "^16.3.8",
|
||||
"@playwright/test": "^1.64.0",
|
||||
"@stryker-mutator/core": "10.0.0",
|
||||
"@stryker-mutator/vitest-runner": "10.0.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tesseract.js-data/deu": "1.0.0",
|
||||
"@tesseract.js-data/eng": "1.0.0",
|
||||
@@ -122,6 +124,7 @@
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"fast-check": "4.10.2",
|
||||
"knip": "^6.41.0",
|
||||
"lefthook": "^2.2.2",
|
||||
"pdf-parse": "^2.4.5",
|
||||
|
||||
@@ -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)}`;
|
||||
Generated
+902
-34
File diff suppressed because it is too large.
Load diff
@@ -131,3 +131,6 @@ auditConfig:
|
||||
# never runs. No patched release is published yet (latest is 1.4.0);
|
||||
# remove this entry and raise the floor once 1.4.1 is out.
|
||||
- GHSA-86w9-cpqp-85rv
|
||||
|
||||
patchedDependencies:
|
||||
"@stryker-mutator/vitest-runner@10.0.0": patches/@stryker-mutator__vitest-runner@10.0.0.patch
|
||||
@@ -157,6 +157,9 @@ describe("container dependency installation", () => {
|
||||
|
||||
expect(dockerfile).toContain(
|
||||
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
|
||||
"# The dependency patches pnpm-workspace.yaml names; the frozen install\n" +
|
||||
"# refuses to run without them.\n" +
|
||||
"COPY patches patches\n" +
|
||||
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
|
||||
"RUN pnpm install --frozen-lockfile --prod=false",
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.",
|
||||
"count": 321,
|
||||
"total": 321,
|
||||
"count": 274,
|
||||
"total": 274,
|
||||
"entries": {
|
||||
"app/api/account/grants/__tests__/invite-access-level.test.ts": 1,
|
||||
"app/api/account/grants/__tests__/invite-recent-proof.test.ts": 1,
|
||||
@@ -36,26 +36,8 @@
|
||||
"app/api/auth/login/__tests__/native-token.test.ts": 1,
|
||||
"app/api/auth/login/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/login/__tests__/trusted-device.test.ts": 1,
|
||||
"app/api/auth/me/ai-usage/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/coach-prefs/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/devices/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/devices/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/diabetes/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/disable-coach/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts": 1,
|
||||
"app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/glucose-unit/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/health-score-config/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/mfa/disable/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/modules/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/notification-prefs/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/security-activity/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/sessions/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/source-priority/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/timezone/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/unit-preference/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/workout-routes/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/native/complete/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/native/login/__tests__/route.test.ts": 1,
|
||||
@@ -122,8 +104,6 @@
|
||||
"app/api/export/encrypted/__tests__/route.test.ts": 1,
|
||||
"app/api/export/health-record/__tests__/route.test.ts": 1,
|
||||
"app/api/feature-flags/__tests__/route.test.ts": 1,
|
||||
"app/api/fhir/__tests__/capability-and-everything.test.ts": 1,
|
||||
"app/api/fhir/__tests__/module-gate.test.ts": 1,
|
||||
"app/api/fitbit/connect/__tests__/route.test.ts": 1,
|
||||
"app/api/fitbit/credentials/__tests__/route.test.ts": 1,
|
||||
"app/api/gamification/achievements/__tests__/hidden-redaction.test.ts": 1,
|
||||
@@ -222,22 +202,14 @@
|
||||
"app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 1,
|
||||
"app/api/medications/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/cadence/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/purge/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/inventory/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/phase-config/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/side-effects/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/compliance/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/intake/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts": 1,
|
||||
@@ -254,11 +226,6 @@
|
||||
"app/api/mood-entries/bulk/__tests__/route.test.ts": 1,
|
||||
"app/api/mood-entries/restore/__tests__/route.test.ts": 1,
|
||||
"app/api/mood/analytics/__tests__/route.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/custom-crud.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/groups-crud.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/layout.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/route.test.ts": 1,
|
||||
"app/api/mood/tags/layout/__tests__/route.test.ts": 1,
|
||||
"app/api/nightscout/connect/__tests__/route.test.ts": 1,
|
||||
"app/api/notifications/action/[token]/__tests__/route.test.ts": 1,
|
||||
"app/api/notifications/status/__tests__/route.test.ts": 1,
|
||||
@@ -280,24 +247,13 @@
|
||||
"app/api/polar/disconnect/__tests__/route.test.ts": 1,
|
||||
"app/api/polar/status/__tests__/route.test.ts": 1,
|
||||
"app/api/search/__tests__/route.test.ts": 1,
|
||||
"app/api/settings/__tests__/channel-enabled-routes.test.ts": 1,
|
||||
"app/api/settings/__tests__/channel-global-switch-refusal.test.ts": 1,
|
||||
"app/api/settings/__tests__/email-test-route.test.ts": 1,
|
||||
"app/api/settings/__tests__/private-origin-routes.test.ts": 1,
|
||||
"app/api/settings/__tests__/telegram-test-locale.test.ts": 1,
|
||||
"app/api/settings/__tests__/webhook-format-route.test.ts": 1,
|
||||
"app/api/settings/reminder-thresholds/__tests__/route.test.ts": 1,
|
||||
"app/api/settings/webhook/test/__tests__/route.test.ts": 1,
|
||||
"app/api/share-links/__tests__/route.test.ts": 1,
|
||||
"app/api/sleep/night/__tests__/route.test.ts": 1,
|
||||
"app/api/sleep/rhythm/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/callback/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/credentials/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/disconnect/__tests__/route.test.ts": 1,
|
||||
"app/api/sync/changes/__tests__/module-gate.test.ts": 1,
|
||||
"app/api/sync/state/__tests__/route.test.ts": 1,
|
||||
"app/api/telegram/webhook/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/documents/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/measurements/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/workouts/__tests__/route.test.ts": 1,
|
||||
@@ -319,10 +275,7 @@
|
||||
"app/api/withings/webhook/__tests__/route.test.ts": 1,
|
||||
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 1,
|
||||
"app/api/workouts/__tests__/batch-create.test.ts": 1,
|
||||
"app/api/workouts/__tests__/batch-scope.test.ts": 1,
|
||||
"app/api/workouts/__tests__/canonical-dedup.test.ts": 1,
|
||||
"app/api/workouts/__tests__/delete.test.ts": 1,
|
||||
"app/api/workouts/__tests__/detail.test.ts": 1,
|
||||
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 1
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "route tests that stub @/lib/api-handler, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 138,
|
||||
"total": 138,
|
||||
"count": 133,
|
||||
"total": 133,
|
||||
"entries": {
|
||||
"app/api/account/grants/__tests__/invite-access-level.test.ts": 1,
|
||||
"app/api/account/grants/__tests__/invite-scope-and-manage.test.ts": 1,
|
||||
@@ -112,15 +112,10 @@
|
||||
"app/api/polar/sync/__tests__/route.test.ts": 1,
|
||||
"app/api/record-settings/integrations/[provider]/__tests__/route.test.ts": 1,
|
||||
"app/api/search/__tests__/route.test.ts": 1,
|
||||
"app/api/settings/__tests__/telegram-test-locale.test.ts": 1,
|
||||
"app/api/strava/callback/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/connect/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/credentials/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/disconnect/__tests__/route.test.ts": 1,
|
||||
"app/api/strava/sync/__tests__/route.test.ts": 1,
|
||||
"app/api/sync/state/__tests__/route.test.ts": 1,
|
||||
"app/api/telegram/webhook/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/documents/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/measurements/__tests__/route.test.ts": 1,
|
||||
"app/api/tokens/workouts/__tests__/route.test.ts": 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 570,
|
||||
"total": 6014,
|
||||
"count": 524,
|
||||
"total": 5496,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -42,26 +42,8 @@
|
||||
"app/api/auth/login/__tests__/native-token.test.ts": 8,
|
||||
"app/api/auth/login/__tests__/route.test.ts": 5,
|
||||
"app/api/auth/login/__tests__/trusted-device.test.ts": 2,
|
||||
"app/api/auth/me/ai-usage/__tests__/route.test.ts": 3,
|
||||
"app/api/auth/me/coach-prefs/__tests__/route.test.ts": 9,
|
||||
"app/api/auth/me/devices/__tests__/route.test.ts": 8,
|
||||
"app/api/auth/me/devices/[id]/__tests__/route.test.ts": 13,
|
||||
"app/api/auth/me/diabetes/__tests__/route.test.ts": 13,
|
||||
"app/api/auth/me/disable-coach/__tests__/route.test.ts": 19,
|
||||
"app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts": 2,
|
||||
"app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts": 5,
|
||||
"app/api/auth/me/glucose-unit/__tests__/route.test.ts": 19,
|
||||
"app/api/auth/me/health-score-config/__tests__/route.test.ts": 25,
|
||||
"app/api/auth/me/mfa/disable/__tests__/route.test.ts": 7,
|
||||
"app/api/auth/me/mfa/webauthn/register/__tests__/route.test.ts": 8,
|
||||
"app/api/auth/me/modules/__tests__/route.test.ts": 29,
|
||||
"app/api/auth/me/notification-prefs/__tests__/route.test.ts": 44,
|
||||
"app/api/auth/me/security-activity/__tests__/route.test.ts": 2,
|
||||
"app/api/auth/me/sessions/__tests__/route.test.ts": 13,
|
||||
"app/api/auth/me/source-priority/__tests__/route.test.ts": 11,
|
||||
"app/api/auth/me/timezone/__tests__/route.test.ts": 15,
|
||||
"app/api/auth/me/unit-preference/__tests__/route.test.ts": 19,
|
||||
"app/api/auth/me/workout-routes/__tests__/route.test.ts": 3,
|
||||
"app/api/auth/mfa/webauthn/verify/__tests__/route.test.ts": 11,
|
||||
"app/api/auth/native/complete/__tests__/route.test.ts": 2,
|
||||
"app/api/auth/native/login/__tests__/route.test.ts": 2,
|
||||
@@ -122,8 +104,6 @@
|
||||
"app/api/export/encrypted/__tests__/route.test.ts": 20,
|
||||
"app/api/export/health-record/__tests__/route.test.ts": 32,
|
||||
"app/api/feature-flags/__tests__/route.test.ts": 6,
|
||||
"app/api/fhir/__tests__/capability-and-everything.test.ts": 4,
|
||||
"app/api/fhir/__tests__/module-gate.test.ts": 6,
|
||||
"app/api/fhir/__tests__/observation-series.test.ts": 8,
|
||||
"app/api/fhir/__tests__/route.test.ts": 9,
|
||||
"app/api/fitbit/connect/__tests__/route.test.ts": 4,
|
||||
@@ -207,22 +187,14 @@
|
||||
"app/api/measurements/series/__tests__/unknown-query-param-open.test.ts": 6,
|
||||
"app/api/medications/__tests__/route.test.ts": 43,
|
||||
"app/api/medications/[id]/__tests__/route.test.ts": 52,
|
||||
"app/api/medications/[id]/cadence/__tests__/route.test.ts": 12,
|
||||
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 18,
|
||||
"app/api/medications/[id]/dose-history/__tests__/route.test.ts": 15,
|
||||
"app/api/medications/[id]/glp1/__tests__/route.test.ts": 47,
|
||||
"app/api/medications/[id]/intake/__tests__/route.test.ts": 45,
|
||||
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 12,
|
||||
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 63,
|
||||
"app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts": 13,
|
||||
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 9,
|
||||
"app/api/medications/[id]/intake/import/[jobId]/status/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/intake/purge/__tests__/route.test.ts": 4,
|
||||
"app/api/medications/[id]/inventory/__tests__/route.test.ts": 45,
|
||||
"app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts": 12,
|
||||
"app/api/medications/[id]/phase-config/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 24,
|
||||
"app/api/medications/[id]/side-effects/__tests__/route.test.ts": 32,
|
||||
"app/api/medications/compliance/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/extract/__tests__/route.test.ts": 38,
|
||||
"app/api/medications/intake/__tests__/route.test.ts": 59,
|
||||
@@ -243,11 +215,6 @@
|
||||
"app/api/mood-entries/restore/__tests__/route.test.ts": 11,
|
||||
"app/api/mood/analytics/__tests__/route.test.ts": 15,
|
||||
"app/api/mood/insights/__tests__/route.test.ts": 3,
|
||||
"app/api/mood/tags/__tests__/custom-crud.test.ts": 2,
|
||||
"app/api/mood/tags/__tests__/groups-crud.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/layout.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/route.test.ts": 1,
|
||||
"app/api/mood/tags/layout/__tests__/route.test.ts": 11,
|
||||
"app/api/nightscout/sync/__tests__/route.test.ts": 3,
|
||||
"app/api/notifications/action/[token]/__tests__/route.test.ts": 5,
|
||||
"app/api/notifications/status/__tests__/route.test.ts": 4,
|
||||
@@ -264,22 +231,12 @@
|
||||
"app/api/personal-records/__tests__/route.test.ts": 15,
|
||||
"app/api/polar/callback/__tests__/route.test.ts": 1,
|
||||
"app/api/polar/sync/__tests__/route.test.ts": 3,
|
||||
"app/api/settings/__tests__/channel-enabled-routes.test.ts": 14,
|
||||
"app/api/settings/__tests__/channel-global-switch-refusal.test.ts": 15,
|
||||
"app/api/settings/__tests__/email-test-route.test.ts": 2,
|
||||
"app/api/settings/__tests__/private-origin-routes.test.ts": 4,
|
||||
"app/api/settings/__tests__/telegram-test-locale.test.ts": 9,
|
||||
"app/api/settings/__tests__/webhook-format-route.test.ts": 4,
|
||||
"app/api/settings/reminder-thresholds/__tests__/route.test.ts": 7,
|
||||
"app/api/settings/webhook/test/__tests__/route.test.ts": 2,
|
||||
"app/api/share-links/__tests__/route.test.ts": 19,
|
||||
"app/api/sleep/night/__tests__/route.test.ts": 24,
|
||||
"app/api/sleep/rhythm/__tests__/route.test.ts": 6,
|
||||
"app/api/strava/sync/__tests__/route.test.ts": 3,
|
||||
"app/api/sync/changes/__tests__/module-gate.test.ts": 7,
|
||||
"app/api/sync/state/__tests__/route.test.ts": 8,
|
||||
"app/api/telegram/webhook/__tests__/route.test.ts": 43,
|
||||
"app/api/tokens/__tests__/route.test.ts": 5,
|
||||
"app/api/tokens/documents/__tests__/route.test.ts": 25,
|
||||
"app/api/tokens/measurements/__tests__/route.test.ts": 25,
|
||||
"app/api/tokens/workouts/__tests__/route.test.ts": 25,
|
||||
@@ -297,10 +254,7 @@
|
||||
"app/api/withings/webhook/__tests__/route.test.ts": 26,
|
||||
"app/api/withings/webhook/[token]/__tests__/route.test.ts": 8,
|
||||
"app/api/workouts/__tests__/batch-create.test.ts": 14,
|
||||
"app/api/workouts/__tests__/batch-scope.test.ts": 10,
|
||||
"app/api/workouts/__tests__/canonical-dedup.test.ts": 20,
|
||||
"app/api/workouts/__tests__/delete.test.ts": 7,
|
||||
"app/api/workouts/__tests__/detail.test.ts": 36,
|
||||
"app/api/workouts/batch/__tests__/unstable-external-id.test.ts": 4,
|
||||
"app/c/[token]/__tests__/report-download.test.ts": 11,
|
||||
"app/c/[token]/d/[id]/__tests__/route.test.ts": 8,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2985,
|
||||
"total": 10634,
|
||||
"count": 2962,
|
||||
"total": 10592,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -138,16 +138,12 @@
|
||||
"app/api/auth/login/__tests__/native-token.test.ts": 2,
|
||||
"app/api/auth/login/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/login/route.ts": 5,
|
||||
"app/api/auth/me/coach-prefs/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/coach-prefs/route.ts": 7,
|
||||
"app/api/auth/me/cycle-prefs/route.ts": 1,
|
||||
"app/api/auth/me/devices/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/devices/[id]/__tests__/route.test.ts": 1,
|
||||
"app/api/auth/me/devices/[id]/route.ts": 3,
|
||||
"app/api/auth/me/devices/route.ts": 2,
|
||||
"app/api/auth/me/diabetes/route.ts": 1,
|
||||
"app/api/auth/me/disable-coach/route.ts": 1,
|
||||
"app/api/auth/me/health-score-config/__tests__/route.test.ts": 3,
|
||||
"app/api/auth/me/health-score-config/route.ts": 1,
|
||||
"app/api/auth/me/injection-site-prefs/route.ts": 1,
|
||||
"app/api/auth/me/labs-local-ocr/route.ts": 1,
|
||||
@@ -155,9 +151,7 @@
|
||||
"app/api/auth/me/mfa/disable/route.ts": 1,
|
||||
"app/api/auth/me/mfa/totp/confirm/route.ts": 1,
|
||||
"app/api/auth/me/mfa/webauthn/register/verify/route.ts": 1,
|
||||
"app/api/auth/me/modules/__tests__/route.test.ts": 4,
|
||||
"app/api/auth/me/modules/route.ts": 4,
|
||||
"app/api/auth/me/notification-prefs/__tests__/route.test.ts": 3,
|
||||
"app/api/auth/me/notification-prefs/route.ts": 4,
|
||||
"app/api/auth/me/route.ts": 28,
|
||||
"app/api/auth/me/security-activity/route.ts": 1,
|
||||
@@ -423,7 +417,6 @@
|
||||
"app/api/medications/__tests__/route.test.ts": 10,
|
||||
"app/api/medications/[id]/__tests__/route.test.ts": 8,
|
||||
"app/api/medications/[id]/api-endpoint/route.ts": 2,
|
||||
"app/api/medications/[id]/cadence/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/cadence/route.ts": 10,
|
||||
"app/api/medications/[id]/compliance/__tests__/route.test.ts": 7,
|
||||
"app/api/medications/[id]/compliance/route.ts": 3,
|
||||
@@ -437,24 +430,18 @@
|
||||
"app/api/medications/[id]/intake/__tests__/source-provenance.test.ts": 1,
|
||||
"app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts": 9,
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts": 24,
|
||||
"app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts": 3,
|
||||
"app/api/medications/[id]/intake/bulk-delete/route.ts": 7,
|
||||
"app/api/medications/[id]/intake/import/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/intake/import/route.ts": 6,
|
||||
"app/api/medications/[id]/intake/purge/__tests__/route.test.ts": 3,
|
||||
"app/api/medications/[id]/intake/purge/route.ts": 4,
|
||||
"app/api/medications/[id]/intake/route.ts": 30,
|
||||
"app/api/medications/[id]/inventory/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts": 5,
|
||||
"app/api/medications/[id]/inventory/[itemId]/route.ts": 9,
|
||||
"app/api/medications/[id]/inventory/route.ts": 5,
|
||||
"app/api/medications/[id]/phase-config/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/phase-config/route.ts": 5,
|
||||
"app/api/medications/[id]/route.ts": 59,
|
||||
"app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts": 1,
|
||||
"app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": 9,
|
||||
"app/api/medications/[id]/schedule-revisions/route.ts": 3,
|
||||
"app/api/medications/[id]/side-effects/__tests__/route.test.ts": 2,
|
||||
"app/api/medications/[id]/side-effects/[logId]/route.ts": 2,
|
||||
"app/api/medications/[id]/side-effects/route.ts": 6,
|
||||
"app/api/medications/categories/[key]/route.ts": 1,
|
||||
@@ -487,16 +474,11 @@
|
||||
"app/api/mood/analytics/__tests__/route.test.ts": 2,
|
||||
"app/api/mood/analytics/route.ts": 1,
|
||||
"app/api/mood/insights/route.ts": 3,
|
||||
"app/api/mood/tags/__tests__/custom-crud.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/groups-crud.test.ts": 1,
|
||||
"app/api/mood/tags/__tests__/layout.test.ts": 2,
|
||||
"app/api/mood/tags/__tests__/route.test.ts": 1,
|
||||
"app/api/mood/tags/[key]/hidden/route.ts": 1,
|
||||
"app/api/mood/tags/custom/[key]/route.ts": 1,
|
||||
"app/api/mood/tags/custom/route.ts": 1,
|
||||
"app/api/mood/tags/groups/[key]/route.ts": 1,
|
||||
"app/api/mood/tags/groups/route.ts": 1,
|
||||
"app/api/mood/tags/layout/__tests__/route.test.ts": 1,
|
||||
"app/api/mood/tags/layout/route.ts": 3,
|
||||
"app/api/mood/tags/route.ts": 1,
|
||||
"app/api/nightscout/connect/__tests__/route.test.ts": 1,
|
||||
@@ -535,12 +517,9 @@
|
||||
"app/api/practitioners/[id]/route.ts": 1,
|
||||
"app/api/practitioners/route.ts": 1,
|
||||
"app/api/record-settings/integrations/route.ts": 1,
|
||||
"app/api/settings/__tests__/channel-enabled-routes.test.ts": 1,
|
||||
"app/api/settings/__tests__/telegram-test-locale.test.ts": 1,
|
||||
"app/api/settings/account/route.ts": 1,
|
||||
"app/api/settings/data/route.ts": 2,
|
||||
"app/api/settings/ntfy/route.ts": 1,
|
||||
"app/api/settings/reminder-thresholds/__tests__/route.test.ts": 2,
|
||||
"app/api/settings/reminder-thresholds/route.ts": 2,
|
||||
"app/api/settings/telegram/test/route.ts": 1,
|
||||
"app/api/share-links/__tests__/route.test.ts": 2,
|
||||
@@ -554,7 +533,6 @@
|
||||
"app/api/strava/credentials/route.ts": 1,
|
||||
"app/api/strava/disconnect/route.ts": 1,
|
||||
"app/api/strava/status/route.ts": 1,
|
||||
"app/api/strava/sync/__tests__/route.test.ts": 1,
|
||||
"app/api/sync/changes/route.ts": 5,
|
||||
"app/api/sync/state/route.ts": 2,
|
||||
"app/api/telegram/webhook/__tests__/route.test.ts": 3,
|
||||
@@ -582,7 +560,6 @@
|
||||
"app/api/withings/webhook/route.ts": 2,
|
||||
"app/api/workouts/__tests__/batch-create.test.ts": 3,
|
||||
"app/api/workouts/__tests__/canonical-dedup.test.ts": 3,
|
||||
"app/api/workouts/__tests__/detail.test.ts": 1,
|
||||
"app/api/workouts/[id]/route.ts": 5,
|
||||
"app/api/workouts/batch/route.ts": 9,
|
||||
"app/api/workouts/route.ts": 2,
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { truncateAllTables } from "../../tests/integration/setup";
|
||||
import type { IntegrationDatabasePlan } from "../../tests/integration/worker-database";
|
||||
|
||||
/** What global setup reads off the migrated template, cut down to the shape. */
|
||||
const PLAN: IntegrationDatabasePlan = {
|
||||
templateSchemas: ["public"],
|
||||
tables: ["app_settings", "mood_tag_categories", "mood_tags", "users"],
|
||||
reference: [
|
||||
{ table: "app_settings", rowsJson: '[{"id":"singleton"}]' },
|
||||
{ table: "mood_tag_categories", rowsJson: '[{"id":"c1"}]' },
|
||||
{ table: "mood_tags", rowsJson: '[{"id":"t1","category_id":"c1"}]' },
|
||||
],
|
||||
};
|
||||
|
||||
function deadlock(): Error & { code: string } {
|
||||
const error = new Error("deadlock detected") as Error & { code: string };
|
||||
@@ -19,45 +31,61 @@ function deadlock(): Error & { code: string } {
|
||||
return error;
|
||||
}
|
||||
|
||||
function fakeClient(executeRaw: ReturnType<typeof vi.fn>) {
|
||||
const empty = vi.fn().mockResolvedValue([]);
|
||||
return {
|
||||
$executeRawUnsafe: executeRaw,
|
||||
moodTag: { findMany: empty, createMany: vi.fn() },
|
||||
moodTagCategory: { findMany: empty, createMany: vi.fn() },
|
||||
} as unknown as Parameters<typeof truncateAllTables>[0];
|
||||
type Execute = (query: string, ...values: unknown[]) => Promise<number>;
|
||||
|
||||
function fakeClient(executeRaw: Execute) {
|
||||
return { $executeRawUnsafe: executeRaw };
|
||||
}
|
||||
|
||||
describe("truncateAllTables", () => {
|
||||
it("retries a deadlocked truncate and succeeds once the lock clears", async () => {
|
||||
const executeRaw = vi
|
||||
.fn()
|
||||
.fn<Execute>()
|
||||
.mockRejectedValueOnce(deadlock())
|
||||
.mockRejectedValueOnce(deadlock())
|
||||
.mockResolvedValue(0);
|
||||
|
||||
await truncateAllTables(fakeClient(executeRaw));
|
||||
await truncateAllTables(fakeClient(executeRaw), PLAN);
|
||||
|
||||
expect(executeRaw).toHaveBeenCalledTimes(3);
|
||||
// Three truncate attempts, then the two seeded tables put back.
|
||||
expect(executeRaw).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("empties every catalogued table and puts the seeded rows back in key order", async () => {
|
||||
const executeRaw = vi.fn<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 () => {
|
||||
const other = new Error("permission denied") as Error & { code: string };
|
||||
other.code = "42501";
|
||||
const executeRaw = vi.fn().mockRejectedValue(other);
|
||||
const executeRaw = vi.fn<Execute>().mockRejectedValue(other);
|
||||
|
||||
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow(
|
||||
"permission denied",
|
||||
);
|
||||
await expect(
|
||||
truncateAllTables(fakeClient(executeRaw), PLAN),
|
||||
).rejects.toThrow("permission denied");
|
||||
expect(executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops retrying a deadlock that never clears", async () => {
|
||||
const executeRaw = vi.fn().mockRejectedValue(deadlock());
|
||||
const executeRaw = vi.fn<Execute>().mockRejectedValue(deadlock());
|
||||
|
||||
await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow(
|
||||
"deadlock detected",
|
||||
);
|
||||
await expect(
|
||||
truncateAllTables(fakeClient(executeRaw), PLAN),
|
||||
).rejects.toThrow("deadlock detected");
|
||||
expect(executeRaw).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,14 @@ import { join } from "node:path";
|
||||
|
||||
const WORKFLOWS = join(process.cwd(), ".github", "workflows");
|
||||
|
||||
/** `run: pnpm test` — the whole unit suite, geometry check included. */
|
||||
const RUNS_UNIT_SUITE = /^\s*(?:-\s*)?run:\s*pnpm test\s*$/m;
|
||||
/**
|
||||
* `run: pnpm test`, or a direct `vitest run` of the unit config with flags
|
||||
* only (the sharded CI job, the nightly shuffled run): the whole unit suite
|
||||
* or a slice of it, geometry check included. A run that names a path or
|
||||
* another `--config` is a different, narrower suite and does not count.
|
||||
*/
|
||||
const RUNS_UNIT_SUITE =
|
||||
/^\s*(?:-\s*)?run:\s*pnpm test\s*$|pnpm exec vitest run(?![^\n]*--config)(?=\s*$|\s+--)/m;
|
||||
const INSTALLS_BROWSER = /playwright install --only-shell chromium/;
|
||||
|
||||
describe("the browser the unit suite measures with", () => {
|
||||
|
||||
@@ -507,9 +507,9 @@ const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
|
||||
const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> =
|
||||
[
|
||||
{
|
||||
path: "src/app/api/fhir/__tests__/module-gate.test.ts",
|
||||
path: "tests/integration/routes/fhir-module-gate.test.ts",
|
||||
proves:
|
||||
"every /api/fhir data route 403s with module.disabled when doctorReport is off",
|
||||
"every /api/fhir data route, search and read, 403s with module.disabled when doctorReport is off (real database, real handler)",
|
||||
},
|
||||
{
|
||||
path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { destroySessionById } from "@/lib/auth/session";
|
||||
import { destroySessionById, sessionHandle } from "@/lib/auth/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,15 +25,21 @@ export const DELETE = apiHandler(
|
||||
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", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { self: id === session.id },
|
||||
details: { self },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.revoke" },
|
||||
meta: { self: id === session.id },
|
||||
meta: { self },
|
||||
});
|
||||
|
||||
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
|
||||
* that meet it.
|
||||
* #947: the test button and the dispatcher must reach the SAME sender
|
||||
* 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
|
||||
* target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that
|
||||
* names the reason (`meta.errorCode = private_origin_not_approved`) instead
|
||||
* of a generic "Invalid data", and a listed literal address saves although
|
||||
* 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.
|
||||
* The behaviour at the routes (save-time refusals, the test routes' 422 and
|
||||
* 502 answers) is proven against the real database in
|
||||
* `tests/integration/routes/settings-channels.test.ts` and
|
||||
* `tests/integration/routes/settings-channel-tests.test.ts`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, 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/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,
|
||||
});
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("the test button and the dispatcher share one sender", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -368,7 +368,11 @@ export function transformRescales(transform: DisplayTransform): boolean {
|
||||
function roundToDecimals(value: number, decimals: number): number {
|
||||
if (!Number.isFinite(value)) return value;
|
||||
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
|
||||
* threw out of the walk before the upsert. The activities read before it
|
||||
* land now, the cursor moves only as far as an oldest-first walk proves
|
||||
* safe, the failure is booked as transient, and the backfill waits for
|
||||
* 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.
|
||||
* What a rate limit does to a running sync (the activities read before it
|
||||
* land, the cursor moves only as far as an oldest-first walk proves, the
|
||||
* backfill waits out the window before its next run) is pinned against
|
||||
* Postgres in `tests/integration/strava-sync.test.ts`.
|
||||
*/
|
||||
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 { 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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient } from "./setup";
|
||||
import { TEMPLATE_DATABASE, workerDatabaseName } from "./worker-database";
|
||||
|
||||
const TEST_ENCRYPTION_KEY =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
@@ -22,6 +23,9 @@ describe("integration worker environment", () => {
|
||||
>`
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import { afterEach, inject, vi } from "vitest";
|
||||
import { afterEach, beforeEach, inject, vi } from "vitest";
|
||||
|
||||
declare module "vitest" {
|
||||
export interface ProvidedContext {
|
||||
integrationDatabaseUrl: string;
|
||||
}
|
||||
}
|
||||
import { quarantineEntryFor } from "../quarantine/quarantine";
|
||||
import { prepareWorkerDatabase } from "./worker-database";
|
||||
|
||||
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 =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
delete process.env.ENCRYPTION_KEYS;
|
||||
@@ -33,6 +36,47 @@ afterEach(async () => {
|
||||
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
|
||||
* suites run before the Web API turndown, whatever the real date, and a test
|
||||
|
||||
@@ -1,47 +1,84 @@
|
||||
/**
|
||||
* Vitest globalSetup — boots ONE Postgres testcontainer for the whole
|
||||
* integration run, applies migrations, and exposes the connection URL
|
||||
* via process.env.DATABASE_URL. The teardown returned at the end stops
|
||||
* the container after all test files have finished.
|
||||
* integration run, migrates ONE template database, and hands the workers
|
||||
* what they need to clone it. The teardown returned at the end stops the
|
||||
* 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
|
||||
* module load time using the current `process.env.DATABASE_URL`. If
|
||||
* the container is rotated between test files, the singleton keeps
|
||||
* pointing at the dead container and queries fail. Booting once,
|
||||
* keeping the URL stable, and truncating between tests is both
|
||||
* faster and safer.
|
||||
* - Truncate-between-tests still gives per-test isolation; see
|
||||
* `setup.ts -> truncateAllTables`.
|
||||
* module load time from `process.env.DATABASE_URL`. A worker's URL is
|
||||
* fixed before any application module loads (`environment-setup.ts`),
|
||||
* so the singleton always points at that worker's clone.
|
||||
*
|
||||
* What the template carries besides the schema is recorded here as well:
|
||||
* the rows the migrations seed (the mood-tag and symptom catalogues, the
|
||||
* 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 { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { Client } from "pg";
|
||||
import type { TestProject } from "vitest/node";
|
||||
import {
|
||||
PostgreSqlContainer,
|
||||
type StartedPostgreSqlContainer,
|
||||
} from "@testcontainers/postgresql";
|
||||
|
||||
import {
|
||||
TEMPLATE_DATABASE,
|
||||
type IntegrationDatabasePlan,
|
||||
type ReferenceTable,
|
||||
} from "./worker-database";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const PROJECT_ROOT = resolve(__dirname, "..", "..");
|
||||
|
||||
let container: StartedPostgreSqlContainer | null = null;
|
||||
|
||||
declare module "vitest" {
|
||||
export interface ProvidedContext {
|
||||
integrationDatabaseUrl: string;
|
||||
integrationDatabasePlan: IntegrationDatabasePlan;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function setup(
|
||||
project: TestProject,
|
||||
): Promise<() => Promise<void>> {
|
||||
container = await new PostgreSqlContainer("postgres:16-alpine")
|
||||
.withDatabase("healthlog_test")
|
||||
.withDatabase(TEMPLATE_DATABASE)
|
||||
.withUsername("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();
|
||||
|
||||
const url = container.getConnectionUri() + "?schema=public&pgbouncer=false";
|
||||
process.env.DATABASE_URL = url;
|
||||
project.provide("integrationDatabaseUrl", url);
|
||||
|
||||
// The explicit child environment keeps Prisma CLI on the same container.
|
||||
execSync("pnpm db:migrate:deploy", {
|
||||
@@ -50,6 +87,9 @@ export default async function setup(
|
||||
env: { ...process.env, DATABASE_URL: url },
|
||||
});
|
||||
|
||||
project.provide("integrationDatabaseUrl", url);
|
||||
project.provide("integrationDatabasePlan", await readTemplate(url));
|
||||
|
||||
return async () => {
|
||||
if (container) {
|
||||
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;
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
/**
|
||||
* Shared `next/headers` mock state for the integration suite.
|
||||
*
|
||||
* Background: vitest runs the integration suite with `isolate: false`
|
||||
* (one worker, one container). The factory passed to
|
||||
* `vi.mock("next/headers", ...)` is resolved ONCE per worker —
|
||||
* whichever test file loads first wins. If two files each declare a
|
||||
* top-level `const cookieJar = new Map()` and reference it from their
|
||||
* own `vi.mock("next/headers", ...)` factory, only the first file's
|
||||
* Map is ever read; the second file's writes silently disappear and
|
||||
* its tests flake based on import order.
|
||||
* `environment-setup.ts` mocks `next/headers` for every file with a stand-in
|
||||
* that reads these jars, so a test sets a cookie or a header here and the
|
||||
* route handler under test sees it. Files are isolated (`isolate: true`), so
|
||||
* each file gets fresh jars; clear them in `beforeEach` to keep cases
|
||||
* independent. The route test kit (`tests/kit/`) fills them from the `auth`
|
||||
* option of `callRoute`, so a kit test never touches them directly.
|
||||
*
|
||||
* Fix: every integration file imports `cookieJar` + `headerJar` from
|
||||
* THIS module (a singleton) and clears them in `beforeEach`. The
|
||||
* `vi.mock` factory inside each test file resolves the Maps via
|
||||
* `await import()` so it sees the same Map instances regardless of
|
||||
* which file's mock factory ran first.
|
||||
* Many files still declare their own identical `vi.mock("next/headers")`.
|
||||
* That predates the shared one, is harmless (a file's own mock wins), and is
|
||||
* only needed where a file wants different behaviour, such as consuming
|
||||
* `queuedSessionIds` below.
|
||||
*/
|
||||
export const cookieJar = new Map<string, string>();
|
||||
export const headerJar = new Map<string, string>();
|
||||
|
||||
@@ -153,7 +153,8 @@ async function untilHeldInTrigger() {
|
||||
const prisma = getPrismaClient();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
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;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
@@ -68,6 +68,7 @@ async function waitForRollupLockWaiter(timeoutMs = 5_000): Promise<boolean> {
|
||||
SELECT count(*)::bigint AS n
|
||||
FROM pg_stat_activity
|
||||
WHERE wait_event_type = 'Lock'
|
||||
AND datname = current_database()
|
||||
AND query ILIKE '%measurement_rollups%'
|
||||
`;
|
||||
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
Reference in new issue
Block a user