From e161fcfd028893723a00cee3acf93e47f2531448 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 04:39:12 +0000 Subject: [PATCH 1/4] issues: capture worker/edge execution gap (#098) and component state-matrix coverage (#099) Both recorded from the 2026-07-29 test-coverage analysis with measured evidence so the follow-ups survive the session context. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012YRCXgX4AWZ579bKN6sk6b --- docs/outstanding-issues.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 056df14d5d..92196b8ce5 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -76,7 +76,7 @@ removed after current-main verification; it is not missing recommended work. | 28 | `#079` | Optional | High — repository hygiene | In explicitly scheduled batches | 30–60 minutes per batch | Disposition at most ten retained worktrees per pass using owner, PR, review-ledger, ancestry, and patch evidence. Preserve every dirty, active, secret-bearing, post-freeze, or ambiguous worktree and stop rather than broad-cleaning. | | 29 | `#086` | A3 | High — repository structure + Specialist | On explicit go-ahead for X3; later packages own their gates | 1 PR per work order | Ship remaining maturity backlog (X3 rag.ts; X7 src/lib reorg; X6 coverage floors; X5 ACL consolidation; L1 one-shot archive; L4 ledger rotation; M1 host hardening) as verified draft PRs from `docs/maturity-backlog-workorders.md`. Start with X3 after go-ahead; stop before RAG edits without the flag or X5 without live-DB approval. | - + ## Open items @@ -131,6 +131,8 @@ removed after current-main verification; it is not missing recommended work. | #095 | P3 | issue | `PR required` reports failure for concurrency-cancelled jobs | **Outcome:** a red `PR required` means a real failure. **Detail:** the aggregate calls `require_success` on `coverage`/`production-ui`, so any push that supersedes an in-flight run reports `coverage result was cancelled` → exit 1, indistinguishable at a glance from a genuine failure. Eleven such reds were produced on PR #1316 in one session, and `Production UI` never once ran to completion. **Next:** in `.github/workflows/ci.yml`, either treat `cancelled` distinctly from `failure` in the aggregate, or reduce push frequency against long UI runs. **Stop:** do not relax `require_success` for genuine failures while doing so. | PR #1316 runs 30340972329 / 30341225585; session 2026-07-28 | 2026-07-28 | | #096 | P2 | task | One PR #1316 review fix is still a live gap on `main` | **Outcome:** the two remaining PR #1316 review findings are fixed on `main` with tests. **Do not chase the commits.** The seven Codex follow-up SHAs (`ff5b682`, `77cfe12`, `9840ed9`, `81ffb86`, `a5d6561`, `967e16c`, `e544d0d`) are **unreachable** — `git fetch origin ` fails for all seven, no open PR or branch carries them, and none was in the squash merge `4bcfeb90`. They were authored in a sandbox on a branch named `work` and never pushed, so the "follow-up PR metadata" each reported does not exist. **Durable source:** the [PR #1316 review threads](https://github.com/BigSimmo/Database/pull/1316/files) persist and describe every fix with file and line detail; re-derive from those, not from the hashes. **Still live on `main` (verified 2026-07-28):** the band adoption gate skips query-backed root modes — `tests/search-results-band-adoption.test.ts:101` returns null for `pathOnly === "/"`, so `/?mode=prescribing` and Documents never enter the route inventory and the root dashboard page is unchecked. **Already fixed independently, no action:** favourites hub counts (`libraryCountsTrusted`), the document-search status derivation, the 401 session-expiry path, and the record-path duplicate notice. **Corrected 2026-07-28 — the Therapy Compass retry-waiter finding is NOT a live defect.** `use-therapy-data.ts:68` `retryWaitersRef` is genuinely unscoped, so a newer request can settle an older retry's promise, but no caller observes it: `useTherapyData` lives in the long-lived `TcProvider` (`bindings.tsx:206`) and `requestKey` derives only from `screen`, so it cannot change without the screen changing; the sole awaiting caller is the band's `AsyncButton` inside `search-screen.tsx:33`, which unmounts on that transition, and `workspace.tsx:36` uses `onClick={b.retryData}` which discards the promise. An earlier note here claimed a visible "Retry stops being busy" symptom — that was wrong and is retained only as the correction. It becomes real if a future caller ever awaits `retry()` from a control that survives a `requestKey` change. **Next:** resolve root-path and href-less modes to `src/app/(search-app)/page.tsx` in the adoption gate, with a negative fixture for a disconnected root route. **Stop:** not user-facing; do not let it block a release, and do not add waiter keying without a reproducer showing a still-mounted control whose busy state clears early. | PR #1316 review sweep; session 2026-07-28 | 2026-07-28 | | #097 | P3 | issue | Gitleaks reports a false red when the PR head moves mid-run | **Outcome:** a red `Gitleaks` means a secret was found, not that someone pushed. **Detail:** on 2026-07-28 the job triggered for head `9bace1d1` checked out that merge ref, then queried the API and built its range against head `40278453` — pushed seconds later and absent from the checkout. Git rejected the range (`fatal: Invalid revision range`), so it scanned `~0 bytes`, logged `no leaks found in partial scan`, and exited 1. The scan did not run at all, which is worse than a normal failure because the natural reading is "noise, ignore it". It cleared on its own once the head stopped moving (`23 commits scanned`, `~198 KB`, `no leaks found`). Both range endpoints resolve in any complete checkout — verified locally against the branch and the PR merge ref — so this is not a `fetch-depth` problem. **Next:** pin the scan to a range the job controls (`base.sha`..the checked-out head) instead of re-querying the API mid-run, so a concurrent push cannot invalidate it. **Related:** same push-churn family as #095. **Stop:** do not weaken the gate to a soft-pass; the fix is a stable range, not a tolerated failure. | PR #1316 runs 30344938800 / 30346797225; session 2026-07-28 | 2026-07-28 | +| #098 | P2 | rec | Ingestion worker and indexing agent are verified by grepping their own source | **Outcome:** the ingestion worker and indexing agent are verified by executing code, not by asserting on their own source text. **Detail:** measured 2026-07-29 via `npm run test:coverage` — `worker/main.ts` (2,015 lines) and `supabase/functions/indexing-v3-agent/index.ts` (1,966 lines) each report **0% executed lines**; no test imports either module. Both are covered only by `readFileSync` + `toContain` assertions in `worker-safe-logging.test.ts`, `worker-visual-capture.test.ts` and `document-metadata-merge.test.ts`, which pass whenever a string is present and break on harmless refactors; `document-metadata-merge.test.ts` additionally reimplements the SQL deep-merge in TypeScript and tests the reimplementation rather than the worker. Area totals: `worker/` 18.6% lines, `supabase/functions/` 4.5%. **Next:** continue the extraction pattern that already works here — `indexing-v3-agent/behavior.ts` (167 lines, 96%) and `ingestion-worker/auth.ts` (30 lines, 90%) — pulling the highest-risk decision points out of `worker/main.ts` (job claim/retry, generation commit, failure classification) into importable modules with executing tests, retiring the matching source-text assertion as each lands. Roughly cost-neutral: each extracted test replaces a grep assertion. **Stop:** do not try to make the 2,000-line entrypoint importable in one pass; extract incrementally and keep each step green. | session 2026-07-29 test-coverage analysis | 2026-07-29 | +| #099 | P2 | rec | Component state matrices are the largest untested surface | **Outcome:** loading / empty / error / disabled states on interactive components are covered by executing tests, not only by E2E happy paths. **Detail:** measured 2026-07-29 — production components (excluding mockups) sit at **38.2% lines / 22.8% branch** across 12,602 lines, with **83 of 208 files at zero executed lines**; there are 51 `.dom.test.tsx` files against 195 components. Playwright does visit these routes, so they are smoke-covered, but branch coverage is where the state matrix lives and smoke journeys rarely reach it. Worst by uncovered lines: `global-search-shell.tsx` (7%), `mode-action-popup.tsx` (21%), `answer-content.tsx` (27%), `document-search-results.tsx` (32%), `universal-search-command-surface.tsx` (39%), `master-search-header.tsx` (43%). A concrete first target with clinical meaning: `calculator-ui.tsx` now covers all exported scoring logic, but `seedCheckboxDefaults`, `toggleCheckboxAnswer` and `selectOptionAnswer` stay uncovered because they are module-private and only reachable through React event handlers — `seedCheckboxDefaults` is what makes an all-negative CAGE / SAD PERSONS screen read as a valid 0 rather than incomplete, so a regression there is a false-negative risk. **Next:** treat as a per-PR convention rather than a backfill push — `docs/testing.md` already prescribes the state matrix, so the gap is enforcement. Start with `global-search-shell.tsx`, which `docs/search-chrome-behaviour.md` treats as a contract surface. Keep additions in the jsdom tier (measured ~0.54s per file) instead of new Playwright journeys (~231 production journeys already run serially at `workers: 1` against a 45-minute CI budget). **Stop:** do not chase the coverage percentage by backfilling low-risk components; the re-ratcheted broad floor in `vitest.config.mts` holds the line. | session 2026-07-29 test-coverage analysis | 2026-07-29 | ## Resolved / archive From 665a036291ccc30fd0d2fece9e3dfd4cfd799f1d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 04:39:12 +0000 Subject: [PATCH 2/4] test: cover clinical calculator scoring and the batch image signed-URL route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds executing coverage to the two highest-risk surfaces that had none, and re-ratchets the broad coverage floor that had drifted well below measured. Clinical calculator scoring (src/components/calculators/calculator-ui.tsx, previously 0% executed lines). The exported pure functions carry the scoring and interpretation for eight validated instruments — PHQ-9, GAD-7, K10, MDQ, CAGE, AUDIT-C, SAD PERSONS, Y-BOCS — and the only calculator test in the repo asserted mockup import boundaries. A wrong severity band renders as an ordinary result, so it needs a floor. tests/calculator-scoring.test.ts covers band-table integrity for all eight instruments (contiguous, spanning the declared range, exactly one band per reachable score, range actually reachable from the items), PHQ-9/GAD-7 boundary scores either side of every cut point, itemScore edge cases, band suppression for scales that cannot read zero (K10, CAGE), the MDQ three-criterion rule, and the result summary text. No live defect was found — the band tables are correct today; nothing was holding them there. Batch image signed URLs (src/app/api/images/signed-urls/route.ts, previously 0% executed lines, no test references). The route mints up to 100 signed URLs per call and carries its own owner-scope and committed-generation implementation, parallel to /api/images/[id]/signed-url which already had five cases. check:owner-scope-api cannot cover the real protection because document_images has no owner_id column: the only thing excluding another owner's image is the documentMap join filter in the handler. Nine cases now pin that filter, including the batch-specific case where one unowned id rides along with an owned one. Added to tests/private-access-routes.test.ts rather than a new file so it reuses that harness; createSignedUrls was added to the storage mock. Both additions were mutation-checked. Overlapping a PHQ-9 band fails three tests including "scores 5 as Mild"; loosening the MDQ impairment threshold from moderate to minor fails one; making the batch route allow a documentMap miss fails the two cross-owner cases. Before this change that last mutation passed the entire suite silently. Coverage floor: the broad group measured 63.99/55.29/57.6/66.19 against floors of 48/38/43/50 — 14-17pp of slack, so a change could delete a large amount of coverage and still pass. Each floor now sits ~2pp under measured. Kept deliberately in the cheap tiers: the unit suite is 46% test execution and 54% import/environment overhead, so a new file costs ~0.37s before it asserts anything while an assertion in an existing file costs ~0.03s. The calculator file runs 60 tests in 514ms (27ms of that is assertions). Nothing was added to the Playwright gate, which runs ~231 journeys serially against a 45-minute budget. Verified: npm run verify:cheap (exit 0; 423 files, 4340 passed | 4 skipped) and npm run test:coverage (exit 0, no threshold errors). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012YRCXgX4AWZ579bKN6sk6b --- tests/calculator-scoring.test.ts | 339 ++++++++++++++++++++++++++++ tests/private-access-routes.test.ts | 201 ++++++++++++++++- vitest.config.mts | 15 +- 3 files changed, 549 insertions(+), 6 deletions(-) create mode 100644 tests/calculator-scoring.test.ts diff --git a/tests/calculator-scoring.test.ts b/tests/calculator-scoring.test.ts new file mode 100644 index 0000000000..bc0f6c4963 --- /dev/null +++ b/tests/calculator-scoring.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it } from "vitest"; + +import { calculators, type CalculatorFixture, type CalculatorItem } from "@/components/calculators/calculator-fixtures"; +import { + deriveCalculator, + formatResultSummary, + itemScore, + progressLabel, + type AnswerMap, +} from "@/components/calculators/calculator-ui"; + +/* + * Scoring guard for the eight validated instruments in the calculators mode. + * + * `calculator-ui.tsx` held the band lookup, the MDQ three-criterion rule and the + * band-suppression rules with no executing test — the only calculator test in the + * repo asserted mockup import boundaries. A wrong band is a clinical + * misclassification that renders as a normal result, so the floor belongs here + * rather than in a Playwright journey: these are pure functions, so the whole + * file costs about as much as one browser navigation. + * + * Everything below drives the exported pure functions only. No DOM, no mocks. + */ + +function fixture(id: string): CalculatorFixture { + const calc = calculators.find((entry) => entry.id === id); + if (!calc) throw new Error(`calculator fixture "${id}" not found`); + return calc; +} + +function lowestOptionIndex(item: CalculatorItem): number { + const options = item.options ?? []; + let lowest = 0; + for (let index = 1; index < options.length; index += 1) { + if (options[index].points < options[lowest].points) lowest = index; + } + return lowest; +} + +/** Every item at its floor: the minimum score the instrument can report. */ +function baselineAnswers(calc: CalculatorFixture): AnswerMap { + const answers: AnswerMap = {}; + for (const item of calc.items) { + answers[item.id] = item.kind === "options" ? lowestOptionIndex(item) : 0; + } + return answers; +} + +/** Every item at its ceiling: the maximum score the instrument can report. */ +function maximalAnswers(calc: CalculatorFixture): AnswerMap { + const answers: AnswerMap = {}; + for (const item of calc.items) { + if (item.kind !== "options") { + answers[item.id] = 1; + continue; + } + const options = item.options ?? []; + let highest = 0; + for (let index = 1; index < options.length; index += 1) { + if (options[index].points > options[highest].points) highest = index; + } + answers[item.id] = highest; + } + return answers; +} + +/** + * Build a complete answer map scoring exactly `target`. Starts from the floor and + * raises one item at a time. Every caller asserts the resulting score, so a helper + * that cannot reach the target fails loudly instead of weakening the assertion. + */ +function answersForScore(calc: CalculatorFixture, target: number): AnswerMap { + const answers = baselineAnswers(calc); + let remaining = target - deriveCalculator(calc, answers).score; + + for (const item of calc.items) { + if (remaining <= 0) break; + if (item.kind === "options") { + const options = item.options ?? []; + const currentIndex = answers[item.id] ?? 0; + const currentPoints = options[currentIndex]?.points ?? 0; + let best = currentIndex; + for (let index = 0; index < options.length; index += 1) { + const delta = options[index].points - currentPoints; + if (delta > 0 && delta <= remaining && options[index].points > options[best].points) best = index; + } + remaining -= (options[best]?.points ?? 0) - currentPoints; + answers[item.id] = best; + } else { + const points = item.points ?? 0; + if (points > 0 && points <= remaining) { + answers[item.id] = 1; + remaining -= points; + } + } + } + + return answers; +} + +describe("calculator band tables", () => { + it.each(calculators.map((calc) => [calc.id, calc] as const))( + "%s declares contiguous bands spanning its full score range", + (_id, calc) => { + const bands = [...calc.bands].sort((left, right) => left.min - right.min); + + expect(bands.length).toBeGreaterThan(0); + expect(bands[0].min).toBe(calc.minScore); + expect(bands[bands.length - 1].max).toBe(calc.maxScore); + + for (let index = 1; index < bands.length; index += 1) { + // A gap leaves a score with no band ("—"); an overlap makes the + // reported severity depend on declaration order. + expect(bands[index].min, `band ${index} of ${calc.id} must start one above the previous band`).toBe( + bands[index - 1].max + 1, + ); + } + }, + ); + + it.each(calculators.map((calc) => [calc.id, calc] as const))( + "%s maps every reachable score to exactly one band", + (_id, calc) => { + for (let score = calc.minScore; score <= calc.maxScore; score += 1) { + const matches = calc.bands.filter((band) => score >= band.min && score <= band.max); + expect(matches, `${calc.id} score ${score} matched ${matches.length} bands`).toHaveLength(1); + } + }, + ); + + it.each(calculators.map((calc) => [calc.id, calc] as const))( + "%s items can actually reach its declared score range", + (_id, calc) => { + // A declared range the items cannot produce means either an unreachable + // band or a score that falls off the end of the table. + expect(deriveCalculator(calc, baselineAnswers(calc)).score).toBe(calc.minScore); + expect(deriveCalculator(calc, maximalAnswers(calc)).score).toBe(calc.maxScore); + }, + ); +}); + +describe("itemScore", () => { + const checkbox: CalculatorItem = { id: "x", text: "x", kind: "checkbox", points: 3 }; + const options: CalculatorItem = { + id: "y", + text: "y", + kind: "options", + options: [ + { label: "None", short: "None", points: 0 }, + { label: "Some", short: "Some", points: 2 }, + ], + }; + + it("scores an unanswered item as zero", () => { + expect(itemScore(checkbox, undefined)).toBe(0); + expect(itemScore(options, undefined)).toBe(0); + }); + + it("scores a checkbox only when explicitly ticked", () => { + expect(itemScore(checkbox, 1)).toBe(3); + expect(itemScore(checkbox, 0)).toBe(0); + }); + + it("scores an options item by the selected option's points", () => { + expect(itemScore(options, 0)).toBe(0); + expect(itemScore(options, 1)).toBe(2); + }); + + it("treats an out-of-range option index as zero rather than NaN", () => { + expect(itemScore(options, 7)).toBe(0); + }); + + it("treats a checkbox with no declared points as zero", () => { + expect(itemScore({ id: "z", text: "z", kind: "checkbox" }, 1)).toBe(0); + }); +}); + +describe("PHQ-9 severity banding", () => { + const phq9 = fixture("phq9"); + + // Boundary scores on both sides of every published PHQ-9 cut point. These map + // to stepped treatment actions, so an off-by-one here changes clinical advice. + const boundaries = [ + [0, "Minimal"], + [4, "Minimal"], + [5, "Mild"], + [9, "Mild"], + [10, "Moderate"], + [14, "Moderate"], + [15, "Moderately severe"], + [19, "Moderately severe"], + [20, "Severe"], + [27, "Severe"], + ] as const; + + it.each(boundaries)("scores %i as %s", (score, label) => { + const state = deriveCalculator(phq9, answersForScore(phq9, score)); + expect(state.score).toBe(score); + expect(state.band?.label).toBe(label); + expect(state.result.label).toBe(label); + }); + + it("raises the item-9 self-harm flag only when that item scores above zero", () => { + const flagged = deriveCalculator(phq9, { ...baselineAnswers(phq9), p9: 1 }); + expect(flagged.flags).toEqual(["Item 9 endorsed — complete a structured suicide-risk assessment now."]); + + const unflagged = deriveCalculator(phq9, { ...baselineAnswers(phq9), p9: 0 }); + expect(unflagged.flags).toEqual([]); + }); + + it("publishes a provisional band while a zero-floor scale is still filling in", () => { + const partial = deriveCalculator(phq9, { p1: 3, p2: 3 }); + expect(partial.complete).toBe(false); + expect(partial.started).toBe(true); + expect(partial.band?.label).toBe("Mild"); + }); +}); + +describe("GAD-7 severity banding", () => { + const gad7 = fixture("gad7"); + + it.each([ + [0, "Minimal"], + [4, "Minimal"], + [5, "Mild"], + [9, "Mild"], + [10, "Moderate"], + [14, "Moderate"], + [15, "Severe"], + [21, "Severe"], + ] as const)("scores %i as %s", (score, label) => { + const state = deriveCalculator(gad7, answersForScore(gad7, score)); + expect(state.score).toBe(score); + expect(state.band?.label).toBe(label); + }); +}); + +describe("band suppression for scales that cannot read zero", () => { + it("withholds a K10 band until every item is answered", () => { + const k10 = fixture("k10"); + // K10 floors at 10, so a part-filled scale would otherwise fall below the + // table and read as the lowest band on incomplete data. + const partial = deriveCalculator(k10, { k1: 4, k2: 4 }); + expect(partial.complete).toBe(false); + expect(partial.band).toBeUndefined(); + expect(partial.result.label).toBe("—"); + + const complete = deriveCalculator(k10, answersForScore(k10, 30)); + expect(complete.complete).toBe(true); + expect(complete.score).toBe(30); + expect(complete.band?.label).toBe("Very high"); + }); + + it("withholds a CAGE band until every yes/no item is recorded", () => { + const cage = fixture("cage"); + // A half-ticked checkbox screen still has undefined items; reading it as + // "low risk" would be a false negative. + const partial = deriveCalculator(cage, { c1: 0, c2: 0 }); + expect(partial.complete).toBe(false); + expect(partial.band).toBeUndefined(); + + const allNegative = deriveCalculator(cage, { c1: 0, c2: 0, c3: 0, c4: 0 }); + expect(allNegative.complete).toBe(true); + expect(allNegative.score).toBe(0); + expect(allNegative.band?.label).toBeDefined(); + }); +}); + +describe("MDQ three-criterion screen", () => { + const mdq = fixture("mdq"); + + /** `count` symptom items endorsed, plus the two criterion items. */ + function mdqAnswers(count: number, extras: AnswerMap = {}): AnswerMap { + const answers: AnswerMap = {}; + for (let index = 1; index <= 13; index += 1) { + answers[`m${index}`] = index <= count ? 1 : 0; + } + return { ...answers, ...extras }; + } + + it("returns a positive screen only when all three criteria are met", () => { + const state = deriveCalculator(mdq, mdqAnswers(7, { mco: 1, mimp: 2 })); + expect(state.score).toBe(7); + expect(state.result.label).toBe("Positive screen"); + expect(state.result.tone).toBe("danger"); + }); + + it("withholds a positive screen when co-occurrence is not confirmed", () => { + const state = deriveCalculator(mdq, mdqAnswers(7, { mco: 0, mimp: 3 })); + expect(state.result.label).toBe("Symptom threshold met"); + expect(state.result.guidance).toContain("co-occurrence"); + }); + + it("withholds a positive screen when impairment is below moderate", () => { + const state = deriveCalculator(mdq, mdqAnswers(9, { mco: 1, mimp: 1 })); + expect(state.result.label).toBe("Symptom threshold met"); + expect(state.result.guidance).toContain("moderate-or-serious impairment"); + }); + + it("names both unmet criteria when neither is confirmed", () => { + const state = deriveCalculator(mdq, mdqAnswers(13, { mco: 0, mimp: 0 })); + expect(state.result.guidance).toContain("co-occurrence and moderate-or-serious impairment"); + }); + + it("returns a negative screen one symptom below the threshold", () => { + const state = deriveCalculator(mdq, mdqAnswers(6, { mco: 1, mimp: 3 })); + expect(state.score).toBe(6); + expect(state.result.label).toBe("Negative screen"); + expect(state.result.tone).toBe("success"); + }); + + it("scores the criterion items as zero points", () => { + // `mco` and `mimp` record state but must never inflate the symptom count. + const withCriteria = deriveCalculator(mdq, mdqAnswers(7, { mco: 1, mimp: 3 })); + const withoutCriteria = deriveCalculator(mdq, mdqAnswers(7, { mco: 0, mimp: 0 })); + expect(withCriteria.score).toBe(withoutCriteria.score); + }); +}); + +describe("result summary text", () => { + const phq9 = fixture("phq9"); + + it("reports score, maximum and band once the scale is complete", () => { + const state = deriveCalculator(phq9, answersForScore(phq9, 12)); + expect(formatResultSummary(phq9, state)).toBe("PHQ-9 12/27 — Moderate"); + }); + + it("appends progress while the scale is incomplete", () => { + const state = deriveCalculator(phq9, { p1: 3, p2: 3 }); + expect(state.complete).toBe(false); + expect(formatResultSummary(phq9, state)).toBe("PHQ-9 6/27 — Mild (2 of 9 answered)"); + }); + + it("counts endorsements rather than answers for checkbox-only scales", () => { + const cage = fixture("cage"); + const state = deriveCalculator(cage, { c1: 1, c2: 1, c3: 0, c4: 0 }); + expect(progressLabel(state)).toBe("2 of 4 endorsed"); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 863ad2d83c..11f2e67735 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -221,6 +221,13 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { data: { signedUrl: `https://signed.local/${path}` }, error: null, })); + // Batch sibling of `createSignedUrl`, used by /api/images/signed-urls. Supabase + // returns one entry per requested path, so the route matches results back to + // images by path rather than by index. + const createSignedUrls = vi.fn(async (paths: string[]) => ({ + data: paths.map((path) => ({ path, signedUrl: `https://signed.local/${path}`, error: null })), + error: null, + })); const remove = vi.fn( async ( ...args: [string[]] @@ -232,7 +239,7 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { return { data: [], error: null }; }, ); - const storageFrom = vi.fn(() => ({ upload, createSignedUrl, remove })); + const storageFrom = vi.fn(() => ({ upload, createSignedUrl, createSignedUrls, remove })); const getUser = vi.fn(async (receivedToken?: string) => receivedToken === token ? { data: { user: { id: userId, app_metadata: { site_role: "administrator" } } }, error: null } @@ -351,7 +358,7 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { }), rpc, storage: { from: storageFrom }, - storageMocks: { upload, createSignedUrl, remove, storageFrom }, + storageMocks: { upload, createSignedUrl, createSignedUrls, remove, storageFrom }, }; return client; @@ -1227,6 +1234,196 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + /* + * Batch sibling of the five `/api/images/[id]/signed-url` cases above. + * `/api/images/signed-urls` mints up to 100 signed URLs per call and carries its + * OWN owner-scope and committed-generation implementation. `check:owner-scope-api` + * cannot cover the real protection here: `document_images` has no `owner_id` + * column, so the only thing keeping another owner's image out of the response is + * the `documentMap` join filter in the handler. These cases pin that filter. + */ + const otherImageId = "44444444-4444-4444-8444-444444444444"; + + function signedUrlsRequest(imageIds: string[], authenticated = true) { + const init: RequestInit = { + method: "POST", + body: JSON.stringify({ imageIds }), + headers: { "content-type": "application/json" }, + }; + return authenticated + ? authenticatedRequest("/api/images/signed-urls", init) + : request("/api/images/signed-urls", init); + } + + /** Two images on two documents with different owners, plus owner-scoped document reads. */ + function createBatchImageMock(options: { imageGeneration?: string; documentGeneration?: string } = {}) { + const imageGeneration = options.imageGeneration ?? "generation-a"; + const documentGeneration = options.documentGeneration ?? "generation-a"; + return createSupabaseMock((call) => { + if (call.table === "document_images") { + const requested = call.inFilters.find((filter) => filter.column === "id")?.values ?? []; + return ok( + [ + { + id: imageId, + document_id: documentId, + storage_path: `${userId}/images/${imageId}.png`, + mime_type: "image/png", + caption: "Owned image", + metadata: { index_generation_id: imageGeneration }, + }, + { + id: otherImageId, + document_id: otherDocumentId, + storage_path: `${otherUserId}/images/${otherImageId}.png`, + mime_type: "image/png", + caption: "Other owner's image", + metadata: { index_generation_id: imageGeneration }, + }, + ].filter((image) => requested.includes(image.id)), + ); + } + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + // Only the caller's own document comes back from an owner-scoped read. + return ok([{ id: documentId, metadata: { index_generation_id: documentGeneration } }]); + } + return ok([]); + }); + } + + it("signs a batch of images whose parent documents are owned", async () => { + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest([imageId])); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.urls).toMatchObject({ + [imageId]: { url: `https://signed.local/${userId}/images/${imageId}.png`, mimeType: "image/png" }, + }); + expect(client.storageMocks.createSignedUrls).toHaveBeenCalledWith([`${userId}/images/${imageId}.png`], 600); + }); + + it("omits images whose parent document belongs to another user", async () => { + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest([otherImageId])); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.urls).toEqual({}); + expect(client.storageMocks.createSignedUrls).not.toHaveBeenCalled(); + }); + + it("signs only the owned image when a batch mixes owners", async () => { + // The batch-specific leak: one unowned id riding along with an owned one must + // not inherit the owned document's access. + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest([imageId, otherImageId])); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(Object.keys(body.urls as Record)).toEqual([imageId]); + expect(client.storageMocks.createSignedUrls).toHaveBeenCalledWith([`${userId}/images/${imageId}.png`], 600); + }); + + it("scopes the batch document read to the caller", async () => { + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + await POST(signedUrlsRequest([imageId])); + + const documentReads = client.calls.filter((call) => call.table === "documents"); + expect(documentReads.length).toBeGreaterThan(0); + expect(documentReads.every((call) => matchesOwnerReadScope(call, userId))).toBe(true); + }); + + it("restricts an anonymous batch to public documents", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "document_images") { + return ok([ + { + id: imageId, + document_id: documentId, + storage_path: `${userId}/images/${imageId}.png`, + mime_type: "image/png", + caption: "Owned image", + metadata: { index_generation_id: "generation-a" }, + }, + ]); + } + // No public document matches, so an anonymous caller gets nothing. + if (call.table === "documents" && matchesOwnerReadScope(call)) return ok([]); + return ok([]); + }); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest([imageId], false)); + + expect(response.status).toBe(200); + expect(await payload(response)).toEqual({ urls: {} }); + expect(client.storageMocks.createSignedUrls).not.toHaveBeenCalled(); + }); + + it("omits batch images from an uncommitted replacement generation", async () => { + const client = createBatchImageMock({ imageGeneration: "generation-new", documentGeneration: "generation-old" }); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest([imageId])); + + expect(response.status).toBe(200); + expect(await payload(response)).toEqual({ urls: {} }); + expect(client.storageMocks.createSignedUrls).not.toHaveBeenCalled(); + }); + + it("returns an empty map for an empty batch without touching storage", async () => { + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest([])); + + expect(response.status).toBe(200); + expect(await payload(response)).toEqual({ urls: {} }); + expect(client.storageMocks.createSignedUrls).not.toHaveBeenCalled(); + }); + + it("rejects a batch of non-uuid image ids before querying", async () => { + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const response = await POST(signedUrlsRequest(["not-a-uuid"])); + + expect(response.status).toBe(400); + expect(client.storageMocks.createSignedUrls).not.toHaveBeenCalled(); + }); + + it("rejects a batch larger than the documented maximum", async () => { + const client = createBatchImageMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/images/signed-urls/route"); + + const tooMany = Array.from( + { length: 101 }, + (_, index) => `55555555-5555-4555-8555-${String(index).padStart(12, "0")}`, + ); + const response = await POST(signedUrlsRequest(tooMany)); + + expect(response.status).toBe(400); + expect(client.storageMocks.createSignedUrls).not.toHaveBeenCalled(); + }); + it("rejects anonymous uploads without touching storage", async () => { const client = createSupabaseMock(); mockRuntime(client); diff --git a/vitest.config.mts b/vitest.config.mts index e7d5347a67..96d949689c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -27,11 +27,18 @@ const config = { include: ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,mjs,cjs}", "worker/**/*.ts", "supabase/functions/**/*.ts"], exclude: ["src/lib/supabase/database.types.ts"], thresholds: { + // Broad regression floor. Re-ratcheted 2026-07-29: the previous values + // (48/38/43/50) had drifted 14-17pp below measured coverage + // (63.99/55.29/57.6/66.19), so a change could delete a large amount of + // coverage and still pass. Each floor now sits ~2pp under measured — enough + // headroom for a PR that ships an uncovered surface, not enough to hide a + // regression. Re-measure with `npm run test:coverage` and raise these when + // the gap grows past ~5pp again; never lower them to make a red gate green. "src/{lib/**/*.ts,app/**/route.ts,components/**/*.{ts,tsx}}": { - statements: 48, - branches: 38, - functions: 43, - lines: 50, + statements: 62, + branches: 53, + functions: 55, + lines: 64, }, // Aggregate behavioral floors ratchet the full post-fixture group rather // than making individual large RAG modules brittle. Each value is the From 6f476b5f741627cb622af57d1b4665e3989789ca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 11:29:56 +0000 Subject: [PATCH 3/4] docs: record PR #1383 babysit after main merge Ledger row for HEAD 0922d7f5: conflict cleared by renumbering coverage follow-ups to #106/#107; verify:cheap and coverage green. Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index d8f751f93b..e86ebd7ad4 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1261,3 +1261,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | claude/latency-fixes-2026-07-29 | 0e30215e98328513e23342a8dceafc9d1728bf9d | pr-1376-ci-bugbot-repair | fixed-p1s-plus-followups;owner-scoped-epochs;shared-cache-race;stream-signal;empty-scope-timing;threads-resolved | vitest:15-pass;docs:check-links:pass;verify:cheap:earlier-pass | | 2026-07-29 | codex/remove-source-overlays | a08a81d320c9f8e1bbbe1facc266d8257213b1ad | PR #1378 babysit | FIXED Codex P1s (restore governance notice); overlays/Preview removed; merged main; verify:cheap PASS; Bugbot no open findings | verify:cheap 4273 pass; focused DOM 4/4; eslint/tsc/build PASS; hosted CI re-running after main sync | | 2026-07-29 | claude/clinical-design-system-update-e34ca9 | 0cdae091ad92f40e0ad7335b3e2d396c44188a4f | PR #1375 conflict fix + Bugbot | FIXED second CONFLICTING after #1378: took main removal of SelectedDocumentEvidencePanel; retained tracking-eyebrow on surviving document-search-results. Prior DocumentViewerRail + form-detail settlement retained. MERGEABLE; CI re-running. | local: document-search-record-fault + design-token tests; merge-tree CLEAN; prior Production UI PASS on 6903f51f; form-detail e2e 2/2. | +| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | From b72bc209b87cad249f4c21b7526a4472ffa71ece Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 29 Jul 2026 11:30:17 +0000 Subject: [PATCH 4/4] docs: supersede PR #1383 babysit ledger at tip Co-authored-by: BigSimmo --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index e86ebd7ad4..136c9563a0 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -1262,3 +1262,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-07-29 | codex/remove-source-overlays | a08a81d320c9f8e1bbbe1facc266d8257213b1ad | PR #1378 babysit | FIXED Codex P1s (restore governance notice); overlays/Preview removed; merged main; verify:cheap PASS; Bugbot no open findings | verify:cheap 4273 pass; focused DOM 4/4; eslint/tsc/build PASS; hosted CI re-running after main sync | | 2026-07-29 | claude/clinical-design-system-update-e34ca9 | 0cdae091ad92f40e0ad7335b3e2d396c44188a4f | PR #1375 conflict fix + Bugbot | FIXED second CONFLICTING after #1378: took main removal of SelectedDocumentEvidencePanel; retained tracking-eyebrow on surviving document-search-results. Prior DocumentViewerRail + form-detail settlement retained. MERGEABLE; CI re-running. | local: document-search-record-fault + design-token tests; merge-tree CLEAN; prior Production UI PASS on 6903f51f; form-detail e2e 2/2. | | 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 0922d7f56624ef84be8abcb2bbc89205027cf9a6 | PR #1383 babysit | BLOCKER CLEARED: merged origin/main; renumbered coverage follow-ups #098/#099 -> #106/#107 (main claimed #098-#105). Before: CONFLICTING/DIRTY, 4 behind; CI green on prior tip; 0 review threads; 0 Bugbot findings. After: mergeable expected; verify:cheap 424 files/4371 passed; test:coverage exit 0; format:changed + check:rag:fixtures pass. | verify:cheap PASS (424 files, 4371 passed \| 4 skipped); test:coverage PASS (no threshold errors); format:changed PASS; check:rag:fixtures PASS (36 golden); Bugbot: no findings; no provider-backed checks | +| 2026-07-29 | claude/test-coverage-analysis-2vcd8a | 6f476b5f741627cb622af57d1b4665e3989789ca | PR #1383 babysit | CLOSEOUT at tip after ledger bookkeeping commit. Merge conflict cleared; coverage follow-ups live as #106/#107; local gates green; awaiting hosted CI on tip. | same as prior tip 0922d7f5 plus ledger append only; no product code change |