mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Merge branch 'main' into olp-pr/backend-integration-tests
This commit is contained in:
commit
71a7aba337
44 changed files with 5170 additions and 27 deletions
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
30
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this PR do, in one or two sentences? -->
|
||||||
|
|
||||||
|
## Why / Motivation
|
||||||
|
|
||||||
|
<!-- What problem does this solve? Why now, and why this approach?
|
||||||
|
Link the issue or context that prompted it. -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
<!-- The notable changes, at a high level. Describe the outcome, not the diff. -->
|
||||||
|
|
||||||
|
## Tradeoffs & risks
|
||||||
|
|
||||||
|
<!-- What did you weigh? What could break, what's out of scope, and what
|
||||||
|
follow-ups (if any) does this leave behind? Note any security or
|
||||||
|
migration implications. -->
|
||||||
|
|
||||||
|
## How verified
|
||||||
|
|
||||||
|
<!-- How do you know this works? Tests added/run, manual steps, commands,
|
||||||
|
screenshots. Include the exact commands where relevant. -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Ran the relevant build/test command for the area changed.
|
||||||
|
- [ ] Reviewed `git diff` and removed unrelated changes.
|
||||||
|
- [ ] Updated docs / env examples if setup, config, or behavior changed.
|
||||||
|
- [ ] No secrets, API keys, real documents, or `.env` files committed.
|
||||||
100
.github/workflows/ci.yml
vendored
Normal file
100
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# CI: build and test.
|
||||||
|
#
|
||||||
|
# Adapted from the amal66/mike fork's .github/workflows/ci.yml (monorepo
|
||||||
|
# layout) to this repository's backend/ + frontend/ layout. Test steps use
|
||||||
|
# `npm test --if-present`, and the eval job checks for evals/run.mjs, so this
|
||||||
|
# workflow is safe to merge before or after the test-harness and evals PRs:
|
||||||
|
# on a tree without those pieces the test steps no-op and the build still
|
||||||
|
# gates the merge. Beyond the fork version this also builds the frontend
|
||||||
|
# (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and
|
||||||
|
# runs eslint as a blocking gate (the error backlog is at zero).
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend build and tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: backend/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# No-ops on a tree without a "test" script (e.g. before the vitest
|
||||||
|
# harness PR merges); runs the suite once it exists.
|
||||||
|
- run: npm test --if-present
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend build and tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
# No-ops on a tree without a "test" script (e.g. before the vitest
|
||||||
|
# harness PR merges); runs the suite once it exists.
|
||||||
|
- run: npm test --if-present
|
||||||
|
|
||||||
|
# Blocking gate: the eslint error backlog was burned down in this PR
|
||||||
|
# (0 errors; warnings do not fail the step), so any new error fails CI.
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
# Production build. NEXT_PUBLIC_* values are inlined at build time and
|
||||||
|
# only need to be well-formed here — nothing is contacted during build.
|
||||||
|
# This catches type errors (next build runs tsc) and broken routes/imports.
|
||||||
|
- run: npm run build
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sb_publishable_placeholder
|
||||||
|
NEXT_PUBLIC_API_BASE_URL: http://localhost:3001
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Offline eval harness: deterministic scorecard for citation accuracy,
|
||||||
|
# prompt-injection resistance, and privilege/PII leakage. Runs against
|
||||||
|
# committed fixtures (no network, no LLM calls, no secrets), so it is cheap
|
||||||
|
# enough to gate every PR. --threshold 1.0 = every case must pass.
|
||||||
|
# Skips gracefully until the evals PR merges.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
evals:
|
||||||
|
name: Eval harness
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Run eval harness (skips if evals/ not present)
|
||||||
|
run: |
|
||||||
|
if [ -f evals/run.mjs ]; then
|
||||||
|
node evals/run.mjs --threshold 1.0
|
||||||
|
else
|
||||||
|
echo "evals/run.mjs not present on this tree; skipping"
|
||||||
|
fi
|
||||||
|
|
@ -54,3 +54,22 @@ Frontend:
|
||||||
```bash
|
```bash
|
||||||
npm run build --prefix frontend
|
npm run build --prefix frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test --prefix backend # backend unit + route integration tests (vitest)
|
||||||
|
npm test --prefix frontend # frontend component/hook tests (vitest + jsdom)
|
||||||
|
npm run test:e2e # Playwright end-to-end suite — see docs/e2e-ci.md
|
||||||
|
node evals/run.mjs --threshold 1.0 # offline eval harness (no network, no API keys)
|
||||||
|
npm run test:stack --prefix backend # gated: real-Supabase auth/access tests (run `supabase start` first)
|
||||||
|
```
|
||||||
|
|
||||||
|
- New features and bug fixes should come with a test at the lowest layer that
|
||||||
|
can catch the regression: unit first, then route-level integration, then
|
||||||
|
end-to-end only for flows a browser is genuinely needed to prove.
|
||||||
|
- CI runs the build, unit/integration tests, and the eval harness on every PR
|
||||||
|
(`.github/workflows/ci.yml`), and the Playwright suite in a full local stack
|
||||||
|
(`.github/workflows/e2e.yml`).
|
||||||
|
- Tests that need a live Supabase or an LLM key are env-gated and skip cleanly
|
||||||
|
when the environment is absent — a plain `npm test` should always be green.
|
||||||
|
|
|
||||||
12
backend/migrations/20260716_01_chat_messages_workflow.sql
Normal file
12
backend/migrations/20260716_01_chat_messages_workflow.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Add chat_messages.workflow.
|
||||||
|
--
|
||||||
|
-- The app persists a `workflow` field on user messages
|
||||||
|
-- (backend/src/routes/chat.ts, projectChat.ts) and reads it back when rendering
|
||||||
|
-- chat history (frontend ChatView -> UserMessage, to show which workflow the
|
||||||
|
-- message was sent under). The column was referenced in code but never created
|
||||||
|
-- by schema.sql or any migration, so every user-message insert failed with
|
||||||
|
-- PostgREST PGRST204 ("Could not find the 'workflow' column") and was dropped
|
||||||
|
-- silently — user prompts vanished on reload while the assistant reply
|
||||||
|
-- persisted. Mirrors the existing content/files jsonb columns.
|
||||||
|
alter table public.chat_messages
|
||||||
|
add column if not exists workflow jsonb;
|
||||||
240
backend/package-lock.json
generated
240
backend/package-lock.json
generated
|
|
@ -38,6 +38,7 @@
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
"@types/supertest": "^7.2.1",
|
"@types/supertest": "^7.2.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
|
|
@ -970,6 +971,42 @@
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.29.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.29.2",
|
"version": "7.29.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||||
|
|
@ -979,6 +1016,30 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
|
|
@ -1490,6 +1551,16 @@
|
||||||
"hono": "^4"
|
"hono": "^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
|
@ -1497,6 +1568,17 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
|
|
@ -3565,6 +3647,37 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"ast-v8-to-istanbul": "^1.0.0",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-reports": "^3.2.0",
|
||||||
|
"magicast": "^0.5.2",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "4.1.10",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.10",
|
"version": "4.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||||
|
|
@ -3786,6 +3899,18 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"js-tokens": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
|
@ -4857,6 +4982,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
|
@ -4925,6 +5060,13 @@
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-to-text": {
|
"node_modules/html-to-text": {
|
||||||
"version": "9.0.5",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
|
@ -5085,6 +5227,45 @@
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
|
|
@ -5094,6 +5275,13 @@
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json-bigint": {
|
"node_modules/json-bigint": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||||
|
|
@ -5480,6 +5668,34 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||||
|
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.3",
|
||||||
|
"@babel/types": "^7.29.0",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mammoth": {
|
"node_modules/mammoth": {
|
||||||
"version": "1.12.0",
|
"version": "1.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
|
||||||
|
|
@ -6264,6 +6480,19 @@
|
||||||
"url": "https://ko-fi.com/killymxi"
|
"url": "https://ko-fi.com/killymxi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||||
|
|
@ -6577,6 +6806,17 @@
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.6.0"
|
"node": ">=6.6.0"
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:stack": "bash scripts/test-stack.sh"
|
"test:stack": "bash scripts/test-stack.sh"
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.90.0",
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
|
|
@ -39,6 +40,7 @@
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
"@types/supertest": "^7.2.1",
|
"@types/supertest": "^7.2.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,7 @@ create table if not exists public.chat_messages (
|
||||||
role text not null,
|
role text not null,
|
||||||
content jsonb,
|
content jsonb,
|
||||||
files jsonb,
|
files jsonb,
|
||||||
|
workflow jsonb,
|
||||||
citations jsonb,
|
citations jsonb,
|
||||||
created_at timestamptz not null default now()
|
created_at timestamptz not null default now()
|
||||||
);
|
);
|
||||||
|
|
|
||||||
163
backend/src/lib/__tests__/access.test.ts
Normal file
163
backend/src/lib/__tests__/access.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
checkProjectAccess,
|
||||||
|
ensureDocAccess,
|
||||||
|
ensureReviewAccess,
|
||||||
|
filterAccessibleDocumentIds,
|
||||||
|
listAccessibleProjectIds,
|
||||||
|
} from "../access";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
function makeDb(tables: Record<string, Row[]>) {
|
||||||
|
return {
|
||||||
|
from(table: string) {
|
||||||
|
let rows = [...(tables[table] ?? [])];
|
||||||
|
const query = {
|
||||||
|
select: () => query,
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
neq: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => row[column] !== value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
rows = rows.filter((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
filter: (column: string, operator: string, value: string) => {
|
||||||
|
if (operator !== "cs") return query;
|
||||||
|
const expected = (JSON.parse(value) as string[]).map((item) =>
|
||||||
|
item.toLowerCase(),
|
||||||
|
);
|
||||||
|
rows = rows.filter((row) => {
|
||||||
|
const actual = row[column];
|
||||||
|
const normalizedActual = Array.isArray(actual)
|
||||||
|
? actual.map((item) => String(item).toLowerCase())
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
Array.isArray(actual) &&
|
||||||
|
expected.every((item) => normalizedActual.includes(item))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
single: async () => ({ data: rows[0] ?? null, error: null }),
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[]; error: null }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("access helpers", () => {
|
||||||
|
const db = makeDb({
|
||||||
|
projects: [
|
||||||
|
{ id: "own-project", user_id: "owner", shared_with: [] },
|
||||||
|
{
|
||||||
|
id: "shared-project",
|
||||||
|
user_id: "other-owner",
|
||||||
|
shared_with: ["Reviewer@Example.com"],
|
||||||
|
},
|
||||||
|
{ id: "private-project", user_id: "other-owner", shared_with: [] },
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: "own-doc", user_id: "owner", project_id: null },
|
||||||
|
{
|
||||||
|
id: "shared-doc",
|
||||||
|
user_id: "other-owner",
|
||||||
|
project_id: "shared-project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "private-doc",
|
||||||
|
user_id: "other-owner",
|
||||||
|
project_id: "private-project",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows project owners", async () => {
|
||||||
|
await expect(
|
||||||
|
checkProjectAccess("own-project", "owner", "owner@example.com", db),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows shared project access case-insensitively", async () => {
|
||||||
|
await expect(
|
||||||
|
checkProjectAccess(
|
||||||
|
"shared-project",
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies private project access", async () => {
|
||||||
|
await expect(
|
||||||
|
checkProjectAccess(
|
||||||
|
"private-project",
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows document owners and shared-project readers", async () => {
|
||||||
|
await expect(
|
||||||
|
ensureDocAccess(
|
||||||
|
{ user_id: "owner", project_id: null },
|
||||||
|
"owner",
|
||||||
|
"owner@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: true });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureDocAccess(
|
||||||
|
{ user_id: "other-owner", project_id: "shared-project" },
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters user-supplied document IDs to accessible documents only", async () => {
|
||||||
|
await expect(
|
||||||
|
filterAccessibleDocumentIds(
|
||||||
|
["own-doc", "shared-doc", "private-doc", "missing-doc"],
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toEqual(["shared-doc"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists own and directly shared projects", async () => {
|
||||||
|
await expect(
|
||||||
|
listAccessibleProjectIds("owner", "reviewer@example.com", db),
|
||||||
|
).resolves.toEqual(expect.arrayContaining(["own-project", "shared-project"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows direct review sharing without project access", async () => {
|
||||||
|
await expect(
|
||||||
|
ensureReviewAccess(
|
||||||
|
{
|
||||||
|
user_id: "other-owner",
|
||||||
|
project_id: null,
|
||||||
|
shared_with: ["Reviewer@Example.com"],
|
||||||
|
},
|
||||||
|
"reviewer",
|
||||||
|
"reviewer@example.com",
|
||||||
|
db,
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ ok: true, isOwner: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal file
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveDoc,
|
||||||
|
resolveDocLabel,
|
||||||
|
type DocIndex,
|
||||||
|
type DocStore,
|
||||||
|
} from "../chat/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveDoc
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveDoc", () => {
|
||||||
|
const index: DocIndex = {
|
||||||
|
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
|
||||||
|
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns the doc entry for a known label", () => {
|
||||||
|
expect(resolveDoc("doc-1", index)).toEqual({
|
||||||
|
document_id: "uuid-aaa",
|
||||||
|
filename: "contract.pdf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown label", () => {
|
||||||
|
expect(resolveDoc("doc-99", index)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an empty string", () => {
|
||||||
|
expect(resolveDoc("", index)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveDocLabel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveDocLabel", () => {
|
||||||
|
const store: DocStore = new Map([
|
||||||
|
["doc-1", { storage_path: "path/a", file_type: "pdf", filename: "contract.pdf" }],
|
||||||
|
["doc-2", { storage_path: "path/b", file_type: "pdf", filename: "nda.pdf" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const index: DocIndex = {
|
||||||
|
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
|
||||||
|
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("resolves by label when the label is in the store", () => {
|
||||||
|
expect(resolveDocLabel("doc-1", store, index)).toBe("doc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by filename when the filename matches a store entry", () => {
|
||||||
|
expect(resolveDocLabel("contract.pdf", store, index)).toBe("doc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves by document UUID via the docIndex", () => {
|
||||||
|
expect(resolveDocLabel("uuid-bbb", store, index)).toBe("doc-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when nothing matches", () => {
|
||||||
|
expect(resolveDocLabel("unknown-id", store, index)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when docIndex is omitted and only UUID matches", () => {
|
||||||
|
// Without the index there is no fallback for raw UUIDs.
|
||||||
|
expect(resolveDocLabel("uuid-aaa", store)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritises exact label match over filename match", () => {
|
||||||
|
// If a label happens to equal a filename of a different doc,
|
||||||
|
// the label match wins.
|
||||||
|
const storeWithCrossMatch: DocStore = new Map([
|
||||||
|
["nda.pdf", { storage_path: "path/c", file_type: "pdf", filename: "contract.pdf" }],
|
||||||
|
]);
|
||||||
|
// "nda.pdf" is a label here, and it IS in the store, so it should
|
||||||
|
// be returned directly without the filename-fallback loop.
|
||||||
|
expect(resolveDocLabel("nda.pdf", storeWithCrossMatch)).toBe("nda.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
397
backend/src/lib/__tests__/citations.test.ts
Normal file
397
backend/src/lib/__tests__/citations.test.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseCitations,
|
||||||
|
parseCitationsWithDiagnostics,
|
||||||
|
parsePartialCitationObjects,
|
||||||
|
createCitation,
|
||||||
|
CITATIONS_OPEN_TAG,
|
||||||
|
CITATIONS_CLOSE_TAG,
|
||||||
|
} from "../chat/citations";
|
||||||
|
import type { DocIndex } from "../chat/types";
|
||||||
|
|
||||||
|
function citationsBlock(json: string) {
|
||||||
|
return `Answer text.\n${CITATIONS_OPEN_TAG}\n${json}\n${CITATIONS_CLOSE_TAG}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseCitationsWithDiagnostics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseCitationsWithDiagnostics", () => {
|
||||||
|
it("reports no block when the tags are absent", () => {
|
||||||
|
const { citations, diagnostics } =
|
||||||
|
parseCitationsWithDiagnostics("plain answer");
|
||||||
|
expect(citations).toEqual([]);
|
||||||
|
expect(diagnostics).toEqual({ hasBlock: false, rawLength: 0, error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a JSON parse error for malformed block content", () => {
|
||||||
|
const { citations, diagnostics } = parseCitationsWithDiagnostics(
|
||||||
|
citationsBlock("[{not json"),
|
||||||
|
);
|
||||||
|
expect(citations).toEqual([]);
|
||||||
|
expect(diagnostics.hasBlock).toBe(true);
|
||||||
|
expect(diagnostics.rawLength).toBeGreaterThan(0);
|
||||||
|
expect(diagnostics.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an error when the block JSON is not an array", () => {
|
||||||
|
const { citations, diagnostics } = parseCitationsWithDiagnostics(
|
||||||
|
citationsBlock('{"ref": 1}'),
|
||||||
|
);
|
||||||
|
expect(citations).toEqual([]);
|
||||||
|
expect(diagnostics.error).toBe("CITATIONS block JSON was not an array.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseCitations — document citations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseCitations (document citations)", () => {
|
||||||
|
it("parses a minimal document citation", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "doc_id": "doc-1", "page": 3, "quote": "the term"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citation).toMatchObject({
|
||||||
|
kind: "document",
|
||||||
|
ref: 1,
|
||||||
|
doc_id: "doc-1",
|
||||||
|
page: 3,
|
||||||
|
quote: "the term",
|
||||||
|
});
|
||||||
|
expect(citation.quotes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives ref from a [N] marker when ref is missing", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"marker": "[7]", "doc_id": "doc-1", "page": 1, "quote": "q"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citation.ref).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops entries without a usable ref or marker", () => {
|
||||||
|
expect(
|
||||||
|
parseCitations(
|
||||||
|
citationsBlock('[{"doc_id": "doc-1", "quote": "q", "marker": "nope"}]'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops document entries without doc_id or quote", () => {
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "doc_id": "doc-1"}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "quote": "q"}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops non-object entries but keeps valid ones", () => {
|
||||||
|
const citations = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[null, "junk", {"ref": 2, "doc_id": "doc-2", "page": 4, "quote": "kept"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 2, doc_id: "doc-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a text field as a quote alias", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 2, "text": "aliased"}]'),
|
||||||
|
);
|
||||||
|
expect(citation.kind).toBe("document");
|
||||||
|
expect((citation as { quote: string }).quote).toBe("aliased");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes pages: numbers kept, ranges kept, junk becomes 1", () => {
|
||||||
|
const citations = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "doc_id": "d", "page": 5, "quote": "a"},' +
|
||||||
|
'{"ref": 2, "doc_id": "d", "page": "3-5", "quote": "b"},' +
|
||||||
|
'{"ref": 3, "doc_id": "d", "page": "12", "quote": "c"},' +
|
||||||
|
'{"ref": 4, "doc_id": "d", "page": "unknown", "quote": "d"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citations.map((c) => (c as { page: number | string }).page)).toEqual([
|
||||||
|
5,
|
||||||
|
"3-5",
|
||||||
|
12,
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps at most 3 quotes and inherits top-level page/sheet/cell", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
ref: 1,
|
||||||
|
doc_id: "doc-1",
|
||||||
|
page: 9,
|
||||||
|
sheet: "Summary",
|
||||||
|
cell: "B7",
|
||||||
|
quotes: [
|
||||||
|
{ quote: "one" },
|
||||||
|
{ quote: "two", page: 2 },
|
||||||
|
{ quote: "three", sheet: "Detail", cell: "C1" },
|
||||||
|
{ quote: "four" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citation.kind).toBe("document");
|
||||||
|
const doc = citation as {
|
||||||
|
page: number | string;
|
||||||
|
quotes: { page: number | string; quote: string; sheet?: string; cell?: string }[];
|
||||||
|
};
|
||||||
|
expect(doc.quotes).toHaveLength(3);
|
||||||
|
expect(doc.quotes[0]).toEqual({
|
||||||
|
page: 9,
|
||||||
|
quote: "one",
|
||||||
|
sheet: "Summary",
|
||||||
|
cell: "B7",
|
||||||
|
});
|
||||||
|
expect(doc.quotes[1].page).toBe(2);
|
||||||
|
expect(doc.quotes[2]).toMatchObject({ sheet: "Detail", cell: "C1" });
|
||||||
|
// Top-level fields mirror the first quote.
|
||||||
|
expect(doc.page).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips quote rows without text", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
|
||||||
|
' "quotes": [{"page": 2}, {"quote": " "}, {"quote": "real"}]}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((citation as { quotes: unknown[] }).quotes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseCitations — case citations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parseCitations (case citations)", () => {
|
||||||
|
it("parses a case citation from a numeric cluster_id", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "cluster_id": 12345, "quote": "held that"}]'),
|
||||||
|
);
|
||||||
|
expect(citation).toMatchObject({ kind: "case", ref: 1, cluster_id: 12345 });
|
||||||
|
expect((citation as { quotes: unknown[] }).quotes).toEqual([
|
||||||
|
{ opinionId: null, type: null, author: null, quote: "held that" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts clusterId camelCase and string cluster ids", () => {
|
||||||
|
const citations = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
'[{"ref": 1, "clusterId": 7, "quote": "a"},' +
|
||||||
|
'{"ref": 2, "cluster_id": "42", "quote": "b"}]',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(citations.map((c) => (c as { cluster_id: number }).cluster_id)).toEqual([
|
||||||
|
7, 42,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors fractional cluster ids", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "cluster_id": 12.9, "quote": "q"}]'),
|
||||||
|
);
|
||||||
|
expect((citation as { cluster_id: number }).cluster_id).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats non-positive cluster ids as document citations", () => {
|
||||||
|
// cluster_id 0 fails the > 0 check, so the entry needs a doc_id.
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 0, "quote": "q"}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes structured case quotes with opinion metadata", () => {
|
||||||
|
const [citation] = parseCitations(
|
||||||
|
citationsBlock(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
ref: 3,
|
||||||
|
cluster_id: 99,
|
||||||
|
quotes: [
|
||||||
|
{
|
||||||
|
quote: "majority text",
|
||||||
|
opinion_id: 11.7,
|
||||||
|
type: "majority",
|
||||||
|
author: "Judge A",
|
||||||
|
},
|
||||||
|
{ text: "concurrence text", opinionId: 12 },
|
||||||
|
{ type: "no quote text, dropped" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((citation as { quotes: unknown[] }).quotes).toEqual([
|
||||||
|
{
|
||||||
|
opinionId: 11,
|
||||||
|
type: "majority",
|
||||||
|
author: "Judge A",
|
||||||
|
quote: "majority text",
|
||||||
|
},
|
||||||
|
{ opinionId: 12, type: null, author: null, quote: "concurrence text" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops case citations with no quotes at all", () => {
|
||||||
|
expect(
|
||||||
|
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 5}]')),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parsePartialCitationObjects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("parsePartialCitationObjects", () => {
|
||||||
|
it("returns [] when no array has started", () => {
|
||||||
|
expect(parsePartialCitationObjects("streaming <CITATIONS>")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts complete objects and ignores a trailing incomplete one", () => {
|
||||||
|
const partial =
|
||||||
|
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 2, "quote": "done"},' +
|
||||||
|
' {"ref": 2, "doc_id": "doc-2", "page": 3, "quote": "still stream';
|
||||||
|
const citations = parsePartialCitationObjects(partial);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 1, doc_id: "doc-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles braces and escaped quotes inside string values", () => {
|
||||||
|
const partial =
|
||||||
|
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
|
||||||
|
' "quote": "clause {a} says \\"stop\\""}';
|
||||||
|
const citations = parsePartialCitationObjects(partial);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect((citations[0] as { quote: string }).quote).toBe(
|
||||||
|
'clause {a} says "stop"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores content after the closing tag", () => {
|
||||||
|
const text =
|
||||||
|
'<CITATIONS>[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}]</CITATIONS>' +
|
||||||
|
'[{"ref": 9, "doc_id": "b", "page": 1, "quote": "after"}]';
|
||||||
|
const citations = parsePartialCitationObjects(text);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at the array close bracket", () => {
|
||||||
|
const text =
|
||||||
|
'[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}] {"ref": 2, "doc_id": "b", "page": 1, "quote": "outside"}';
|
||||||
|
const citations = parsePartialCitationObjects(text);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips malformed objects but keeps later valid ones", () => {
|
||||||
|
const text =
|
||||||
|
'[{"ref": bad}, {"ref": 2, "doc_id": "doc-2", "page": 1, "quote": "ok"}';
|
||||||
|
const citations = parsePartialCitationObjects(text);
|
||||||
|
expect(citations).toHaveLength(1);
|
||||||
|
expect(citations[0]).toMatchObject({ ref: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createCitation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("createCitation", () => {
|
||||||
|
const docIndex: DocIndex = {
|
||||||
|
"doc-1": {
|
||||||
|
document_id: "uuid-aaa",
|
||||||
|
filename: "contract.pdf",
|
||||||
|
version_id: "ver-1",
|
||||||
|
version_number: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("builds a document citation payload from the doc index", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 4, "quote": "q"}]'),
|
||||||
|
);
|
||||||
|
expect(createCitation(parsed, docIndex)).toMatchObject({
|
||||||
|
type: "citation_data",
|
||||||
|
kind: "document",
|
||||||
|
ref: 1,
|
||||||
|
doc_id: "doc-1",
|
||||||
|
document_id: "uuid-aaa",
|
||||||
|
version_id: "ver-1",
|
||||||
|
version_number: 2,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
page: 4,
|
||||||
|
quote: "q",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the raw doc_id as filename when unresolvable", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 1, "doc_id": "doc-9", "page": 1, "quote": "q"}]'),
|
||||||
|
);
|
||||||
|
const citation = createCitation(parsed, docIndex);
|
||||||
|
expect(citation).toMatchObject({
|
||||||
|
filename: "doc-9",
|
||||||
|
document_id: undefined,
|
||||||
|
version_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enriches a case citation from the cluster map", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
|
||||||
|
);
|
||||||
|
const cases = new Map([
|
||||||
|
[
|
||||||
|
55,
|
||||||
|
{
|
||||||
|
caseName: "Smith v. Jones",
|
||||||
|
citations: ["123 U.S. 456", "alt cite"],
|
||||||
|
url: "https://example.test/case",
|
||||||
|
pdfUrl: null,
|
||||||
|
dateFiled: "1990-01-02",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
expect(createCitation(parsed, docIndex, cases)).toMatchObject({
|
||||||
|
type: "citation_data",
|
||||||
|
kind: "case",
|
||||||
|
ref: 2,
|
||||||
|
cluster_id: 55,
|
||||||
|
case_name: "Smith v. Jones",
|
||||||
|
citation: "123 U.S. 456",
|
||||||
|
url: "https://example.test/case",
|
||||||
|
pdfUrl: null,
|
||||||
|
dateFiled: "1990-01-02",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nulls case metadata when the cluster map has no entry", () => {
|
||||||
|
const [parsed] = parseCitations(
|
||||||
|
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
|
||||||
|
);
|
||||||
|
expect(createCitation(parsed, docIndex)).toMatchObject({
|
||||||
|
case_name: null,
|
||||||
|
citation: null,
|
||||||
|
url: null,
|
||||||
|
pdfUrl: null,
|
||||||
|
dateFiled: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
315
backend/src/lib/__tests__/documentVersions.test.ts
Normal file
315
backend/src/lib/__tests__/documentVersions.test.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
loadActiveVersion,
|
||||||
|
attachActiveVersionPaths,
|
||||||
|
attachLatestVersionNumbers,
|
||||||
|
} from "../documentVersions";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only Supabase mock covering the query chains documentVersions uses:
|
||||||
|
* select/eq/in/is/not filters plus single() and awaiting the builder.
|
||||||
|
*/
|
||||||
|
function makeDb(tables: Record<string, Row[]>) {
|
||||||
|
return {
|
||||||
|
from(table: string) {
|
||||||
|
let rows = [...(tables[table] ?? [])];
|
||||||
|
const query: any = {
|
||||||
|
select: () => query,
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
rows = rows.filter((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
is: (column: string, value: unknown) => {
|
||||||
|
rows = rows.filter((row) => (row[column] ?? null) === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
not: (column: string, operator: string, value: unknown) => {
|
||||||
|
if (operator === "is" && value === null) {
|
||||||
|
rows = rows.filter((row) => row[column] != null);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
single: async () => ({ data: rows[0] ?? null, error: null }),
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[]; error: null }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape of the mutable doc rows the attach* helpers annotate in place. */
|
||||||
|
type TestDoc = {
|
||||||
|
id: string;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
latest_version_number?: number | null;
|
||||||
|
[k: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FULL_VERSION = {
|
||||||
|
id: "ver-1",
|
||||||
|
document_id: "doc-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u/doc-1/converted.pdf",
|
||||||
|
version_number: 3,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
source: "upload",
|
||||||
|
file_type: "application/pdf",
|
||||||
|
size_bytes: 1024,
|
||||||
|
page_count: 12,
|
||||||
|
deleted_at: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadActiveVersion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("loadActiveVersion", () => {
|
||||||
|
it("resolves the document's current version by default", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [FULL_VERSION],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
|
||||||
|
id: "ver-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u/doc-1/converted.pdf",
|
||||||
|
version_number: 3,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
source: "upload",
|
||||||
|
file_type: "application/pdf",
|
||||||
|
size_bytes: 1024,
|
||||||
|
page_count: 12,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers an explicit versionId over current_version_id", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [
|
||||||
|
FULL_VERSION,
|
||||||
|
{ ...FULL_VERSION, id: "ver-2", version_number: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const version = await loadActiveVersion("doc-1", db, "ver-2");
|
||||||
|
expect(version?.id).toBe("ver-2");
|
||||||
|
expect(version?.version_number).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when neither versionId nor current_version_id exist", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: null }],
|
||||||
|
document_versions: [FULL_VERSION],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the version belongs to a different document", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-other" }],
|
||||||
|
document_versions: [
|
||||||
|
{ ...FULL_VERSION, id: "ver-other", document_id: "doc-2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
// Also guards against a spoofed explicit versionId.
|
||||||
|
await expect(
|
||||||
|
loadActiveVersion("doc-1", db, "ver-other"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for soft-deleted versions", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [
|
||||||
|
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the version has no storage_path", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [{ ...FULL_VERSION, storage_path: null }],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults optional metadata fields to null", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
|
||||||
|
document_versions: [
|
||||||
|
{
|
||||||
|
id: "ver-1",
|
||||||
|
document_id: "doc-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.docx",
|
||||||
|
deleted_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
|
||||||
|
id: "ver-1",
|
||||||
|
storage_path: "documents/u/doc-1/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
version_number: null,
|
||||||
|
filename: null,
|
||||||
|
source: null,
|
||||||
|
file_type: null,
|
||||||
|
size_bytes: null,
|
||||||
|
page_count: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// attachActiveVersionPaths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("attachActiveVersionPaths", () => {
|
||||||
|
it("returns the same empty array untouched", async () => {
|
||||||
|
const db = makeDb({ document_versions: [] });
|
||||||
|
const docs: TestDoc[] = [];
|
||||||
|
await expect(attachActiveVersionPaths(db, docs)).resolves.toBe(docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nulls all fields when no document has a current version", async () => {
|
||||||
|
const db = makeDb({ document_versions: [FULL_VERSION] });
|
||||||
|
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: null },
|
||||||
|
]);
|
||||||
|
expect(doc).toMatchObject({
|
||||||
|
filename: "Untitled document",
|
||||||
|
storage_path: null,
|
||||||
|
pdf_storage_path: null,
|
||||||
|
file_type: null,
|
||||||
|
size_bytes: null,
|
||||||
|
page_count: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges active-version metadata onto each row", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
FULL_VERSION,
|
||||||
|
{
|
||||||
|
...FULL_VERSION,
|
||||||
|
id: "ver-2",
|
||||||
|
storage_path: "documents/u/doc-2/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
filename: "nda.docx",
|
||||||
|
version_number: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const docs = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: "ver-1" },
|
||||||
|
{ id: "doc-2", current_version_id: "ver-2" },
|
||||||
|
{ id: "doc-3", current_version_id: null },
|
||||||
|
]);
|
||||||
|
expect(docs[0]).toMatchObject({
|
||||||
|
storage_path: "documents/u/doc-1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u/doc-1/converted.pdf",
|
||||||
|
active_version_number: 3,
|
||||||
|
filename: "contract.pdf",
|
||||||
|
file_type: "application/pdf",
|
||||||
|
size_bytes: 1024,
|
||||||
|
page_count: 12,
|
||||||
|
});
|
||||||
|
expect(docs[1]).toMatchObject({
|
||||||
|
storage_path: "documents/u/doc-2/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
active_version_number: 1,
|
||||||
|
filename: "nda.docx",
|
||||||
|
});
|
||||||
|
// Mixed list: the version-less doc still gets explicit nulls.
|
||||||
|
expect(docs[2]).toMatchObject({
|
||||||
|
storage_path: null,
|
||||||
|
filename: "Untitled document",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'Untitled document' for blank filenames", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [{ ...FULL_VERSION, filename: " " }],
|
||||||
|
});
|
||||||
|
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: "ver-1" },
|
||||||
|
]);
|
||||||
|
expect(doc.filename).toBe("Untitled document");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores soft-deleted versions", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
|
||||||
|
{ id: "doc-1", current_version_id: "ver-1" },
|
||||||
|
]);
|
||||||
|
expect(doc.storage_path).toBeNull();
|
||||||
|
expect(doc.filename).toBe("Untitled document");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// attachLatestVersionNumbers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("attachLatestVersionNumbers", () => {
|
||||||
|
const versionRow = (
|
||||||
|
document_id: string,
|
||||||
|
version_number: number | null,
|
||||||
|
overrides: Row = {},
|
||||||
|
) => ({
|
||||||
|
document_id,
|
||||||
|
version_number,
|
||||||
|
source: "assistant_edit",
|
||||||
|
deleted_at: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the same empty array untouched", async () => {
|
||||||
|
const db = makeDb({ document_versions: [] });
|
||||||
|
const docs: TestDoc[] = [];
|
||||||
|
await expect(attachLatestVersionNumbers(db, docs)).resolves.toBe(docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the max assistant_edit version number per document", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
versionRow("doc-1", 1),
|
||||||
|
versionRow("doc-1", 4),
|
||||||
|
versionRow("doc-1", 2),
|
||||||
|
versionRow("doc-2", 7),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const docs = await attachLatestVersionNumbers<TestDoc>(db, [
|
||||||
|
{ id: "doc-1" },
|
||||||
|
{ id: "doc-2" },
|
||||||
|
{ id: "doc-3" },
|
||||||
|
]);
|
||||||
|
expect(docs.map((d) => d.latest_version_number)).toEqual([4, 7, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-assistant_edit and soft-deleted versions", async () => {
|
||||||
|
const db = makeDb({
|
||||||
|
document_versions: [
|
||||||
|
versionRow("doc-1", 9, { source: "upload" }),
|
||||||
|
versionRow("doc-1", 8, { deleted_at: "2026-01-01T00:00:00Z" }),
|
||||||
|
versionRow("doc-1", 2),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const docs = await attachLatestVersionNumbers<TestDoc>(db, [{ id: "doc-1" }]);
|
||||||
|
expect(docs[0].latest_version_number).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
backend/src/lib/__tests__/llmModels.test.ts
Normal file
119
backend/src/lib/__tests__/llmModels.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
CLAUDE_MAIN_MODELS,
|
||||||
|
GEMINI_MAIN_MODELS,
|
||||||
|
OPENAI_MAIN_MODELS,
|
||||||
|
CLAUDE_MID_MODELS,
|
||||||
|
GEMINI_MID_MODELS,
|
||||||
|
OPENAI_MID_MODELS,
|
||||||
|
CLAUDE_LOW_MODELS,
|
||||||
|
GEMINI_LOW_MODELS,
|
||||||
|
OPENAI_LOW_MODELS,
|
||||||
|
DEFAULT_MAIN_MODEL,
|
||||||
|
DEFAULT_TITLE_MODEL,
|
||||||
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
providerForModel,
|
||||||
|
resolveModel,
|
||||||
|
} from "../llm/models";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// providerForModel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("providerForModel", () => {
|
||||||
|
it("maps claude-* ids to the claude provider", () => {
|
||||||
|
for (const model of [...CLAUDE_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...CLAUDE_LOW_MODELS]) {
|
||||||
|
expect(providerForModel(model)).toBe("claude");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps gemini-* ids to the gemini provider", () => {
|
||||||
|
for (const model of [...GEMINI_MAIN_MODELS, ...GEMINI_MID_MODELS, ...GEMINI_LOW_MODELS]) {
|
||||||
|
expect(providerForModel(model)).toBe("gemini");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps gpt-* ids to the openai provider", () => {
|
||||||
|
for (const model of [...OPENAI_MAIN_MODELS, ...OPENAI_MID_MODELS, ...OPENAI_LOW_MODELS]) {
|
||||||
|
expect(providerForModel(model)).toBe("openai");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws on an unknown model id", () => {
|
||||||
|
expect(() => providerForModel("llama-3")).toThrow(/Unknown model id/);
|
||||||
|
expect(() => providerForModel("")).toThrow(/Unknown model id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("infers by prefix only, without validating against the catalog", () => {
|
||||||
|
// Documents current behavior: any claude-/gemini-/gpt- prefix is
|
||||||
|
// accepted even if the id is not a canonical model.
|
||||||
|
expect(providerForModel("claude-nonexistent")).toBe("claude");
|
||||||
|
expect(providerForModel("gpt-nonexistent")).toBe("openai");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveModel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("resolveModel", () => {
|
||||||
|
it("returns a known model id unchanged", () => {
|
||||||
|
expect(resolveModel("claude-sonnet-4-6", DEFAULT_MAIN_MODEL)).toBe(
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
);
|
||||||
|
expect(resolveModel("gpt-5.4-lite", DEFAULT_TITLE_MODEL)).toBe(
|
||||||
|
"gpt-5.4-lite",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for unknown model ids", () => {
|
||||||
|
expect(resolveModel("gpt-3.5-turbo", DEFAULT_MAIN_MODEL)).toBe(
|
||||||
|
DEFAULT_MAIN_MODEL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for null, undefined, and empty ids", () => {
|
||||||
|
expect(resolveModel(null, DEFAULT_MAIN_MODEL)).toBe(DEFAULT_MAIN_MODEL);
|
||||||
|
expect(resolveModel(undefined, DEFAULT_TABULAR_MODEL)).toBe(
|
||||||
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
);
|
||||||
|
expect(resolveModel("", DEFAULT_TITLE_MODEL)).toBe(DEFAULT_TITLE_MODEL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts models from every tier of the catalog", () => {
|
||||||
|
const catalog = [
|
||||||
|
...CLAUDE_MAIN_MODELS,
|
||||||
|
...GEMINI_MAIN_MODELS,
|
||||||
|
...OPENAI_MAIN_MODELS,
|
||||||
|
...CLAUDE_MID_MODELS,
|
||||||
|
...GEMINI_MID_MODELS,
|
||||||
|
...OPENAI_MID_MODELS,
|
||||||
|
...CLAUDE_LOW_MODELS,
|
||||||
|
...GEMINI_LOW_MODELS,
|
||||||
|
...OPENAI_LOW_MODELS,
|
||||||
|
];
|
||||||
|
for (const model of catalog) {
|
||||||
|
expect(resolveModel(model, "fallback-model")).toBe(model);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default model sanity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("default models", () => {
|
||||||
|
it("every default resolves to itself (defaults are in the catalog)", () => {
|
||||||
|
expect(resolveModel(DEFAULT_MAIN_MODEL, "x")).toBe(DEFAULT_MAIN_MODEL);
|
||||||
|
expect(resolveModel(DEFAULT_TITLE_MODEL, "x")).toBe(DEFAULT_TITLE_MODEL);
|
||||||
|
expect(resolveModel(DEFAULT_TABULAR_MODEL, "x")).toBe(
|
||||||
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every default has a resolvable provider", () => {
|
||||||
|
expect(providerForModel(DEFAULT_MAIN_MODEL)).toBe("gemini");
|
||||||
|
expect(providerForModel(DEFAULT_TITLE_MODEL)).toBe("gemini");
|
||||||
|
expect(providerForModel(DEFAULT_TABULAR_MODEL)).toBe("gemini");
|
||||||
|
});
|
||||||
|
});
|
||||||
154
backend/src/lib/__tests__/safeError.test.ts
Normal file
154
backend/src/lib/__tests__/safeError.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
redactSensitiveText,
|
||||||
|
safeErrorMessage,
|
||||||
|
safeErrorLog,
|
||||||
|
} from "../safeError";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// redactSensitiveText
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("redactSensitiveText", () => {
|
||||||
|
it('redacts the OpenAI "Incorrect API key provided" message', () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText(
|
||||||
|
"Incorrect API key provided: sk-proj-abc123def456ghi789.",
|
||||||
|
),
|
||||||
|
).toBe("Incorrect API key provided: [redacted].");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the trailing period optional in the incorrect-key message", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("Incorrect API key provided: badkey123"),
|
||||||
|
).toBe("Incorrect API key provided: [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets after api_key labels", () => {
|
||||||
|
expect(redactSensitiveText("api_key: mysecret123")).toBe(
|
||||||
|
"api_key: [redacted]",
|
||||||
|
);
|
||||||
|
expect(redactSensitiveText("api key = mysecret123")).toBe(
|
||||||
|
"api key = [redacted]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets after token/secret/authorization labels", () => {
|
||||||
|
expect(redactSensitiveText("token: abcdef123456")).toBe(
|
||||||
|
"token: [redacted]",
|
||||||
|
);
|
||||||
|
expect(redactSensitiveText("secret is abcdef123456")).toBe(
|
||||||
|
"secret is [redacted]",
|
||||||
|
);
|
||||||
|
expect(redactSensitiveText("authorization: abcdef123456")).toBe(
|
||||||
|
"authorization: [redacted]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves short values after labels alone (below 6 chars)", () => {
|
||||||
|
expect(redactSensitiveText("token: abc")).toBe("token: abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts bare OpenAI-style sk- keys anywhere in the text", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("request failed for sk-abc123def456ghi789 today"),
|
||||||
|
).toBe("request failed for [redacted] today");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts bare Anthropic-style sk-ant- keys", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("used sk-ant-api03-abc123def456"),
|
||||||
|
).toBe("used [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts bare Google AIza keys", () => {
|
||||||
|
expect(
|
||||||
|
redactSensitiveText("key AIzaSyA1234567890abcdefghij failed"),
|
||||||
|
).toBe("key [redacted] failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts multiple secrets in one string", () => {
|
||||||
|
const result = redactSensitiveText(
|
||||||
|
"first sk-abc123def456ghi789 then AIzaSyA1234567890abcdefghij",
|
||||||
|
);
|
||||||
|
expect(result).toBe("first [redacted] then [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary text unchanged", () => {
|
||||||
|
expect(redactSensitiveText("Document not found")).toBe(
|
||||||
|
"Document not found",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// safeErrorMessage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("safeErrorMessage", () => {
|
||||||
|
it("uses the message of an Error instance", () => {
|
||||||
|
expect(safeErrorMessage(new Error("boom"))).toBe("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets inside an Error message", () => {
|
||||||
|
expect(
|
||||||
|
safeErrorMessage(new Error("bad key sk-abc123def456ghi789")),
|
||||||
|
).toBe("bad key [redacted]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes plain strings through (redacted)", () => {
|
||||||
|
expect(safeErrorMessage("token: abcdef123456")).toBe(
|
||||||
|
"token: [redacted]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for non-Error, non-string values", () => {
|
||||||
|
expect(safeErrorMessage(42)).toBe("Unexpected error");
|
||||||
|
expect(safeErrorMessage(null)).toBe("Unexpected error");
|
||||||
|
expect(safeErrorMessage({ message: "obj" })).toBe("Unexpected error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for an Error with an empty message", () => {
|
||||||
|
expect(safeErrorMessage(new Error(""))).toBe("Unexpected error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors a custom fallback", () => {
|
||||||
|
expect(safeErrorMessage(undefined, "Chat failed")).toBe("Chat failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// safeErrorLog
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("safeErrorLog", () => {
|
||||||
|
it("captures name, message, and stack for an Error", () => {
|
||||||
|
const error = new Error("boom");
|
||||||
|
const log = safeErrorLog(error);
|
||||||
|
expect(log.name).toBe("Error");
|
||||||
|
expect(log.message).toBe("boom");
|
||||||
|
expect(log.stack).toContain("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts secrets in the message and stack", () => {
|
||||||
|
const error = new Error("bad key sk-abc123def456ghi789");
|
||||||
|
const log = safeErrorLog(error);
|
||||||
|
expect(log.message).toBe("bad key [redacted]");
|
||||||
|
expect(log.stack).not.toContain("sk-abc123def456ghi789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'Unexpected error' for an empty Error message", () => {
|
||||||
|
expect(safeErrorLog(new Error("")).message).toBe("Unexpected error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the stack when the Error has none", () => {
|
||||||
|
const error = new Error("boom");
|
||||||
|
error.stack = undefined;
|
||||||
|
expect(safeErrorLog(error).stack).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles non-Error values with a null name and no stack", () => {
|
||||||
|
const log = safeErrorLog("plain failure");
|
||||||
|
expect(log).toEqual({ name: null, message: "plain failure" });
|
||||||
|
});
|
||||||
|
});
|
||||||
149
backend/src/lib/__tests__/storage.test.ts
Normal file
149
backend/src/lib/__tests__/storage.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeDownloadFilename,
|
||||||
|
sanitizeDispositionFilename,
|
||||||
|
encodeRFC5987,
|
||||||
|
buildContentDisposition,
|
||||||
|
storageKey,
|
||||||
|
pdfStorageKey,
|
||||||
|
generatedDocKey,
|
||||||
|
versionStorageKey,
|
||||||
|
} from "../storage";
|
||||||
|
|
||||||
|
describe("normalizeDownloadFilename", () => {
|
||||||
|
it("trims surrounding whitespace", () => {
|
||||||
|
expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to 'download' for empty string", () => {
|
||||||
|
expect(normalizeDownloadFilename("")).toBe("download");
|
||||||
|
expect(normalizeDownloadFilename(" ")).toBe("download");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces control characters with underscore", () => {
|
||||||
|
expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf");
|
||||||
|
expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces forward and backward slashes with underscore", () => {
|
||||||
|
expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf");
|
||||||
|
expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves normal filenames unchanged", () => {
|
||||||
|
expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe(
|
||||||
|
"Contract v2 (Final).pdf",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeDispositionFilename", () => {
|
||||||
|
it("strips double-quote characters", () => {
|
||||||
|
expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips backslash characters", () => {
|
||||||
|
expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips non-ASCII characters", () => {
|
||||||
|
expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still applies normalizeDownloadFilename rules first", () => {
|
||||||
|
expect(sanitizeDispositionFilename(" ")).toBe("download");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encodeRFC5987", () => {
|
||||||
|
it("encodes spaces as %20", () => {
|
||||||
|
expect(encodeRFC5987("hello world")).toBe("hello%20world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes single-quote as %27", () => {
|
||||||
|
expect(encodeRFC5987("it's")).toContain("%27");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes ( and ) as %28 and %29", () => {
|
||||||
|
const result = encodeRFC5987("a(b)c");
|
||||||
|
expect(result).toContain("%28");
|
||||||
|
expect(result).toContain("%29");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes * as %2A", () => {
|
||||||
|
expect(encodeRFC5987("a*b")).toContain("%2A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves safe ASCII characters unencoded", () => {
|
||||||
|
expect(encodeRFC5987("file.pdf")).toBe("file.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildContentDisposition", () => {
|
||||||
|
it("produces an attachment header with ASCII filename", () => {
|
||||||
|
const header = buildContentDisposition("attachment", "contract.pdf");
|
||||||
|
expect(header).toMatch(/^attachment;/);
|
||||||
|
expect(header).toContain('filename="contract.pdf"');
|
||||||
|
expect(header).toContain("filename*=UTF-8''contract.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces an inline header", () => {
|
||||||
|
const header = buildContentDisposition("inline", "preview.pdf");
|
||||||
|
expect(header).toMatch(/^inline;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes unicode filename in filename* param", () => {
|
||||||
|
const header = buildContentDisposition("attachment", "Ünïcödé.pdf");
|
||||||
|
expect(header).toContain("filename*=UTF-8''");
|
||||||
|
expect(header).not.toContain("Ü");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storageKey", () => {
|
||||||
|
it("includes userId, docId, and correct extension", () => {
|
||||||
|
const key = storageKey("user1", "doc1", "contract.pdf");
|
||||||
|
expect(key).toBe("documents/user1/doc1/source.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .bin for extensions longer than 16 chars", () => {
|
||||||
|
const key = storageKey("user1", "doc1", "file.toolongextension1234");
|
||||||
|
expect(key).toBe("documents/user1/doc1/source.bin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .bin when no extension", () => {
|
||||||
|
const key = storageKey("user1", "doc1", "noextension");
|
||||||
|
expect(key).toBe("documents/user1/doc1/source.bin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pdfStorageKey", () => {
|
||||||
|
it("places PDF in the correct path with stem", () => {
|
||||||
|
const key = pdfStorageKey("user1", "doc1", "contract");
|
||||||
|
expect(key).toBe("documents/user1/doc1/contract.pdf");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generatedDocKey", () => {
|
||||||
|
it("uses generated/ prefix and .docx extension for docx files", () => {
|
||||||
|
const key = generatedDocKey("user1", "doc1", "output.docx");
|
||||||
|
expect(key).toBe("generated/user1/doc1/generated.docx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .docx for extensions longer than 16 chars", () => {
|
||||||
|
const key = generatedDocKey("user1", "doc1", "output.toolongextension1234");
|
||||||
|
expect(key).toBe("generated/user1/doc1/generated.docx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("versionStorageKey", () => {
|
||||||
|
it("includes userId, docId, versionSlug, and extension", () => {
|
||||||
|
const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf");
|
||||||
|
expect(key).toBe("documents/user1/doc1/versions/v2.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to .bin for unknown extensions", () => {
|
||||||
|
const key = versionStorageKey("user1", "doc1", "v2", "file");
|
||||||
|
expect(key).toBe("documents/user1/doc1/versions/v2.bin");
|
||||||
|
});
|
||||||
|
});
|
||||||
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal file
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import { normalizeApiKeyProvider, hasEnvApiKey } from "../userApiKeys";
|
||||||
|
|
||||||
|
describe("normalizeApiKeyProvider", () => {
|
||||||
|
it('returns "claude" for "claude"', () => {
|
||||||
|
expect(normalizeApiKeyProvider("claude")).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "openai" for "openai"', () => {
|
||||||
|
expect(normalizeApiKeyProvider("openai")).toBe("openai");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "gemini" for "gemini"', () => {
|
||||||
|
expect(normalizeApiKeyProvider("gemini")).toBe("gemini");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown provider strings", () => {
|
||||||
|
expect(normalizeApiKeyProvider("unknown")).toBeNull();
|
||||||
|
expect(normalizeApiKeyProvider("")).toBeNull();
|
||||||
|
expect(normalizeApiKeyProvider("Claude")).toBeNull();
|
||||||
|
expect(normalizeApiKeyProvider("OPENAI")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hasEnvApiKey", () => {
|
||||||
|
const envVars = [
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"CLAUDE_API_KEY",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Clear before AND after each test so keys exported in the developer's
|
||||||
|
// shell (or CI) can't leak into assertions.
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const v of envVars) delete process.env[v];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const v of envVars) delete process.env[v];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for claude when ANTHROPIC_API_KEY is set", () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for claude when CLAUDE_API_KEY is set as fallback", () => {
|
||||||
|
process.env.CLAUDE_API_KEY = "sk-claude-test";
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for openai when OPENAI_API_KEY is set", () => {
|
||||||
|
process.env.OPENAI_API_KEY = "sk-openai-test";
|
||||||
|
expect(hasEnvApiKey("openai")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for gemini when GEMINI_API_KEY is set", () => {
|
||||||
|
process.env.GEMINI_API_KEY = "gemini-key-test";
|
||||||
|
expect(hasEnvApiKey("gemini")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no env key is set for the provider", () => {
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(false);
|
||||||
|
expect(hasEnvApiKey("openai")).toBe(false);
|
||||||
|
expect(hasEnvApiKey("gemini")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores whitespace-only env values", () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = " ";
|
||||||
|
expect(hasEnvApiKey("claude")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
432
backend/src/lib/__tests__/userDataCleanup.test.ts
Normal file
432
backend/src/lib/__tests__/userDataCleanup.test.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../storage", () => ({
|
||||||
|
deleteFile: vi.fn(async () => {}),
|
||||||
|
listFiles: vi.fn(async () => [] as string[]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { deleteFile, listFiles } from "../storage";
|
||||||
|
import {
|
||||||
|
deleteAllUserChats,
|
||||||
|
deleteAllUserTabularReviews,
|
||||||
|
deleteUserProjects,
|
||||||
|
deleteUserAccountData,
|
||||||
|
} from "../userDataCleanup";
|
||||||
|
|
||||||
|
const deleteFileMock = vi.mocked(deleteFile);
|
||||||
|
const listFilesMock = vi.mocked(listFiles);
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateful Supabase mock: deletes and updates mutate `tables`, so tests can
|
||||||
|
* assert on exactly which rows survived a cleanup call. Supports the chains
|
||||||
|
* userDataCleanup uses (select/delete/update + eq/in/filter-cs) and can
|
||||||
|
* inject a delete error per table to exercise error propagation.
|
||||||
|
*/
|
||||||
|
function makeDb(
|
||||||
|
initialTables: Record<string, Row[]>,
|
||||||
|
options: { deleteErrors?: Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
const tables: Record<string, Row[]> = {};
|
||||||
|
for (const [name, rows] of Object.entries(initialTables)) {
|
||||||
|
tables[name] = rows.map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
const db = {
|
||||||
|
from(table: string) {
|
||||||
|
const rowsOf = () => tables[table] ?? (tables[table] = []);
|
||||||
|
let predicate: (row: Row) => boolean = () => true;
|
||||||
|
let mode: "select" | "delete" | "update" = "select";
|
||||||
|
let patch: Row = {};
|
||||||
|
const narrow = (next: (row: Row) => boolean) => {
|
||||||
|
const prev = predicate;
|
||||||
|
predicate = (row) => prev(row) && next(row);
|
||||||
|
};
|
||||||
|
const query: any = {
|
||||||
|
select: () => query,
|
||||||
|
delete: () => {
|
||||||
|
mode = "delete";
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
update: (value: Row) => {
|
||||||
|
mode = "update";
|
||||||
|
patch = value;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
narrow((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
narrow((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
filter: (column: string, operator: string, value: string) => {
|
||||||
|
if (operator !== "cs") return query;
|
||||||
|
const expected = (JSON.parse(value) as string[]).map((item) =>
|
||||||
|
item.toLowerCase(),
|
||||||
|
);
|
||||||
|
narrow((row) => {
|
||||||
|
const actual = row[column];
|
||||||
|
if (!Array.isArray(actual)) return false;
|
||||||
|
const normalized = actual.map((item) =>
|
||||||
|
String(item).toLowerCase(),
|
||||||
|
);
|
||||||
|
return expected.every((item) => normalized.includes(item));
|
||||||
|
});
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[] | null; error: unknown }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => {
|
||||||
|
let result: { data: Row[] | null; error: unknown };
|
||||||
|
if (mode === "delete") {
|
||||||
|
const message = options.deleteErrors?.[table];
|
||||||
|
if (message) {
|
||||||
|
result = { data: null, error: { message } };
|
||||||
|
} else {
|
||||||
|
tables[table] = rowsOf().filter((row) => !predicate(row));
|
||||||
|
result = { data: null, error: null };
|
||||||
|
}
|
||||||
|
} else if (mode === "update") {
|
||||||
|
for (const row of rowsOf().filter(predicate)) {
|
||||||
|
Object.assign(row, patch);
|
||||||
|
}
|
||||||
|
result = { data: null, error: null };
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
data: rowsOf().filter(predicate).map((row) => ({ ...row })),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return Promise.resolve(result).then(resolve, reject);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { db: db as any, tables };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = (rows: Row[] | undefined) => (rows ?? []).map((row) => row.id);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteFileMock.mockClear();
|
||||||
|
deleteFileMock.mockResolvedValue(undefined as never);
|
||||||
|
listFilesMock.mockClear();
|
||||||
|
listFilesMock.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteAllUserChats
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteAllUserChats", () => {
|
||||||
|
it("deletes only the target user's assistant and tabular chats", async () => {
|
||||||
|
const { db, tables } = makeDb({
|
||||||
|
chats: [
|
||||||
|
{ id: "c1", user_id: "u1" },
|
||||||
|
{ id: "c2", user_id: "u2" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [
|
||||||
|
{ id: "tc1", user_id: "u1" },
|
||||||
|
{ id: "tc2", user_id: "u2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await deleteAllUserChats(db, "u1");
|
||||||
|
expect(ids(tables.chats)).toEqual(["c2"]);
|
||||||
|
expect(ids(tables.tabular_review_chats)).toEqual(["tc2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces delete failures with context", async () => {
|
||||||
|
const { db } = makeDb(
|
||||||
|
{ chats: [{ id: "c1", user_id: "u1" }], tabular_review_chats: [] },
|
||||||
|
{ deleteErrors: { chats: "boom" } },
|
||||||
|
);
|
||||||
|
await expect(deleteAllUserChats(db, "u1")).rejects.toThrow(
|
||||||
|
"Failed to delete assistant chats: boom",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteAllUserTabularReviews
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteAllUserTabularReviews", () => {
|
||||||
|
const fixture = () =>
|
||||||
|
makeDb({
|
||||||
|
tabular_reviews: [
|
||||||
|
{ id: "r1", user_id: "u1" },
|
||||||
|
{ id: "r2", user_id: "u1" },
|
||||||
|
{ id: "r-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [
|
||||||
|
{ id: "rc1", review_id: "r1" },
|
||||||
|
{ id: "rc-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
tabular_review_chat_messages: [
|
||||||
|
{ id: "rm1", chat_id: "rc1" },
|
||||||
|
{ id: "rm-other", chat_id: "rc-other" },
|
||||||
|
],
|
||||||
|
tabular_cells: [
|
||||||
|
{ id: "cell1", review_id: "r1" },
|
||||||
|
{ id: "cell-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades messages, chats, and cells before the reviews", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteAllUserTabularReviews(db, "u1")).resolves.toBe(2);
|
||||||
|
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
|
||||||
|
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 and deletes nothing for a user with no reviews", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteAllUserTabularReviews(db, "u3")).resolves.toBe(0);
|
||||||
|
expect(tables.tabular_reviews).toHaveLength(3);
|
||||||
|
expect(tables.tabular_cells).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteUserProjects
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteUserProjects", () => {
|
||||||
|
const fixture = () =>
|
||||||
|
makeDb({
|
||||||
|
projects: [
|
||||||
|
{ id: "p1", user_id: "u1" },
|
||||||
|
{ id: "p2", user_id: "u1" },
|
||||||
|
{ id: "p-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: "d1", user_id: "u1", project_id: "p1" },
|
||||||
|
{ id: "d-loose", user_id: "u1", project_id: null },
|
||||||
|
{ id: "d-other", user_id: "u2", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
document_versions: [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
document_id: "d1",
|
||||||
|
storage_path: "documents/u1/d1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u1/d1/converted.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-other",
|
||||||
|
document_id: "d-other",
|
||||||
|
storage_path: "documents/u2/d-other/source.pdf",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chats: [
|
||||||
|
{ id: "c1", project_id: "p1" },
|
||||||
|
{ id: "c-other", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
chat_messages: [
|
||||||
|
{ id: "m1", chat_id: "c1" },
|
||||||
|
{ id: "m-other", chat_id: "c-other" },
|
||||||
|
],
|
||||||
|
tabular_reviews: [
|
||||||
|
{ id: "r1", project_id: "p1" },
|
||||||
|
{ id: "r-other", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [
|
||||||
|
{ id: "rc1", review_id: "r1" },
|
||||||
|
{ id: "rc-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
tabular_review_chat_messages: [
|
||||||
|
{ id: "rm1", chat_id: "rc1" },
|
||||||
|
{ id: "rm-other", chat_id: "rc-other" },
|
||||||
|
],
|
||||||
|
tabular_cells: [
|
||||||
|
{ id: "cell1", review_id: "r1" },
|
||||||
|
{ id: "cell-other", review_id: "r-other" },
|
||||||
|
],
|
||||||
|
project_subfolders: [
|
||||||
|
{ id: "f1", project_id: "p1" },
|
||||||
|
{ id: "f-other", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascades project contents and storage files for owned projects", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteUserProjects(db, "u1")).resolves.toBe(2);
|
||||||
|
|
||||||
|
expect(ids(tables.projects)).toEqual(["p-other"]);
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-loose", "d-other"]);
|
||||||
|
expect(ids(tables.chats)).toEqual(["c-other"]);
|
||||||
|
expect(ids(tables.chat_messages)).toEqual(["m-other"]);
|
||||||
|
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
|
||||||
|
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
|
||||||
|
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
|
||||||
|
expect(ids(tables.project_subfolders)).toEqual(["f-other"]);
|
||||||
|
|
||||||
|
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
|
||||||
|
expect(deletedPaths.sort()).toEqual([
|
||||||
|
"documents/u1/d1/converted.pdf",
|
||||||
|
"documents/u1/d1/source.pdf",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restricts deletion to the requested owned projects", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
// p-other belongs to u2, so requesting it must not delete anything of theirs.
|
||||||
|
await expect(
|
||||||
|
deleteUserProjects(db, "u1", ["p2", "p-other", "p2"]),
|
||||||
|
).resolves.toBe(1);
|
||||||
|
expect(ids(tables.projects)).toEqual(["p1", "p-other"]);
|
||||||
|
expect(ids(tables.documents)).toEqual(["d1", "d-loose", "d-other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for an explicitly empty project list", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteUserProjects(db, "u1", [])).resolves.toBe(0);
|
||||||
|
expect(tables.projects).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when the user owns no projects", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await expect(deleteUserProjects(db, "u3")).resolves.toBe(0);
|
||||||
|
expect(tables.projects).toHaveLength(3);
|
||||||
|
expect(deleteFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// deleteUserAccountData
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("deleteUserAccountData", () => {
|
||||||
|
const fixture = () =>
|
||||||
|
makeDb({
|
||||||
|
projects: [
|
||||||
|
{ id: "p1", user_id: "u1", shared_with: [] },
|
||||||
|
{
|
||||||
|
id: "p-other",
|
||||||
|
user_id: "u2",
|
||||||
|
shared_with: ["u1@example.com", " U1@Example.com ", "keep@example.com"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tabular_reviews: [
|
||||||
|
{ id: "r1", user_id: "u1", shared_with: [] },
|
||||||
|
{ id: "r-other", user_id: "u2", shared_with: ["u1@example.com"] },
|
||||||
|
],
|
||||||
|
documents: [
|
||||||
|
{ id: "d1", user_id: "u1", project_id: null },
|
||||||
|
// Guest doc uploaded by another user into u1's project: deleted too.
|
||||||
|
{ id: "d-guest", user_id: "u2", project_id: "p1" },
|
||||||
|
{ id: "d-other", user_id: "u2", project_id: "p-other" },
|
||||||
|
],
|
||||||
|
document_versions: [
|
||||||
|
{
|
||||||
|
id: "v1",
|
||||||
|
document_id: "d1",
|
||||||
|
storage_path: "documents/u1/d1/source.pdf",
|
||||||
|
pdf_storage_path: "documents/u1/d1/converted.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-guest",
|
||||||
|
document_id: "d-guest",
|
||||||
|
storage_path: "documents/u2/d-guest/source.docx",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "v-other",
|
||||||
|
document_id: "d-other",
|
||||||
|
storage_path: "documents/u2/d-other/source.pdf",
|
||||||
|
pdf_storage_path: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chats: [
|
||||||
|
{ id: "c1", user_id: "u1" },
|
||||||
|
{ id: "c-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
tabular_review_chats: [{ id: "rc1", user_id: "u1" }],
|
||||||
|
project_subfolders: [{ id: "f1", user_id: "u1" }],
|
||||||
|
hidden_workflows: [{ id: "h1", user_id: "u1" }],
|
||||||
|
workflow_open_source_submissions: [
|
||||||
|
{ id: "s1", submitted_by_user_id: "u1" },
|
||||||
|
],
|
||||||
|
workflow_shares: [
|
||||||
|
{ id: "ws-by", shared_by_user_id: "u1", shared_with_email: "x@y.z" },
|
||||||
|
{
|
||||||
|
id: "ws-to",
|
||||||
|
shared_by_user_id: "u2",
|
||||||
|
shared_with_email: "u1@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ws-keep",
|
||||||
|
shared_by_user_id: "u2",
|
||||||
|
shared_with_email: "keep@example.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
workflows: [
|
||||||
|
{ id: "w1", user_id: "u1" },
|
||||||
|
{ id: "w-other", user_id: "u2" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the user's rows, files, and share references everywhere", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
listFilesMock.mockResolvedValue(["documents/u1/orphan.bin"]);
|
||||||
|
|
||||||
|
await deleteUserAccountData(db, "u1", " U1@Example.COM ");
|
||||||
|
|
||||||
|
// Owned docs and guest docs inside owned projects are gone.
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-other"]);
|
||||||
|
expect(ids(tables.projects)).toEqual(["p-other"]);
|
||||||
|
expect(ids(tables.chats)).toEqual(["c-other"]);
|
||||||
|
expect(tables.tabular_review_chats).toEqual([]);
|
||||||
|
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
|
||||||
|
expect(tables.project_subfolders).toEqual([]);
|
||||||
|
expect(tables.hidden_workflows).toEqual([]);
|
||||||
|
expect(tables.workflow_open_source_submissions).toEqual([]);
|
||||||
|
expect(ids(tables.workflows)).toEqual(["w-other"]);
|
||||||
|
|
||||||
|
// Shares by the user and shares to the user's email are both removed.
|
||||||
|
expect(ids(tables.workflow_shares)).toEqual(["ws-keep"]);
|
||||||
|
|
||||||
|
// The email is scrubbed from other users' shared_with lists
|
||||||
|
// (case-insensitively), preserving other collaborators.
|
||||||
|
expect(tables.projects[0].shared_with).toEqual(["keep@example.com"]);
|
||||||
|
expect(tables.tabular_reviews[0].shared_with).toEqual([]);
|
||||||
|
|
||||||
|
// Version files for deleted docs plus orphans under the user's prefix.
|
||||||
|
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
|
||||||
|
expect(deletedPaths.sort()).toEqual([
|
||||||
|
"documents/u1/d1/converted.pdf",
|
||||||
|
"documents/u1/d1/source.pdf",
|
||||||
|
"documents/u1/orphan.bin",
|
||||||
|
"documents/u2/d-guest/source.docx",
|
||||||
|
]);
|
||||||
|
expect(listFilesMock).toHaveBeenCalledWith("documents/u1/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats storage prefix cleanup as best-effort", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
listFilesMock.mockRejectedValue(new Error("storage unavailable"));
|
||||||
|
await expect(
|
||||||
|
deleteUserAccountData(db, "u1", "u1@example.com"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-other"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips shared_with scrubbing when no email is known", async () => {
|
||||||
|
const { db, tables } = fixture();
|
||||||
|
await deleteUserAccountData(db, "u1", null);
|
||||||
|
// Rows referencing the email by value are left in place...
|
||||||
|
expect(tables.projects.find((row) => row.id === "p-other")?.shared_with)
|
||||||
|
.toContain("u1@example.com");
|
||||||
|
expect(ids(tables.workflow_shares)).toEqual(["ws-to", "ws-keep"]);
|
||||||
|
// ...but the user's own data is still deleted.
|
||||||
|
expect(ids(tables.documents)).toEqual(["d-other"]);
|
||||||
|
expect(tables.workflows.map((row) => row.id)).toEqual(["w-other"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
backend/src/lib/__tests__/userLookup.test.ts
Normal file
267
backend/src/lib/__tests__/userLookup.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
normalizeEmail,
|
||||||
|
normalizeDisplayName,
|
||||||
|
loadProfileUsersByEmail,
|
||||||
|
findProfileUserByEmail,
|
||||||
|
findMissingUserEmails,
|
||||||
|
syncProfileEmail,
|
||||||
|
} from "../userLookup";
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal user_profiles-shaped Supabase mock. Supports the query chains
|
||||||
|
* userLookup uses (select/eq/in/not + single-row readers) plus insert and
|
||||||
|
* update so syncProfileEmail can be exercised end to end.
|
||||||
|
*/
|
||||||
|
function makeDb(initialRows: Row[]) {
|
||||||
|
const tables: Record<string, Row[]> = {
|
||||||
|
user_profiles: initialRows.map((row) => ({ ...row })),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
tables,
|
||||||
|
from(table: string) {
|
||||||
|
const all = () => tables[table] ?? [];
|
||||||
|
let predicate: (row: Row) => boolean = () => true;
|
||||||
|
let mode: "select" | "insert" | "update" = "select";
|
||||||
|
let pendingRow: Row = {};
|
||||||
|
const narrow = (next: (row: Row) => boolean) => {
|
||||||
|
const prev = predicate;
|
||||||
|
predicate = (row) => prev(row) && next(row);
|
||||||
|
};
|
||||||
|
const query: any = {
|
||||||
|
select: () => query,
|
||||||
|
insert: (row: Row) => {
|
||||||
|
mode = "insert";
|
||||||
|
pendingRow = row;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
update: (patch: Row) => {
|
||||||
|
mode = "update";
|
||||||
|
pendingRow = patch;
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
eq: (column: string, value: unknown) => {
|
||||||
|
narrow((row) => row[column] === value);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
in: (column: string, values: unknown[]) => {
|
||||||
|
narrow((row) => values.includes(row[column]));
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
not: (column: string, operator: string, value: unknown) => {
|
||||||
|
if (operator === "is" && value === null) {
|
||||||
|
narrow((row) => row[column] != null);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
maybeSingle: async () => ({
|
||||||
|
data: all().filter(predicate)[0] ?? null,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
then: (
|
||||||
|
resolve: (value: { data: Row[] | null; error: null }) => unknown,
|
||||||
|
reject?: (reason: unknown) => unknown,
|
||||||
|
) => {
|
||||||
|
if (mode === "insert") {
|
||||||
|
all().push({ ...pendingRow });
|
||||||
|
return Promise.resolve({ data: null, error: null }).then(
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mode === "update") {
|
||||||
|
for (const row of all().filter(predicate)) {
|
||||||
|
Object.assign(row, pendingRow);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: null, error: null }).then(
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
data: all().filter(predicate),
|
||||||
|
error: null,
|
||||||
|
}).then(resolve, reject);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// normalizeEmail / normalizeDisplayName
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("normalizeEmail", () => {
|
||||||
|
it("trims and lowercases", () => {
|
||||||
|
expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for non-strings", () => {
|
||||||
|
expect(normalizeEmail(null)).toBe("");
|
||||||
|
expect(normalizeEmail(undefined)).toBe("");
|
||||||
|
expect(normalizeEmail(42)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeDisplayName", () => {
|
||||||
|
it("trims usable names", () => {
|
||||||
|
expect(normalizeDisplayName(" Ada Lovelace ")).toBe("Ada Lovelace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for empty or non-string values", () => {
|
||||||
|
expect(normalizeDisplayName(" ")).toBeNull();
|
||||||
|
expect(normalizeDisplayName("")).toBeNull();
|
||||||
|
expect(normalizeDisplayName(null)).toBeNull();
|
||||||
|
expect(normalizeDisplayName(7)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// loadProfileUsersByEmail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("loadProfileUsersByEmail", () => {
|
||||||
|
it("indexes profiles by normalized email and by id", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "Alice@Example.com", display_name: " Alice " },
|
||||||
|
{ user_id: "u2", email: "bob@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const { userByEmail, userById } = await loadProfileUsersByEmail(
|
||||||
|
db as any,
|
||||||
|
);
|
||||||
|
expect(userByEmail.get("alice@example.com")).toEqual({
|
||||||
|
id: "u1",
|
||||||
|
email: "alice@example.com",
|
||||||
|
display_name: "Alice",
|
||||||
|
});
|
||||||
|
expect(userById.get("u2")).toEqual({
|
||||||
|
id: "u2",
|
||||||
|
email: "bob@example.com",
|
||||||
|
display_name: null,
|
||||||
|
});
|
||||||
|
expect(userByEmail.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows whose email normalizes to empty", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: " ", display_name: null },
|
||||||
|
{ user_id: "u2", email: "ok@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const { userByEmail } = await loadProfileUsersByEmail(db as any);
|
||||||
|
expect(userByEmail.size).toBe(1);
|
||||||
|
expect(userByEmail.has("ok@example.com")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findProfileUserByEmail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("findProfileUserByEmail", () => {
|
||||||
|
const rows = [
|
||||||
|
{ user_id: "u1", email: "alice@example.com", display_name: "Alice" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("finds a profile by normalized email", async () => {
|
||||||
|
const db = makeDb(rows);
|
||||||
|
await expect(
|
||||||
|
findProfileUserByEmail(db as any, " ALICE@example.com "),
|
||||||
|
).resolves.toEqual({
|
||||||
|
id: "u1",
|
||||||
|
email: "alice@example.com",
|
||||||
|
display_name: "Alice",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no profile matches", async () => {
|
||||||
|
const db = makeDb(rows);
|
||||||
|
await expect(
|
||||||
|
findProfileUserByEmail(db as any, "missing@example.com"),
|
||||||
|
).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without querying for empty input", async () => {
|
||||||
|
const db = makeDb(rows);
|
||||||
|
await expect(findProfileUserByEmail(db as any, " ")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// findMissingUserEmails
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("findMissingUserEmails", () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "alice@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("returns only emails with no matching profile", async () => {
|
||||||
|
await expect(
|
||||||
|
findMissingUserEmails(db as any, [
|
||||||
|
"Alice@Example.com",
|
||||||
|
"carol@example.com",
|
||||||
|
]),
|
||||||
|
).resolves.toEqual(["carol@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes and drops empty entries before querying", async () => {
|
||||||
|
await expect(
|
||||||
|
findMissingUserEmails(db as any, [
|
||||||
|
"carol@example.com",
|
||||||
|
" CAROL@example.com ",
|
||||||
|
"",
|
||||||
|
" ",
|
||||||
|
]),
|
||||||
|
).resolves.toEqual(["carol@example.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns [] for an empty input list", async () => {
|
||||||
|
await expect(findMissingUserEmails(db as any, [])).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// syncProfileEmail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("syncProfileEmail", () => {
|
||||||
|
it("inserts a profile row when none exists", async () => {
|
||||||
|
const db = makeDb([]);
|
||||||
|
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(db.tables.user_profiles).toEqual([
|
||||||
|
{ user_id: "u1", email: "new@example.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the stored email already matches (case-insensitive)", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "Same@Example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const result = await syncProfileEmail(db as any, "u1", "same@example.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(db.tables.user_profiles[0].email).toBe("Same@Example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the stored email when it changed", async () => {
|
||||||
|
const db = makeDb([
|
||||||
|
{ user_id: "u1", email: "old@example.com", display_name: null },
|
||||||
|
]);
|
||||||
|
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(db.tables.user_profiles[0].email).toBe("new@example.com");
|
||||||
|
expect(db.tables.user_profiles[0].updated_at).toEqual(expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null without touching the table for missing inputs", async () => {
|
||||||
|
const db = makeDb([]);
|
||||||
|
await expect(syncProfileEmail(db as any, "", "a@b.com")).resolves.toBeNull();
|
||||||
|
await expect(syncProfileEmail(db as any, "u1", null)).resolves.toBeNull();
|
||||||
|
await expect(syncProfileEmail(db as any, "u1", " ")).resolves.toBeNull();
|
||||||
|
expect(db.tables.user_profiles).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -152,9 +152,16 @@ async function attachChatCreatorLabels(
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /projects
|
// GET /projects
|
||||||
|
// Pass ?include=documents to also receive each project's documents in the
|
||||||
|
// same response. The directory pickers (useDirectoryData) previously fanned
|
||||||
|
// out one GET /projects/:id per project to obtain those documents; with N
|
||||||
|
// projects that burst — auth check plus several DB queries per request —
|
||||||
|
// could overwhelm the Supabase gateway. Batching keeps it at one request
|
||||||
|
// and a fixed number of queries regardless of project count.
|
||||||
projectsRouter.get("/", requireAuth, async (req, res) => {
|
projectsRouter.get("/", requireAuth, async (req, res) => {
|
||||||
const userId = res.locals.userId as string;
|
const userId = res.locals.userId as string;
|
||||||
const userEmail = res.locals.userEmail as string | undefined;
|
const userEmail = res.locals.userEmail as string | undefined;
|
||||||
|
const includeDocuments = req.query.include === "documents";
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
|
|
||||||
const { data, error } = await db.rpc("get_projects_overview", {
|
const { data, error } = await db.rpc("get_projects_overview", {
|
||||||
|
|
@ -163,7 +170,45 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
|
||||||
});
|
});
|
||||||
if (error) return void res.status(500).json({ detail: error.message });
|
if (error) return void res.status(500).json({ detail: error.message });
|
||||||
|
|
||||||
res.json(data ?? []);
|
const projects = (data ?? []) as { id: string }[];
|
||||||
|
if (!includeDocuments || projects.length === 0) {
|
||||||
|
return void res.json(projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: docs, error: docsError } = await db
|
||||||
|
.from("documents")
|
||||||
|
.select("*")
|
||||||
|
.in(
|
||||||
|
"project_id",
|
||||||
|
projects.map((p) => p.id),
|
||||||
|
)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
if (docsError)
|
||||||
|
return void res.status(500).json({ detail: docsError.message });
|
||||||
|
|
||||||
|
const docsTyped = (docs ?? []) as unknown as {
|
||||||
|
id: string;
|
||||||
|
project_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
current_version_id?: string | null;
|
||||||
|
}[];
|
||||||
|
await attachLatestVersionNumbers(db, docsTyped);
|
||||||
|
await attachActiveVersionPaths(db, docsTyped);
|
||||||
|
await attachDocumentOwnerLabels(db, docsTyped);
|
||||||
|
|
||||||
|
const docsByProject = new Map<string, typeof docsTyped>();
|
||||||
|
for (const doc of docsTyped) {
|
||||||
|
if (!doc.project_id) continue;
|
||||||
|
const bucket = docsByProject.get(doc.project_id);
|
||||||
|
if (bucket) bucket.push(doc);
|
||||||
|
else docsByProject.set(doc.project_id, [doc]);
|
||||||
|
}
|
||||||
|
res.json(
|
||||||
|
projects.map((p) => ({
|
||||||
|
...p,
|
||||||
|
documents: docsByProject.get(p.id) ?? [],
|
||||||
|
})),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /projects
|
// POST /projects
|
||||||
|
|
|
||||||
38
backend/vitest.config.mts
Normal file
38
backend/vitest.config.mts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
exclude: ["dist/**", "node_modules/**"],
|
||||||
|
// Generous timeouts so cold-start module transform/import latency
|
||||||
|
// can't cause spurious timeout failures on a cold CI runner. Warm
|
||||||
|
// tests finish in ~1s; this only guards the pathological cold case —
|
||||||
|
// it does not mask hangs.
|
||||||
|
testTimeout: 20000,
|
||||||
|
hookTimeout: 20000,
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "lcov"],
|
||||||
|
include: ["src/lib/**"],
|
||||||
|
// No-regression RATCHET floor, not a target. src/lib/** spans the
|
||||||
|
// tested libs (access, storage keys/dispositions, downloadTokens,
|
||||||
|
// userApiKeys provider/env checks, chat doc resolution, safeError,
|
||||||
|
// llm model resolution, chat citations, userLookup,
|
||||||
|
// documentVersions, userDataCleanup) AND the large, still-untested
|
||||||
|
// feature libs (courtlistener, mcp, chat tool dispatch, llm
|
||||||
|
// providers, spreadsheet/docx handling), so the global number is
|
||||||
|
// still low. Measured on this tree: 11.18% statements, 10.98%
|
||||||
|
// branches, 14.43% functions, 10.91% lines. These floors sit just
|
||||||
|
// below that (rounded down to whole percents) so CI fails on a
|
||||||
|
// *drop*. Floors only go up: when you add tests, raise them in the
|
||||||
|
// same PR. Backlog + per-area status: docs/testing-coverage.md.
|
||||||
|
thresholds: {
|
||||||
|
statements: 11,
|
||||||
|
branches: 10,
|
||||||
|
functions: 14,
|
||||||
|
lines: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
122
docs/testing-coverage.md
Normal file
122
docs/testing-coverage.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Backend unit-test coverage
|
||||||
|
|
||||||
|
The backend has a Vitest unit-test harness over `backend/src/lib/**`. This doc
|
||||||
|
tracks what is covered, what still needs tests, and how the coverage ratchet
|
||||||
|
works — so you can pick up a checkbox below and land it as a small PR.
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm test # run all unit tests
|
||||||
|
npm run test:coverage # same, plus the per-file coverage table + floor check
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests live in `backend/src/lib/__tests__/*.test.ts`. Read a couple of the
|
||||||
|
existing suites first (`access.test.ts`, `userDataCleanup.test.ts`) and match
|
||||||
|
their conventions: plain in-memory Supabase query mocks (no network, no real
|
||||||
|
database), one `describe` block per exported function, and tests that assert
|
||||||
|
current behavior.
|
||||||
|
|
||||||
|
## Current coverage (measured 2026-07)
|
||||||
|
|
||||||
|
Per-area statement coverage from `npm run test:coverage`:
|
||||||
|
|
||||||
|
| Lib area | % statements | Tested? |
|
||||||
|
| --- | ---: | :---: |
|
||||||
|
| `lib/safeError.ts` | 100 | ✓ |
|
||||||
|
| `lib/userDataCleanup.ts` | 100 | ✓ |
|
||||||
|
| `lib/llm/models.ts` | 100 | ✓ |
|
||||||
|
| `lib/documentVersions.ts` | 98 | ✓ |
|
||||||
|
| `lib/chat/citations.ts` | 98 | ✓ |
|
||||||
|
| `lib/userLookup.ts` | 91 | ✓ |
|
||||||
|
| `lib/downloadTokens.ts` | 87 | ✓ |
|
||||||
|
| `lib/chat/types.ts` | 85 | ✓ |
|
||||||
|
| `lib/access.ts` | 76 | ✓ |
|
||||||
|
| `lib/storage.ts` | 33 | partial — key/disposition helpers only |
|
||||||
|
| `lib/userApiKeys.ts` | 13 | partial — provider/env helpers only |
|
||||||
|
| `lib/documentTypes.ts`, `lib/userSettings.ts`, `lib/upload.ts`, `lib/officeText.ts` | 0 | ✗ |
|
||||||
|
| `lib/convert.ts`, `lib/spreadsheet.ts`, `lib/docxTrackedChanges.ts` | 0 | ✗ |
|
||||||
|
| `lib/userDataExport.ts` | 0 | ✗ |
|
||||||
|
| `lib/courtlistener.ts`, `lib/systemWorkflows.ts` | 0 | ✗ |
|
||||||
|
| `lib/chat/prompts.ts`, `lib/chat/contextBuilders.ts`, `lib/chat/streaming.ts` | 0 | ✗ |
|
||||||
|
| `lib/chat/tools/**` (schemas, documentOps, toolDispatcher) | 0 | ✗ |
|
||||||
|
| `lib/llm/**` (providers, tools, index, rawStreamLog) | ~4 | ✗ (only models.ts) |
|
||||||
|
| `lib/mcp/**` (client, servers, oauth, types) | 0 | ✗ |
|
||||||
|
|
||||||
|
Global: **11.18% statements / 10.98% branches / 14.43% functions / 10.91%
|
||||||
|
lines**. The global number is low because `src/lib/**` includes several very
|
||||||
|
large feature libs (toolDispatcher, documentOps, systemWorkflows,
|
||||||
|
courtlistener, docxTrackedChanges) that dominate the line count.
|
||||||
|
|
||||||
|
## TODO — untested libs, in priority order
|
||||||
|
|
||||||
|
Each item is meant to be one self-contained PR: add the suite, then raise the
|
||||||
|
floors in `backend/vitest.config.mts` to just below the new measured numbers.
|
||||||
|
Size is a rough guess: S ≈ an hour, M ≈ an afternoon.
|
||||||
|
|
||||||
|
- [ ] `lib/documentTypes.ts` — pure catalog/lookup of document types; assert
|
||||||
|
known types resolve and unknown inputs fall back sanely. (S)
|
||||||
|
- [ ] `lib/chat/prompts.ts` — pure prompt builders; assert key instructions and
|
||||||
|
interpolated values appear in the output strings. (S)
|
||||||
|
- [ ] `lib/userSettings.ts` — title/tabular model resolution from which API
|
||||||
|
keys a user has; reuse the Supabase mock pattern from
|
||||||
|
`userLookup.test.ts`. (S)
|
||||||
|
- [ ] `lib/upload.ts` — multer wrapper: assert LIMIT_FILE_SIZE maps to a 413
|
||||||
|
with the right message and other errors pass through. (S)
|
||||||
|
- [ ] `lib/officeText.ts` — office XML text extraction; build a tiny in-memory
|
||||||
|
zip fixture with JSZip and assert extracted/decoded text. (S)
|
||||||
|
- [ ] `lib/chat/tools/toolSchemas.ts` — assert every tool schema has a name,
|
||||||
|
description, and well-formed parameters (guards against schema drift). (S)
|
||||||
|
- [ ] `lib/userApiKeys.ts` (rest) — encrypt/decrypt round-trip and DB
|
||||||
|
load/store paths with a mocked Supabase client. (M)
|
||||||
|
- [ ] `lib/storage.ts` (rest) — S3 upload/download/list/delete wrappers with a
|
||||||
|
mocked AWS SDK client. (M)
|
||||||
|
- [ ] `lib/userDataExport.ts` — export assembly: given seeded mock tables,
|
||||||
|
assert the export contains the user's data and nobody else's. (M)
|
||||||
|
- [ ] `lib/spreadsheet.ts` — parse a small in-memory xlsx fixture; assert sheet
|
||||||
|
and cell extraction, including empty/edge cells. (M)
|
||||||
|
- [ ] `lib/chat/contextBuilders.ts` — context assembly from doc stores; assert
|
||||||
|
doc labels, truncation, and ordering. (M)
|
||||||
|
- [ ] `lib/docxTrackedChanges.ts` — tracked-changes XML round-trip on a minimal
|
||||||
|
docx fixture: insert/delete runs, accept/reject. High value: document
|
||||||
|
integrity. (M)
|
||||||
|
- [ ] `lib/courtlistener.ts` — API client with mocked fetch: query building,
|
||||||
|
pagination, and error paths. Legal-research correctness. (M)
|
||||||
|
- [ ] `lib/systemWorkflows.ts` — mostly data: assert workflow definitions are
|
||||||
|
well-formed (unique ids, non-empty skill markdown). (S)
|
||||||
|
- [ ] `lib/llm/tools.ts` + `lib/llm/index.ts` — provider-neutral tool plumbing
|
||||||
|
and provider selection with mocked provider modules. (M)
|
||||||
|
- [ ] `lib/mcp/types.ts` + `lib/mcp/servers.ts` — server config validation and
|
||||||
|
allow-listing logic; security relevant. (M)
|
||||||
|
- [ ] `lib/mcp/client.ts` + `lib/mcp/oauth.ts` — connection lifecycle and OAuth
|
||||||
|
token handling with a mocked MCP SDK; security relevant. (M)
|
||||||
|
- [ ] `lib/llm/rawStreamLog.ts` — log path construction and redaction with a
|
||||||
|
mocked fs. (S)
|
||||||
|
- [ ] `lib/chat/tools/documentOps.ts` — start with the pure helpers (diff/match
|
||||||
|
utilities), not the full tool handlers. (M)
|
||||||
|
- [ ] `lib/chat/tools/toolDispatcher.ts` — dispatch table routing and argument
|
||||||
|
validation with stubbed tools; don't try to cover every tool body. (M)
|
||||||
|
- [ ] `lib/chat/streaming.ts` + `lib/llm/{claude,gemini,openai}.ts` — streaming
|
||||||
|
loops and provider adapters; hardest to unit test, consider extracting
|
||||||
|
pure chunk-parsing helpers first. (M)
|
||||||
|
|
||||||
|
Not worth unit testing directly: `lib/supabase.ts` and `lib/convert.ts` are
|
||||||
|
thin wrappers around external services (Supabase auth, LibreOffice); they are
|
||||||
|
better exercised by the e2e suite.
|
||||||
|
|
||||||
|
## Ratchet policy
|
||||||
|
|
||||||
|
`backend/vitest.config.mts` enforces global coverage **floors** (currently
|
||||||
|
statements 11 / branches 10 / functions 14 / lines 10). They are a
|
||||||
|
no-regression ratchet, not a target:
|
||||||
|
|
||||||
|
- **Floors only go up.** Never lower them to get a PR green — that means your
|
||||||
|
change removed tested behavior or added a large untested lib; add tests
|
||||||
|
instead.
|
||||||
|
- **Raise them in the same PR that adds tests.** After your suite passes, run
|
||||||
|
`npm run test:coverage`, take the new global numbers, and set each floor to
|
||||||
|
the measured value rounded down to a whole percent.
|
||||||
|
- Keep the measured numbers in the config comment and the table above honest
|
||||||
|
when you do.
|
||||||
1973
frontend/package-lock.json
generated
1973
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -62,15 +62,20 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/marked": "^5.0.2",
|
"@types/marked": "^5.0.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"baseline-browser-mapping": "^2.9.11",
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
|
"jsdom": "^27.0.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ export function AskInputPopup({
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- auto-submit when every question is answered; submit() sets state as part of the side effect
|
||||||
if (canSubmit) submit();
|
if (canSubmit) submit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,7 @@ export function CaseLawPanel({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab.opinions?.length) {
|
if (tab.opinions?.length) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync path of an async fetch effect: serve prop/cache data without a loading flash
|
||||||
setOpinions(tab.opinions);
|
setOpinions(tab.opinions);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -269,10 +270,12 @@ export function CaseLawPanel({
|
||||||
orderOpinions(opinions).find(
|
orderOpinions(opinions).find(
|
||||||
({ opinion }) => typeof opinion.opinionId === "number",
|
({ opinion }) => typeof opinion.opinionId === "number",
|
||||||
)?.opinion.opinionId ?? null;
|
)?.opinion.opinionId ?? null;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset active opinion after opinions load
|
||||||
setActiveOpinionId(firstOpinionId);
|
setActiveOpinionId(firstOpinionId);
|
||||||
}, [opinions]);
|
}, [opinions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync quote list when the tab prop changes
|
||||||
setRelevantQuotes(tab.quotes ?? []);
|
setRelevantQuotes(tab.quotes ?? []);
|
||||||
}, [tab.quotes]);
|
}, [tab.quotes]);
|
||||||
|
|
||||||
|
|
@ -321,6 +324,7 @@ export function CaseLawPanel({
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset quote selection when the quote set changes
|
||||||
setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 });
|
setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 });
|
||||||
const firstQuote = relevantQuotes[0];
|
const firstQuote = relevantQuotes[0];
|
||||||
setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null);
|
setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null);
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ export function ChatView({
|
||||||
const panelCloseTimerRef = useRef<number | null>(null);
|
const panelCloseTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset per-chat UI state when switching chats
|
||||||
setHiddenAskInputKeys(new Set());
|
setHiddenAskInputKeys(new Set());
|
||||||
}, [chatId]);
|
}, [chatId]);
|
||||||
|
|
||||||
|
|
@ -519,6 +520,7 @@ export function ChatView({
|
||||||
const c = messagesContainerRef.current;
|
const c = messagesContainerRef.current;
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
c.addEventListener("scroll", updateScrollButton);
|
c.addEventListener("scroll", updateScrollButton);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial scroll-button state must be measured from the live DOM
|
||||||
updateScrollButton();
|
updateScrollButton();
|
||||||
return () => c.removeEventListener("scroll", updateScrollButton);
|
return () => c.removeEventListener("scroll", updateScrollButton);
|
||||||
}, [messages, updateScrollButton]);
|
}, [messages, updateScrollButton]);
|
||||||
|
|
@ -553,6 +555,7 @@ export function ChatView({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
hasScrolledRef.current = false;
|
hasScrolledRef.current = false;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- hide messages until scroll position is restored to avoid a visible jump
|
||||||
setMessagesVisible(false);
|
setMessagesVisible(false);
|
||||||
} else if (!hasScrolledRef.current) {
|
} else if (!hasScrolledRef.current) {
|
||||||
const userMsgCount = messages.filter(
|
const userMsgCount = messages.filter(
|
||||||
|
|
@ -682,8 +685,8 @@ export function ChatView({
|
||||||
{msg.role === "user" ? (
|
{msg.role === "user" ? (
|
||||||
<UserMessage
|
<UserMessage
|
||||||
content={msg.content ?? ""}
|
content={msg.content ?? ""}
|
||||||
files={(msg as any).files}
|
files={msg.files}
|
||||||
workflow={(msg as any).workflow}
|
workflow={msg.workflow}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
|
|
@ -692,11 +695,11 @@ export function ChatView({
|
||||||
i === messages.length - 1 &&
|
i === messages.length - 1 &&
|
||||||
isResponseLoading
|
isResponseLoading
|
||||||
}
|
}
|
||||||
isError={!!(msg as any).error}
|
isError={!!msg.error}
|
||||||
errorMessage={
|
errorMessage={
|
||||||
typeof (msg as any)
|
typeof msg.error ===
|
||||||
.error === "string"
|
"string"
|
||||||
? (msg as any).error
|
? msg.error
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
citations={msg.citations}
|
citations={msg.citations}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export function CitationQuotesHeader({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasMultipleQuotes && viewMode === "list") {
|
if (!hasMultipleQuotes && viewMode === "list") {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- collapse list view when quotes drop to a single item
|
||||||
setViewMode("single");
|
setViewMode("single");
|
||||||
}
|
}
|
||||||
}, [hasMultipleQuotes, viewMode]);
|
}, [hasMultipleQuotes, viewMode]);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export function PreResponseWrapper({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (forceOpen) {
|
if (forceOpen) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- streaming open/minimize latch (see comment above)
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { useSmoothedReveal } from "./useSmoothedReveal";
|
||||||
|
|
||||||
|
const FULL = "**Demo mode** — no AI provider key is configured.";
|
||||||
|
|
||||||
|
describe("useSmoothedReveal", () => {
|
||||||
|
it("snaps to the full text when the stream ends mid-reveal", async () => {
|
||||||
|
// Stream a long body in one chunk while active: the rAF pacer will only
|
||||||
|
// have revealed a prefix by the time the stream ends.
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ text, active }) => useSmoothedReveal(text, active),
|
||||||
|
{ initialProps: { text: "", active: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender({ text: FULL, active: true });
|
||||||
|
expect(result.current.length).toBeLessThan(FULL.length);
|
||||||
|
|
||||||
|
// Stream ends. The hook must snap to the full text — it drives the
|
||||||
|
// rendered slice off `revealedInt`, so updating only the internal ref
|
||||||
|
// would leave the reply frozen at a partial prefix (e.g. "**Demo mo").
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ text: FULL, active: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe(FULL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the full text immediately for a replayed (non-streaming) message", () => {
|
||||||
|
const { result } = renderHook(() => useSmoothedReveal(FULL, false));
|
||||||
|
expect(result.current).toBe(FULL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns more than the text it was given", async () => {
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ text, active }) => useSmoothedReveal(text, active),
|
||||||
|
{ initialProps: { text: FULL, active: false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Text replaced by something shorter (edited / retried turn).
|
||||||
|
await act(async () => {
|
||||||
|
rerender({ text: "short", active: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe("short");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -61,6 +61,7 @@ export function Modal({
|
||||||
// Portals can't render during SSR, so a keep-mounted modal only renders
|
// Portals can't render during SSR, so a keep-mounted modal only renders
|
||||||
// (hidden) after the first client mount.
|
// (hidden) after the first client mount.
|
||||||
const [hasMounted, setHasMounted] = useState(false);
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- SSR portal gate: must flip after first client mount
|
||||||
useEffect(() => setHasMounted(true), []);
|
useEffect(() => setHasMounted(true), []);
|
||||||
const hasHeader = breadcrumbs?.length;
|
const hasHeader = breadcrumbs?.length;
|
||||||
const hasFooter =
|
const hasFooter =
|
||||||
|
|
|
||||||
92
frontend/src/app/components/shared/FileTypeIcon.test.tsx
Normal file
92
frontend/src/app/components/shared/FileTypeIcon.test.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { FileTypeIcon, fileTypeKind } from "./FileTypeIcon";
|
||||||
|
|
||||||
|
describe("fileTypeKind", () => {
|
||||||
|
it("maps bare file_type values to a kind", () => {
|
||||||
|
expect(fileTypeKind("pdf")).toBe("pdf");
|
||||||
|
expect(fileTypeKind("docx")).toBe("word");
|
||||||
|
expect(fileTypeKind("doc")).toBe("word");
|
||||||
|
expect(fileTypeKind("xlsx")).toBe("excel");
|
||||||
|
expect(fileTypeKind("xlsm")).toBe("excel");
|
||||||
|
expect(fileTypeKind("xls")).toBe("excel");
|
||||||
|
expect(fileTypeKind("pptx")).toBe("ppt");
|
||||||
|
expect(fileTypeKind("ppt")).toBe("ppt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps filenames by their extension", () => {
|
||||||
|
expect(fileTypeKind("report.pdf")).toBe("pdf");
|
||||||
|
expect(fileTypeKind("Quarterly Deck.PPTX")).toBe("ppt");
|
||||||
|
expect(fileTypeKind("model.final.xlsx")).toBe("excel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive and trims whitespace", () => {
|
||||||
|
expect(fileTypeKind(" PDF ")).toBe("pdf");
|
||||||
|
expect(fileTypeKind("DOCX")).toBe("word");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to other for unknown, empty, or nullish input", () => {
|
||||||
|
expect(fileTypeKind("txt")).toBe("other");
|
||||||
|
expect(fileTypeKind("")).toBe("other");
|
||||||
|
expect(fileTypeKind(null)).toBe("other");
|
||||||
|
expect(fileTypeKind(undefined)).toBe("other");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("FileTypeIcon", () => {
|
||||||
|
const svgOf = (container: HTMLElement) => container.querySelector("svg");
|
||||||
|
const imgOf = (container: HTMLElement) => container.querySelector("img");
|
||||||
|
|
||||||
|
it("renders the PDF icon image", () => {
|
||||||
|
const { container } = render(<FileTypeIcon fileType="pdf" />);
|
||||||
|
expect(imgOf(container)).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
expect.stringContaining("/icons/file-types/pdf.svg"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Word icon image", () => {
|
||||||
|
const { container } = render(<FileTypeIcon fileType="deck.docx" />);
|
||||||
|
expect(imgOf(container)).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
expect.stringContaining("/icons/file-types/word.svg"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the Excel icon image", () => {
|
||||||
|
const { container } = render(<FileTypeIcon fileType="xlsx" />);
|
||||||
|
expect(imgOf(container)).toHaveAttribute(
|
||||||
|
"src",
|
||||||
|
expect.stringContaining("/icons/file-types/excel.svg"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a grey icon for unknown types", () => {
|
||||||
|
const { container } = render(<FileTypeIcon fileType={null} />);
|
||||||
|
expect(svgOf(container)).toHaveClass("text-gray-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a muted grayscale image for a known kind", () => {
|
||||||
|
const { container } = render(<FileTypeIcon fileType="pdf" muted />);
|
||||||
|
const img = imgOf(container);
|
||||||
|
expect(img).toHaveClass("grayscale");
|
||||||
|
expect(img).toHaveClass("opacity-35");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a muted grey placeholder for unknown types", () => {
|
||||||
|
const { container } = render(<FileTypeIcon fileType={null} muted />);
|
||||||
|
const svg = svgOf(container);
|
||||||
|
expect(svg).toHaveClass("text-gray-300");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("always applies shrink-0 and merges a custom className", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<FileTypeIcon fileType="pdf" className="h-6 w-6" />,
|
||||||
|
);
|
||||||
|
const img = imgOf(container);
|
||||||
|
expect(img).toHaveClass("shrink-0");
|
||||||
|
expect(img).toHaveClass("h-6");
|
||||||
|
expect(img).toHaveClass("w-6");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -21,6 +21,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync fast paths of the async MFA check effect
|
||||||
setGateState("idle");
|
setGateState("idle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +65,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
if (gateState === "required" && !isVerifyPage) {
|
if (gateState === "required" && !isVerifyPage) {
|
||||||
if (hasRecentMfaVerification()) {
|
if (hasRecentMfaVerification()) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear gate when a recent MFA verification exists instead of redirecting
|
||||||
setGateState("verified");
|
setGateState("verified");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { getLibrary, listProjects } from "@/app/lib/mikeApi";
|
||||||
getLibrary,
|
|
||||||
getProject,
|
|
||||||
listProjects,
|
|
||||||
} from "@/app/lib/mikeApi";
|
|
||||||
import type { Document, LibraryFolder, Project } from "./types";
|
import type { Document, LibraryFolder, Project } from "./types";
|
||||||
|
|
||||||
export type DirectoryTab = "files" | "templates" | "projects";
|
export type DirectoryTab = "files" | "templates" | "projects";
|
||||||
|
|
@ -45,17 +41,14 @@ async function loadTemplates() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
const projects = await listProjects();
|
// One batched request. Fanning out getProject(id) per project caused an
|
||||||
const fullProjects = await Promise.all(
|
// N+1 burst on every directory-modal open that could overwhelm the
|
||||||
projects.map((project) => getProject(project.id)),
|
// Supabase gateway once an account had accumulated projects.
|
||||||
);
|
const projects = await listProjects({ includeDocuments: true });
|
||||||
const projectCounts = new Map(
|
return projects.map((project) => ({
|
||||||
projects.map((project) => [project.id, project.document_count ?? 0]),
|
|
||||||
);
|
|
||||||
return fullProjects.map((project) => ({
|
|
||||||
...project,
|
...project,
|
||||||
document_count:
|
document_count:
|
||||||
project.documents?.length ?? projectCounts.get(project.id) ?? 0,
|
project.documents?.length ?? project.document_count ?? 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ function TRResponseStatus({ isActive }: { isActive: boolean }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasActiveRef.current && !isActive) {
|
if (wasActiveRef.current && !isActive) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- timed 'Done' flash on the active->idle transition
|
||||||
setShowDone(true);
|
setShowDone(true);
|
||||||
setDoneVisible(true);
|
setDoneVisible(true);
|
||||||
const t = setTimeout(() => setDoneVisible(false), 1500);
|
const t = setTimeout(() => setDoneVisible(false), 1500);
|
||||||
|
|
|
||||||
39
frontend/src/app/components/tabular/TRTable.test.tsx
Normal file
39
frontend/src/app/components/tabular/TRTable.test.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { TRTable } from "./TRTable";
|
||||||
|
import type { Document } from "../shared/types";
|
||||||
|
|
||||||
|
const doc = { id: "doc-1", filename: "report.pdf" } as Document;
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
return render(
|
||||||
|
<TRTable
|
||||||
|
loading={false}
|
||||||
|
columns={[]}
|
||||||
|
documents={[doc]}
|
||||||
|
cells={[]}
|
||||||
|
savingColumn={false}
|
||||||
|
savingColumnsConfig={false}
|
||||||
|
selectedDocIds={[]}
|
||||||
|
onSelectionChange={vi.fn()}
|
||||||
|
onExpand={vi.fn()}
|
||||||
|
onCitationClick={vi.fn()}
|
||||||
|
onUpdateColumn={vi.fn()}
|
||||||
|
onDeleteColumn={vi.fn()}
|
||||||
|
onAddColumn={vi.fn()}
|
||||||
|
onAddDocuments={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("TRTable", () => {
|
||||||
|
// The grid here is div-based (no table/columnheader/rowheader roles), so
|
||||||
|
// this asserts on rendered content rather than ARIA table semantics.
|
||||||
|
it("renders the Document header and a row for each document", () => {
|
||||||
|
renderTable();
|
||||||
|
expect(screen.getByText("Document")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("report.pdf")).toBeInTheDocument();
|
||||||
|
// One select-all checkbox in the header plus one per document row.
|
||||||
|
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/src/app/components/ui/button.test.tsx
Normal file
44
frontend/src/app/components/ui/button.test.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
|
describe("Button", () => {
|
||||||
|
it("renders its children", () => {
|
||||||
|
render(<Button>Click me</Button>);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Click me" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onClick when activated", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Button onClick={onClick}>Submit</Button>);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the variant class for destructive buttons", () => {
|
||||||
|
render(<Button variant="destructive">Delete</Button>);
|
||||||
|
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
|
||||||
|
"bg-destructive",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onClick while disabled", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<Button disabled onClick={onClick}>
|
||||||
|
Disabled
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Disabled" }));
|
||||||
|
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
frontend/src/app/components/ui/cite-button.test.tsx
Normal file
40
frontend/src/app/components/ui/cite-button.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CiteButton } from "./cite-button";
|
||||||
|
|
||||||
|
describe("CiteButton", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the default 'Cite' label", () => {
|
||||||
|
render(<CiteButton quoteText="hello" citationText="Doe 2020" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /cite/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the label when showText is false", () => {
|
||||||
|
render(
|
||||||
|
<CiteButton
|
||||||
|
quoteText="hello"
|
||||||
|
citationText="Doe 2020"
|
||||||
|
showText={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Cite")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the quote and citation, then shows 'Copied'", async () => {
|
||||||
|
// userEvent.setup() installs a clipboard stub on navigator; spy on it.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeText = vi.spyOn(navigator.clipboard, "writeText");
|
||||||
|
render(<CiteButton quoteText={`he said "hi"`} citationText="Doe 2020" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button"));
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(`"he said 'hi'" Doe 2020`);
|
||||||
|
expect(await screen.findByText("Copied")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
91
frontend/src/app/components/ui/pill-button.test.tsx
Normal file
91
frontend/src/app/components/ui/pill-button.test.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { PillButton } from "./pill-button";
|
||||||
|
|
||||||
|
describe("PillButton", () => {
|
||||||
|
it("renders its children as a button by default", () => {
|
||||||
|
render(<PillButton tone="black">Save</PillButton>);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to type=button", () => {
|
||||||
|
render(<PillButton tone="black">Save</PillButton>);
|
||||||
|
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute(
|
||||||
|
"type",
|
||||||
|
"button",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the tone class", () => {
|
||||||
|
render(<PillButton tone="danger">Delete</PillButton>);
|
||||||
|
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
|
||||||
|
"bg-red-600/90",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the normal size class when requested", () => {
|
||||||
|
render(
|
||||||
|
<PillButton tone="blue" size="normal">
|
||||||
|
Continue
|
||||||
|
</PillButton>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass(
|
||||||
|
"text-sm",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the sm size class", () => {
|
||||||
|
render(<PillButton tone="blue">Next</PillButton>);
|
||||||
|
expect(screen.getByRole("button", { name: "Next" })).toHaveClass(
|
||||||
|
"text-xs",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires onClick when activated", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PillButton tone="white" onClick={onClick}>
|
||||||
|
Click me
|
||||||
|
</PillButton>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Click me" }));
|
||||||
|
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fire onClick while disabled", async () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<PillButton tone="black" disabled onClick={onClick}>
|
||||||
|
Disabled
|
||||||
|
</PillButton>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Disabled" }));
|
||||||
|
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders as its child element via asChild", () => {
|
||||||
|
render(
|
||||||
|
<PillButton tone="blue" asChild>
|
||||||
|
<a href="/docs">Docs</a>
|
||||||
|
</PillButton>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole("link", { name: "Docs" });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute("href", "/docs");
|
||||||
|
// asChild drops the intrinsic button type onto the child.
|
||||||
|
expect(link).not.toHaveAttribute("type");
|
||||||
|
// Pill styling still lands on the rendered child.
|
||||||
|
expect(link).toHaveClass("rounded-full");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -73,6 +73,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear chat state on logout inside the effect that loads chats
|
||||||
setChats([]);
|
setChats([]);
|
||||||
setChatLimit(INITIAL_CHAT_LIMIT);
|
setChatLimit(INITIAL_CHAT_LIMIT);
|
||||||
setHasMoreChats(false);
|
setHasMoreChats(false);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export function useFetchDocxBytes(
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!documentId) {
|
if (!documentId) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale bytes when documentId is removed, within the fetch effect
|
||||||
setBytes(null);
|
setBytes(null);
|
||||||
setDownloadUrl(null);
|
setDownloadUrl(null);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export function useSelectedModel(): [string, (id: string) => void] {
|
||||||
const [model, setModelState] = useState<string>(DEFAULT_MODEL_ID);
|
const [model, setModelState] = useState<string>(DEFAULT_MODEL_ID);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydration-safe localStorage read; SSR must render the default model
|
||||||
setModelState(readStored());
|
setModelState(readStored());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,11 @@ async function toApiError(response: Response, path: string) {
|
||||||
// Projects
|
// Projects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function listProjects(): Promise<Project[]> {
|
export async function listProjects(options?: {
|
||||||
return apiRequest<Project[]>("/projects");
|
includeDocuments?: boolean;
|
||||||
|
}): Promise<Project[]> {
|
||||||
|
const query = options?.includeDocuments ? "?include=documents" : "";
|
||||||
|
return apiRequest<Project[]>(`/projects${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProject(
|
export async function createProject(
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ export default function SupportPage() {
|
||||||
{/* Email Display (if logged in) */}
|
{/* Email Display (if logged in) */}
|
||||||
{user?.email && (
|
{user?.email && (
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
We'll respond to:{" "}
|
We'll respond to:{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
45
frontend/vitest.config.mts
Normal file
45
frontend/vitest.config.mts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
const resolvePath = (relative: string) =>
|
||||||
|
fileURLToPath(new URL(relative, import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
// Mirror the `@/*` path alias from tsconfig.json so unit tests resolve
|
||||||
|
// the same module specifiers the app uses.
|
||||||
|
alias: [
|
||||||
|
{
|
||||||
|
find: /^@\/(.*)$/,
|
||||||
|
replacement: resolvePath("./src/$1"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
// app/lib/supabase.ts creates its client at module load, so any
|
||||||
|
// component whose import graph reaches it needs these set. Dummy
|
||||||
|
// values — unit tests never talk to Supabase.
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: "http://localhost:54321",
|
||||||
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: "test-anon-key",
|
||||||
|
},
|
||||||
|
// jsdom 27's CSS-color parser (@asamuzakjp/css-color) is CJS but
|
||||||
|
// require()s the ESM-only @csstools/css-calc. That require() happens
|
||||||
|
// in the worker process while the jsdom environment boots — before
|
||||||
|
// Vite's transform pipeline is involved — so deps.inline can't fix it.
|
||||||
|
// Instead, let Node itself handle require(esm): default on >=22.12,
|
||||||
|
// and enabled by this (there harmless) flag on 22.0–22.11.
|
||||||
|
execArgv: ["--experimental-require-module"],
|
||||||
|
// Unit tests only. Keep any Playwright e2e specs (*.spec.ts) out.
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
exclude: ["node_modules/**", "e2e/**", "**/*.spec.ts"],
|
||||||
|
// Generous timeouts to absorb cold-start jsdom + transform latency on CI.
|
||||||
|
testTimeout: 20000,
|
||||||
|
hookTimeout: 20000,
|
||||||
|
},
|
||||||
|
});
|
||||||
1
frontend/vitest.setup.ts
Normal file
1
frontend/vitest.setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
Loading…
Add table
Add a link
Reference in a new issue