Developer docs
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

SuiteCommandRunsScope
API integrationnpm test --workspace @verjson/apiVitest, Nodeapps/api/src/**/*.test.ts — the real Hono app driven in-process, against a real test database
Web componentnpm test --workspace @verjson/webVitest + Testing Library, jsdomapps/web/src/**/*.test.tsx — components with the network mocked by MSW
E2Enpm run test:e2ePlaywright, real browserapps/web/e2e/*.spec.ts — user journeys against a running stack
Typesnpm run typechecktsc --noEmit × 3 workspacesThe contract boundary is checked here
Lintnpm run linteslint × 2 appsIncludes the import-boundary rules
Everythingnpm run verifytypecheck → lint → test → buildThe pre-commit gate

The mocking rule

Anything that leaves the process is mocked, except the database.

DependencyIn API testsIn web testsIn E2E
PostgreSQLreal (a separate app_test DB, truncated before each case)n/areal
The API itselfreal, in-process via app.request() — no port boundMSW handlersreal
Social platforms (LinkedIn, Meta, …)vi.mock on the provider adapterMSWrecorded fixtures
Google / Meta Adsvi.mock on the adapterMSWrecorded fixtures
Groq / ElevenLabsvi.mock — deterministic canned output, never a live callMSWfixtures
Stripefixtures for checkout sessions and webhook eventsMSWfixtures — no test-mode charges in CI
SEMrushrecorded responses — the live API is metered and would bill per test runMSWfixtures
Apollorecorded payloads, with no real personal data in the fixturesMSWfixtures
GitHubstubbed — a test must never open a real pull requestMSWfixtures
WhatsApp Cloud APIstubbed — a real send costs money and reaches a real personMSWfixtures
S3 / MinIOin-memory faken/areal MinIO container
Clockinjected, frozen in scheduling testsfrozenreal

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

IDModuleSuiteSpecStatus
T-001auth — signup creates org + ownerVitest (API)apps/api/src/test/auth.test.ts✅
T-002auth — login returns tokens; wrong password 401sVitest (API)apps/api/src/test/auth.test.ts✅
T-003auth — duplicate email 409sVitest (API)apps/api/src/test/auth.test.ts✅
T-004auth — refresh exchanges for a new pairVitest (API)apps/api/src/test/auth.test.ts✅
T-005auth — /me requires a bearer tokenVitest (API)apps/api/src/test/auth.test.ts✅
T-006contract — a bad body 422s with { detail: [{ msg }] }Vitest (API)apps/api/src/test/auth.test.ts✅
T-007landing, all 7 dashboard tabs, docs GFM tables, feature filter, auth guardPlaywrightapps/web/e2e/smoke.spec.ts✅ 5/5
T-017OAuth — forged, expired and wrong-purpose state rejectedVitest (API)apps/api/src/test/oauth.test.ts✅
T-018OAuth — redirect allow-list refuses off-origin and non-http targetsVitest (API)apps/api/src/test/oauth.test.ts✅
T-019encryption — tampered ciphertext rejected; purposes are separate keysVitest (API)apps/api/src/test/crypto.test.ts✅
T-008tenant isolation — a cross-org project read 404s (not 403)Vitest (API)apps/api/src/test/projects.test.ts✅
T-009agency scope — only assigned projects are visible; unassigned 404sVitest (API)apps/api/src/test/projects.test.ts✅
T-012agency users are read-only; members cannot manage membershipVitest (API)apps/api/src/test/projects.test.ts✅
T-013a cross-org user cannot be added as a project memberVitest (API)apps/api/src/test/projects.test.ts✅
T-014budgets — upsert replaces, channel must be in the mix, foreign currency excluded from the roll-upVitest (API)apps/api/src/test/projects.test.ts✅
T-015slugs are unique per org, not globallyVitest (API)apps/api/src/test/projects.test.ts✅
T-020email broadcasts — a broadcast reaches only the targeted segment, never an unsubscribeVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-021email broadcasts — a second Send is refused; the list is never mailed twiceVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-022email broadcasts — CSV import rejects bad rows individually with their row number, and a re-import updates in place without resurrecting an unsubscribeVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-023email broadcasts — a partial send records which addresses failed instead of reporting cleanVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-024email broadcasts — import summary names every non-zero outcome, not just successesVitest (web)apps/web/src/lib/email-broadcasts.test.ts✅
T-025custom categories — one project's category cannot be used by another, even inside the same orgVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-026custom categories — deleting one that holds contacts is refused with the head count, and reassignment moves every contact rather than dropping anyVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-027custom categories — CSV import matches a category name case- and whitespace-insensitively, and only auto-creates when askedVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-028custom categories — a broadcast reaches the custom segment only, and a draft whose audience was deleted refuses to sendVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-029custom categories — audience keys round-trip and refuse to guess; recipient counts read sendable, never totalVitest (web)apps/web/src/lib/email-broadcasts.test.ts✅
T-146aemail tags — a tag from another project is refused outright, and no part of the request is appliedVitest (API)apps/api/src/test/email-tags.test.ts✅
T-146bemail tags — renaming a tag keeps every membership, and folded names cannot collideVitest (API)apps/api/src/test/email-tags.test.ts✅
T-146cemail 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-146demail contacts — the display name is derived from the split name and stays in step on editVitest (API)apps/api/src/test/email-tags.test.ts✅
T-146eemail tags — ?tag_id= returns only tagged contacts, and a foreign tag id 404s instead of answering emptyVitest (API)apps/api/src/test/email-tags.test.ts✅
T-030sheets import — a non-Google, internal or lookalike link is refused without any outbound requestVitest (API)apps/api/src/test/email-broadcasts-sheets.test.ts✅
T-031sheets import — an out-of-scope project and a forbidden role are both refused before the fetch, so the endpoint is not a probeVitest (API)apps/api/src/test/email-broadcasts-sheets.test.ts✅
T-032sheets import — a private sheet returns the sharing setting to change; an off-Google redirect is refused rather than followedVitest (API)apps/api/src/test/email-broadcasts-sheets.test.ts✅
T-033sheets import — an oversized body is aborted mid-read, declared or notVitest (API)apps/api/src/test/email-broadcasts-sheets.test.ts✅
T-034sheets 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-035email HTML sanitiser — allow-list holds for tags, attributes, URL schemes and CSS; output is well-formed and idempotentVitest (API)apps/api/src/test/email-html.test.ts✅
T-036merge tags — fallbacks, escaping into HTML only, unknown tags left standingVitest (API)apps/api/src/test/email-html.test.ts✅
T-037composer bodies — sanitised on write, text part derived, plain-text broadcasts stay plainVitest (API)apps/api/src/test/email-composer.test.ts✅
T-038attachments — org-prefix ownership, size caps, read once by key, missing object names the fileVitest (API)apps/api/src/test/email-composer.test.ts✅
T-039test send — writes no recipients, moves no status, requires send permissionVitest (API)apps/api/src/test/email-composer.test.ts✅
T-040templates & signature — per-project uniqueness, sanitising, cross-tenant invisibilityVitest (API)apps/api/src/test/email-composer.test.ts✅
T-041composer rules — attachment caps match the contract, send blockers, preview sanitising, autosave wordingVitest (web)apps/web/src/lib/email-composer.test.ts✅
T-042pixel 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-043CAPI forwarding — posts to the pixel node, major units, epoch seconds, event_id, hashed id and no raw IPVitest (API)apps/api/src/test/track-endpoints.test.ts✅
T-044CAPI resilience — one event per distinct pixel, and a Graph failure never fails the trackerVitest (API)apps/api/src/test/track-endpoints.test.ts✅
T-045email audience — the CSV importer reads split name, phone and job title; an explicit full_name still wins and a pre-F-146 file imports unchangedVitest (API)apps/api/src/test/email-broadcasts.test.ts✅
T-046email audience — the contact form's exact payload (profile fields + exactly one audience column) is accepted, and an emptied box clears the fieldVitest (API)apps/api/src/test/email-tags.test.ts✅
T-047email custom fields — a number is stored in a column that compares as a number, so 9 > 10 cannot be trueVitest (API)apps/api/src/test/email-fields.test.ts✅
T-048email 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-049email custom fields — a field id from another project 404s and writes nothing; key and type are refused on editVitest (API)apps/api/src/test/email-fields.test.ts✅
T-050email segments — a number condition compares as a number, so 9 > 10 is falseVitest (API)apps/api/src/test/email-segments.test.ts✅
T-051email 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-052email segments — a rule naming a deleted tag matches nobody, never everybodyVitest (API)apps/api/src/test/email-segments.test.ts✅
T-053email segments — counts are computed per read, and sendable excludes unsubscribesVitest (API)apps/api/src/test/email-segments.test.ts✅
T-054email segments — ?segment_id= narrows rather than replaces, and a foreign id 404sVitest (API)apps/api/src/test/email-segments.test.ts✅
T-055email broadcasts — a segment target is resolved at SEND time, so a contact who joined after the draft is mailed and an unsubscribe is notVitest (API)apps/api/src/test/email-segments.test.ts✅
T-056email broadcasts — a draft naming two audiences is refused; switching target clears the other threeVitest (API)apps/api/src/test/email-segments.test.ts✅
T-057email 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-058email tracking — a token cannot be forged, truncated, or used as a different kindVitest (API)apps/api/src/test/email-tracking.test.ts✅
T-059email tracking — the unsubscribe link is in both MIME parts and the pixel is in neither the text part nor an untracked renderVitest (API)apps/api/src/test/email-tracking.test.ts✅
T-060email engagement — the pixel answers identically for a real and a forged token, and is never cachedVitest (API)apps/api/src/test/email-engagement.test.ts✅
T-061email engagement — the click redirect takes no destination from the request and 404s a forgeryVitest (API)apps/api/src/test/email-engagement.test.ts✅
T-062email engagement — a GET never unsubscribes; the POST writes suppression, flag and event, and is idempotentVitest (API)apps/api/src/test/email-engagement.test.ts✅
T-063email engagement — a suppressed address is not mailed even when its contact looks subscribedVitest (API)apps/api/src/test/email-engagement.test.ts✅
T-064automation runtime — one node per tick; a wait is measured from arrival, not from whenever the sweep next lookedVitest (API)apps/api/src/test/automations-runtime.test.ts✅
T-065automation runtime — of three concurrent claims exactly one wins, and re-executing a send node sends nothingVitest (API)apps/api/src/test/automations-runtime.test.ts✅
T-066automation runtime — a contact who unsubscribes mid-journey gets no further email; a dead worker's claim is reclaimedVitest (API)apps/api/src/test/automations-runtime.test.ts✅
T-067automation branching — yes/no paths, scoped to THIS journey's email, using the segment evaluator for contact fieldsVitest (API)apps/api/src/test/automations-runtime.test.ts✅
T-068automation entry rules — once / every_time / after_completion, and a suppressed contact refused at the doorVitest (API)apps/api/src/test/automations-runtime.test.ts✅
T-069automation API — editing refused while live, a step with contacts standing on it cannot be deletedVitest (API)apps/api/src/test/automations-api.test.ts✅
T-070automation validation — reports every fault at once; catches a half-wired condition and a waitless loopVitest (API)apps/api/src/test/automations-api.test.ts✅
T-071automation triggers — contact added, the RIGHT tag only, and an open through the public pixelVitest (API)apps/api/src/test/automations-api.test.ts✅
T-072assistant actions — proposing sends nothing, and says so in the result the model readsVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-073assistant actions — Confirm runs once under three concurrent presses, never after dismiss or expiry, never for another userVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-074assistant actions — a Member's confirmed send is refused by the domain in the Send button's words, recorded as failedVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-075assistant actions — names resolve inside the person's scope; an unknown one lists what exists; model-written bodies are escapedVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-076assistant actions — the card is returned with the reply and survives a reload of the threadVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-077assistant journeys — the requirements' nurture example is built in one confirm, the condition pointing at that email's node, left as a ready draftVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-078assistant journeys — a question about an unsent email, steps after an if, and an uncheckable field condition are all refused before a card existsVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-079assistant step edits — read back with ids, insert pushes down, change keeps kind, remove joins, live journeys refused until stopped, foreign step ids refusedVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-080assistant unsubscribe — flag, address suppression and every journey endedVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-081assistant test send — only ever to the person's own address, and the broadcast stays a draftVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-082assistant 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 enforcedVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-083assistant lists — a tag is created once; up to 50 contacts added with duplicates skipped and bad addresses refusedVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-084assistant bulk tag — exactly the people in the segment when proposed, not a latecomerVitest (API)apps/api/src/test/ai-chat-actions.test.ts✅
T-085assistant, 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-086assistant 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 zeroVitest (API)apps/api/src/test/ai-chat-reports.test.ts✅
T-087assistant reports never quote numbers a mock analytics adapter produced, nor another organization's emailVitest (API)apps/api/src/test/ai-chat-reports.test.ts✅
T-088chat 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 ruleVitest (API)apps/api/src/test/ai-chat-reports.test.ts✅
T-089brand 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 404Vitest (API)apps/api/src/test/ai-chat-reports.test.ts✅
T-090assistant 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 scheduleVitest (API)apps/api/src/test/ai-chat-more.test.ts✅
T-091best 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 patternVitest (API)apps/api/src/test/ai-chat-more.test.ts✅
T-092assistant images — nothing drawn before Confirm; usable on a post by name; listed to the model; refused without posts:writeVitest (API)apps/api/src/test/ai-chat-more.test.ts✅
T-093assistant memory — confirmed in and out, per project or everywhere, carried into new conversations, private to its ownerVitest (API)apps/api/src/test/ai-chat-more.test.ts✅
T-016Stripe webhook — signature verified, idempotent on event idVitest (API)—🔲 blocked on F-016
T-010approval gate — nothing publishes without an approvalVitest (API)—🔲 blocked on F-035
T-011dashboard — JSON drives the UI (add a row → it renders)Playwrightapps/web/e2e/dashboard.spec.ts🔲

Notes

  • The API suite needs a database. docker compose up -d postgres creates both app and app_test (see apps/api/prisma/init-test-db.sql); vitest.config.ts binds the test process to TEST_DATABASE_URL before the Prisma singleton is imported.
  • setup.ts DELETEs every row of every table before each case, in TABLES order. Add each new table to its TABLES list, child-first or tests will leak state into each other — a parent listed before a child that references it with ON DELETE RESTRICT fails 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.ts clones the migrated TEST_DATABASE_URL database as <name>_w1..N (CREATE DATABASE … TEMPLATE) and drops the clones afterwards; worker-database-env.ts points each worker at its own. N is one per core, capped at 4; TEST_DB_WORKERS=N overrides it, and TEST_DB_WORKERS=1 runs 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.