Quality
Testing
What is covered, by which suite, and the mocking rule.
docs/TESTING.md
What is covered, by which suite, and where the spec lives.
requirements.txt asks for "code reviewed — and mock tested — both backend and frontend". This
is the plan for the second half of that.
Suites
| Suite | Command | Runs | Scope |
|---|---|---|---|
| API integration | npm test --workspace @verjson/api | Vitest, Node | apps/api/src/**/*.test.ts — the real Hono app driven in-process, against a real test database |
| Web component | npm test --workspace @verjson/web | Vitest + Testing Library, jsdom | apps/web/src/**/*.test.tsx — components with the network mocked by MSW |
| E2E | npm run test:e2e | Playwright, real browser | apps/web/e2e/*.spec.ts — user journeys against a running stack |
| Types | npm run typecheck | tsc --noEmit × 3 workspaces | The contract boundary is checked here |
| Lint | npm run lint | eslint × 2 apps | Includes the import-boundary rules |
| Everything | npm run verify | typecheck → lint → test → build | The pre-commit gate |
The mocking rule
Anything that leaves the process is mocked, except the database.
| Dependency | In API tests | In web tests | In E2E |
|---|---|---|---|
| PostgreSQL | real (a separate app_test DB, truncated before each case) | n/a | real |
| The API itself | real, in-process via app.request() — no port bound | MSW handlers | real |
| Social platforms (LinkedIn, Meta, …) | vi.mock on the provider adapter | MSW | recorded fixtures |
| Google / Meta Ads | vi.mock on the adapter | MSW | recorded fixtures |
| Groq / ElevenLabs | vi.mock — deterministic canned output, never a live call | MSW | fixtures |
| Stripe | fixtures for checkout sessions and webhook events | MSW | fixtures — no test-mode charges in CI |
| SEMrush | recorded responses — the live API is metered and would bill per test run | MSW | fixtures |
| Apollo | recorded payloads, with no real personal data in the fixtures | MSW | fixtures |
| GitHub | stubbed — a test must never open a real pull request | MSW | fixtures |
| WhatsApp Cloud API | stubbed — a real send costs money and reaches a real person | MSW | fixtures |
| S3 / MinIO | in-memory fake | n/a | real MinIO container |
| Clock | injected, frozen in scheduling tests | frozen | real |
Why the database is exempt: our hardest correctness property is tenant isolation, and a mocked Prisma proves nothing about whether a query is org-scoped. Testing against real Postgres is the only way that assertion means anything.
Coverage table
| ID | Module | Suite | Spec | Status |
|---|---|---|---|---|
| T-001 | auth — signup creates org + owner | Vitest (API) | apps/api/src/test/auth.test.ts | ✅ |
| T-002 | auth — login returns tokens; wrong password 401s | Vitest (API) | apps/api/src/test/auth.test.ts | ✅ |
| T-003 | auth — duplicate email 409s | Vitest (API) | apps/api/src/test/auth.test.ts | ✅ |
| T-004 | auth — refresh exchanges for a new pair | Vitest (API) | apps/api/src/test/auth.test.ts | ✅ |
| T-005 | auth — /me requires a bearer token | Vitest (API) | apps/api/src/test/auth.test.ts | ✅ |
| T-006 | contract — a bad body 422s with { detail: [{ msg }] } | Vitest (API) | apps/api/src/test/auth.test.ts | ✅ |
| T-007 | landing, all 7 dashboard tabs, docs GFM tables, feature filter, auth guard | Playwright | apps/web/e2e/smoke.spec.ts | ✅ 5/5 |
| T-017 | OAuth — forged, expired and wrong-purpose state rejected | Vitest (API) | apps/api/src/test/oauth.test.ts | ✅ |
| T-018 | OAuth — redirect allow-list refuses off-origin and non-http targets | Vitest (API) | apps/api/src/test/oauth.test.ts | ✅ |
| T-019 | encryption — tampered ciphertext rejected; purposes are separate keys | Vitest (API) | apps/api/src/test/crypto.test.ts | ✅ |
| T-008 | tenant isolation — a cross-org project read 404s (not 403) | Vitest (API) | apps/api/src/test/projects.test.ts | ✅ |
| T-009 | agency scope — only assigned projects are visible; unassigned 404s | Vitest (API) | apps/api/src/test/projects.test.ts | ✅ |
| T-012 | agency users are read-only; members cannot manage membership | Vitest (API) | apps/api/src/test/projects.test.ts | ✅ |
| T-013 | a cross-org user cannot be added as a project member | Vitest (API) | apps/api/src/test/projects.test.ts | ✅ |
| T-014 | budgets — upsert replaces, channel must be in the mix, foreign currency excluded from the roll-up | Vitest (API) | apps/api/src/test/projects.test.ts | ✅ |
| T-015 | slugs are unique per org, not globally | Vitest (API) | apps/api/src/test/projects.test.ts | ✅ |
| T-020 | email broadcasts — a broadcast reaches only the targeted segment, never an unsubscribe | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-021 | email broadcasts — a second Send is refused; the list is never mailed twice | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-022 | email broadcasts — CSV import rejects bad rows individually with their row number, and a re-import updates in place without resurrecting an unsubscribe | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-023 | email broadcasts — a partial send records which addresses failed instead of reporting clean | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-024 | email broadcasts — import summary names every non-zero outcome, not just successes | Vitest (web) | apps/web/src/lib/email-broadcasts.test.ts | ✅ |
| T-025 | custom categories — one project's category cannot be used by another, even inside the same org | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-026 | custom categories — deleting one that holds contacts is refused with the head count, and reassignment moves every contact rather than dropping any | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-027 | custom categories — CSV import matches a category name case- and whitespace-insensitively, and only auto-creates when asked | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-028 | custom categories — a broadcast reaches the custom segment only, and a draft whose audience was deleted refuses to send | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-029 | custom categories — audience keys round-trip and refuse to guess; recipient counts read sendable, never total | Vitest (web) | apps/web/src/lib/email-broadcasts.test.ts | ✅ |
| T-146a | email tags — a tag from another project is refused outright, and no part of the request is applied | Vitest (API) | apps/api/src/test/email-tags.test.ts | ✅ |
| T-146b | email tags — renaming a tag keeps every membership, and folded names cannot collide | Vitest (API) | apps/api/src/test/email-tags.test.ts | ✅ |
| T-146c | email tags — re-applying a tag and removing an absent one are no-ops (the automation re-entry path) | Vitest (API) | apps/api/src/test/email-tags.test.ts | ✅ |
| T-146d | email contacts — the display name is derived from the split name and stays in step on edit | Vitest (API) | apps/api/src/test/email-tags.test.ts | ✅ |
| T-146e | email tags — ?tag_id= returns only tagged contacts, and a foreign tag id 404s instead of answering empty | Vitest (API) | apps/api/src/test/email-tags.test.ts | ✅ |
| T-030 | sheets import — a non-Google, internal or lookalike link is refused without any outbound request | Vitest (API) | apps/api/src/test/email-broadcasts-sheets.test.ts | ✅ |
| T-031 | sheets import — an out-of-scope project and a forbidden role are both refused before the fetch, so the endpoint is not a probe | Vitest (API) | apps/api/src/test/email-broadcasts-sheets.test.ts | ✅ |
| T-032 | sheets import — a private sheet returns the sharing setting to change; an off-Google redirect is refused rather than followed | Vitest (API) | apps/api/src/test/email-broadcasts-sheets.test.ts | ✅ |
| T-033 | sheets import — an oversized body is aborted mid-read, declared or not | Vitest (API) | apps/api/src/test/email-broadcasts-sheets.test.ts | ✅ |
| T-034 | sheets import — a sheet goes through the identical CSV pipeline (aliases, dedupe, custom categories) | Vitest (API) | apps/api/src/test/email-broadcasts-sheets.test.ts | ✅ |
| T-035 | email HTML sanitiser — allow-list holds for tags, attributes, URL schemes and CSS; output is well-formed and idempotent | Vitest (API) | apps/api/src/test/email-html.test.ts | ✅ |
| T-036 | merge tags — fallbacks, escaping into HTML only, unknown tags left standing | Vitest (API) | apps/api/src/test/email-html.test.ts | ✅ |
| T-037 | composer bodies — sanitised on write, text part derived, plain-text broadcasts stay plain | Vitest (API) | apps/api/src/test/email-composer.test.ts | ✅ |
| T-038 | attachments — org-prefix ownership, size caps, read once by key, missing object names the file | Vitest (API) | apps/api/src/test/email-composer.test.ts | ✅ |
| T-039 | test send — writes no recipients, moves no status, requires send permission | Vitest (API) | apps/api/src/test/email-composer.test.ts | ✅ |
| T-040 | templates & signature — per-project uniqueness, sanitising, cross-tenant invisibility | Vitest (API) | apps/api/src/test/email-composer.test.ts | ✅ |
| T-041 | composer rules — attachment caps match the contract, send blockers, preview sanitising, autosave wording | Vitest (web) | apps/web/src/lib/email-composer.test.ts | ✅ |
| T-042 | pixel list — tenant-scoped, external id passed not ours, adapter-without-pixels named, revoked token becomes 'reconnect' | Vitest (API) | apps/api/src/test/ads-publish.test.ts | ✅ |
| T-043 | CAPI forwarding — posts to the pixel node, major units, epoch seconds, event_id, hashed id and no raw IP | Vitest (API) | apps/api/src/test/track-endpoints.test.ts | ✅ |
| T-044 | CAPI resilience — one event per distinct pixel, and a Graph failure never fails the tracker | Vitest (API) | apps/api/src/test/track-endpoints.test.ts | ✅ |
| T-045 | email audience — the CSV importer reads split name, phone and job title; an explicit full_name still wins and a pre-F-146 file imports unchanged | Vitest (API) | apps/api/src/test/email-broadcasts.test.ts | ✅ |
| T-046 | email audience — the contact form's exact payload (profile fields + exactly one audience column) is accepted, and an emptied box clears the field | Vitest (API) | apps/api/src/test/email-tags.test.ts | ✅ |
| T-047 | email custom fields — a number is stored in a column that compares as a number, so 9 > 10 cannot be true | Vitest (API) | apps/api/src/test/email-fields.test.ts | ✅ |
| T-048 | email custom fields — clearing deletes the sparse row, so is empty can tell "never set" from "set to nothing" | Vitest (API) | apps/api/src/test/email-fields.test.ts | ✅ |
| T-049 | email custom fields — a field id from another project 404s and writes nothing; key and type are refused on edit | Vitest (API) | apps/api/src/test/email-fields.test.ts | ✅ |
| T-050 | email segments — a number condition compares as a number, so 9 > 10 is false | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-051 | email segments — a negative condition includes contacts with no value at all (SQL NOT NULL ILIKE is not TRUE) | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-052 | email segments — a rule naming a deleted tag matches nobody, never everybody | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-053 | email segments — counts are computed per read, and sendable excludes unsubscribes | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-054 | email segments — ?segment_id= narrows rather than replaces, and a foreign id 404s | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-055 | email broadcasts — a segment target is resolved at SEND time, so a contact who joined after the draft is mailed and an unsubscribe is not | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-056 | email broadcasts — a draft naming two audiences is refused; switching target clears the other three | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-057 | email broadcasts — a sent broadcast stays readable after its segment is deleted (SET NULL + snapshot label) | Vitest (API) | apps/api/src/test/email-segments.test.ts | ✅ |
| T-058 | email tracking — a token cannot be forged, truncated, or used as a different kind | Vitest (API) | apps/api/src/test/email-tracking.test.ts | ✅ |
| T-059 | email tracking — the unsubscribe link is in both MIME parts and the pixel is in neither the text part nor an untracked render | Vitest (API) | apps/api/src/test/email-tracking.test.ts | ✅ |
| T-060 | email engagement — the pixel answers identically for a real and a forged token, and is never cached | Vitest (API) | apps/api/src/test/email-engagement.test.ts | ✅ |
| T-061 | email engagement — the click redirect takes no destination from the request and 404s a forgery | Vitest (API) | apps/api/src/test/email-engagement.test.ts | ✅ |
| T-062 | email engagement — a GET never unsubscribes; the POST writes suppression, flag and event, and is idempotent | Vitest (API) | apps/api/src/test/email-engagement.test.ts | ✅ |
| T-063 | email engagement — a suppressed address is not mailed even when its contact looks subscribed | Vitest (API) | apps/api/src/test/email-engagement.test.ts | ✅ |
| T-064 | automation runtime — one node per tick; a wait is measured from arrival, not from whenever the sweep next looked | Vitest (API) | apps/api/src/test/automations-runtime.test.ts | ✅ |
| T-065 | automation runtime — of three concurrent claims exactly one wins, and re-executing a send node sends nothing | Vitest (API) | apps/api/src/test/automations-runtime.test.ts | ✅ |
| T-066 | automation runtime — a contact who unsubscribes mid-journey gets no further email; a dead worker's claim is reclaimed | Vitest (API) | apps/api/src/test/automations-runtime.test.ts | ✅ |
| T-067 | automation branching — yes/no paths, scoped to THIS journey's email, using the segment evaluator for contact fields | Vitest (API) | apps/api/src/test/automations-runtime.test.ts | ✅ |
| T-068 | automation entry rules — once / every_time / after_completion, and a suppressed contact refused at the door | Vitest (API) | apps/api/src/test/automations-runtime.test.ts | ✅ |
| T-069 | automation API — editing refused while live, a step with contacts standing on it cannot be deleted | Vitest (API) | apps/api/src/test/automations-api.test.ts | ✅ |
| T-070 | automation validation — reports every fault at once; catches a half-wired condition and a waitless loop | Vitest (API) | apps/api/src/test/automations-api.test.ts | ✅ |
| T-071 | automation triggers — contact added, the RIGHT tag only, and an open through the public pixel | Vitest (API) | apps/api/src/test/automations-api.test.ts | ✅ |
| T-072 | assistant actions — proposing sends nothing, and says so in the result the model reads | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-073 | assistant actions — Confirm runs once under three concurrent presses, never after dismiss or expiry, never for another user | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-074 | assistant actions — a Member's confirmed send is refused by the domain in the Send button's words, recorded as failed | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-075 | assistant actions — names resolve inside the person's scope; an unknown one lists what exists; model-written bodies are escaped | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-076 | assistant actions — the card is returned with the reply and survives a reload of the thread | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-077 | assistant journeys — the requirements' nurture example is built in one confirm, the condition pointing at that email's node, left as a ready draft | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-078 | assistant journeys — a question about an unsent email, steps after an if, and an uncheckable field condition are all refused before a card exists | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-079 | assistant step edits — read back with ids, insert pushes down, change keeps kind, remove joins, live journeys refused until stopped, foreign step ids refused | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-080 | assistant unsubscribe — flag, address suppression and every journey ended | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-081 | assistant test send — only ever to the person's own address, and the broadcast stays a draft | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-082 | assistant posts — a draft publishes nothing; a schedule is outward with its time; approval-required projects say so; text-only to Instagram is refused; caption limits and multi-account ambiguity are enforced | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-083 | assistant lists — a tag is created once; up to 50 contacts added with duplicates skipped and bad addresses refused | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-084 | assistant bulk tag — exactly the people in the segment when proposed, not a latecomer | Vitest (API) | apps/api/src/test/ai-chat-actions.test.ts | ✅ |
| T-085 | assistant, LIVE — real model prepares rather than claims, builds the branching journey correctly, invents nothing (opt-in, costs money) | Vitest (API, live) | apps/api/src/test/assistant-live.test.ts | ⛔ blocked: OpenRouter key rejected (401) |
| T-086 | assistant reports — email rates with links counted per person and the project's own usual; automation step results and where people wait; channel followers, best posts and weekdays; website sessions and search clicks — each saying "missing" rather than zero | Vitest (API) | apps/api/src/test/ai-chat-reports.test.ts | ✅ |
| T-087 | assistant reports never quote numbers a mock analytics adapter produced, nor another organization's email | Vitest (API) | apps/api/src/test/ai-chat-reports.test.ts | ✅ |
| T-088 | chat attachments — own-organization keys only, names (never keys) to the model and the browser; a post carries only files attached in its thread, held to each platform's adapter rule | Vitest (API) | apps/api/src/test/ai-chat-reports.test.ts | ✅ |
| T-089 | brand voice — saved only on Confirm and merged; round-trips through Settings with blanks as none; reaches the assistant and the AI caption; another org's project is a 404 | Vitest (API) | apps/api/src/test/ai-chat-reports.test.ts | ✅ |
| T-090 | assistant post edits — caption/title/files/time; published refused; approval-required times go to review with no committed time; a scheduled post whose words change comes off the schedule | Vitest (API) | apps/api/src/test/ai-chat-more.test.ts | ✅ |
| T-091 | best times — posts ranked by weekday/hour in the project's timezone with a 2-post floor; email send slots by click rate with a 20-send floor; "too little to tell" when there is no pattern | Vitest (API) | apps/api/src/test/ai-chat-more.test.ts | ✅ |
| T-092 | assistant images — nothing drawn before Confirm; usable on a post by name; listed to the model; refused without posts:write | Vitest (API) | apps/api/src/test/ai-chat-more.test.ts | ✅ |
| T-093 | assistant memory — confirmed in and out, per project or everywhere, carried into new conversations, private to its owner | Vitest (API) | apps/api/src/test/ai-chat-more.test.ts | ✅ |
| T-016 | Stripe webhook — signature verified, idempotent on event id | Vitest (API) | — | 🔲 blocked on F-016 |
| T-010 | approval gate — nothing publishes without an approval | Vitest (API) | — | 🔲 blocked on F-035 |
| T-011 | dashboard — JSON drives the UI (add a row → it renders) | Playwright | apps/web/e2e/dashboard.spec.ts | 🔲 |
Notes
- The API suite needs a database.
docker compose up -d postgrescreates bothappandapp_test(seeapps/api/prisma/init-test-db.sql);vitest.config.tsbinds the test process toTEST_DATABASE_URLbefore the Prisma singleton is imported. setup.tsDELETEs every row of every table before each case, inTABLESorder. Add each new table to itsTABLESlist, child-first or tests will leak state into each other — a parent listed before a child that references it withON DELETE RESTRICTfails the reset. It is DELETE, not TRUNCATE, on purpose: TRUNCATE gives every table a new file, and fsyncing millions of them stalled Postgres checkpoints past the hook timeout once files ran in parallel.- The API suite runs files in parallel, one Postgres database per worker:
global-setup.tsclones the migratedTEST_DATABASE_URLdatabase as<name>_w1..N(CREATE DATABASE … TEMPLATE) and drops the clones afterwards;worker-database-env.tspoints each worker at its own. N is one per core, capped at 4;TEST_DB_WORKERS=Noverrides it, andTEST_DB_WORKERS=1runs serially against the base database as before. Cloning needs nothing else connected to the base database, and a role that may create databases. - Rate limiting is off outside production unless
RATELIMIT_FORCE=1, so local runs and the Playwright suite are not throttled.