De documentatie is alleen in het Engels geschreven. De rest van de site is ook in het Nederlands beschikbaar.
Testing strategy
What is tested where: Postgres rules with pgTAP, edge functions with Deno, app logic with unit tests, screens with UI tests, and the full flow end to end.
Bijgewerkt: 29 October 2026
The pyramid
| Layer | Tool | Runs | Owns |
|---|---|---|---|
| Postgres schema, RLS, functions | pgTAP in supabase/tests/*.sql | CI and before db push | Every visibility rule, every function guard, every trigger |
| Edge functions | Deno tests beside handler.ts | CI | Push fan-out, deletion, export, e-mail code verification, with injected fakes |
| App domain logic | JUnit/Truth + XCTest | CI, pre-commit | Validation, formatting, snapshot diffing, sync reconciliation buckets, view-model state |
| Screens | Compose UI tests + XCUITest, semantics only | CI (emulator/simulator) | Each screen renders each state; accessibility acceptance |
| End to end | run-e2e.ps1 (Android; the XCUITest port follows when iOS resumes), against local Supabase with seed.sql | before a merge into develop (smoke), before a phase is delivered and before a release (full) | The whole functional design, scenario by scenario: see End-to-end test process |
| Website | vitest | CI | i18n parity, no literal store URLs outside StoreBadges, sitemap consistency |
The visibility matrix (pgTAP)
build_snapshot is the most important function in the system and gets a table-driven test: three profiles (Anna, Ben, Cara), each with names, two phones, an e-mail, an address in fields mode, a birthday with hidden year and a photo, three circles each; every pairwise connection in every circle; assertions that each detail appears in exactly the snapshots whose circle is in its circle_ids, that the year appears only where year_circle_ids allows, that address fields follow field_scopes, and that a non-connected profile receives null. Then: move Ben to another circle → Anna’s snapshot changes and a circle_changed notification exists for Ben only if his view changed; delete a circle with members → error; remove a detail → hash changes, contact_updated emitted once per affected connection; block → snapshot removed and search returns nothing.
RLS is tested by set local role authenticated; set local request.jwt.claims = '{"sub": "<uuid>"}' and asserting row counts on every table for owner, connected stranger and unrelated stranger.
The pgTAP suite
Sixteen files, 803 assertions, run with npx supabase test db (which drives pg_prove). Each file wraps itself in begin … rollback, so tests leave nothing behind and file order does not matter.
| File | What it pins |
|---|---|
01_schema_and_profiles.sql | An account row appears for every auth user; a new profile arrives with its default circles and system detail types; the main profile cannot be deleted by hand; nobody reads another account’s rows |
02_rls_ownership.sql | The ownership matrix: one row of Anna’s in every profile-scoped table, each asked the same three questions as owner, as a connected contact and as a stranger |
03_details_constraints.sql | A detail may only point at circles and types of its own profile; the default name drives display_name; verification is never self-awarded; four photos per profile under a path derived from the row’s own ids |
04_snapshot_matrix.sql | The visibility matrix above, asked of every pair and every circle, plus the year rule, the field-level address rule and the null for a non-connected profile |
05_invitations.sql | send_invitation and accept_invitation refuse everything they should, and when they say yes both sides of the relationship are complete in one transaction |
06_search.sql | Tokens are derived from exactly the details that may be found, and search_profiles refuses malformed tokens, applies the AND rule over name words, and excludes opted-out, blocked and departed profiles |
07_blocks_and_removal.sql | Ending a relationship ends both rows and tells nobody (A102): no notification for either side, after a removal, a block or a profile deletion |
08_account_lifecycle.sql | Deletion leaves nothing anywhere, tells every contact exactly once, keeps the audit row; the export is complete; the disposable-domain hook refuses a sign-up |
09_storage_policies.sql | can_view_photo matches the table rule exactly, including the whole-name match, and the push trigger survives a missing Vault entry |
10_notifications_rate_limits.sql | Connections and invitations are function-only; a notification’s kind is the database’s word; a circle with members refuses to disappear; rate limiting, audit and coalescing behave |
11_profile_rules.sql | The five detail limits hold at their boundary under a row lock; a year circle is always a day-and-month circle; delete_profile refuses the main profile and a stranger, and tells every connected profile exactly once; replace_detail_type moves every detail and refuses a system type |
12_phase4_contacts.sql | The invitation reminder is the sender’s, once, and only after seven days; the circle preview is the owner’s and equals what a member of that circle already holds; the name tokeniser matches its golden vector word for word; the expiry sweep is on the hourly schedule |
13_invitation_parties.sql | Each side of a pending invitation learns the other’s default name and avatar path and nothing else; a third account and an id that is not an invitation get the same empty answer; two hundred ids are a page and more than that is refused |
14_sign_in_email.sql | The address an account signs in with is verified by the server wherever it is written: on insert, on an edit that turns another address into it, and on a managed profile of the same account; the same address on somebody else’s profile proves nothing; a searchable sign-in address is findable through search_profiles without a code |
15_prune_notifications.sql | A notification older than ninety days is deleted, one that is one day short of it is not; the function returns the count it removed; nobody but the nightly job can call it; the schedule exists exactly once |
16_phase5_services.sql | A service and its links are the owner’s alone and are writable only in the columns the client owns; ten services is the limit; a link belongs to its service’s profile; removing a contact keeps the address-book entry, lets the contact go and moves updated_at, while an entry that was never written goes with it; cross_reference takes two hundred hashed tokens and no names, echoes the token that matched, applies the same four exclusions as search and refuses the thirty-first call in an hour; a synced circle that is deleted leaves circle_ids instead of wedging the row; a link beside an orphaned entry is refused as a duplicate and adopted by an update instead; delete_account_data takes both tables with it |
Saymail’s web/supabase/tests are plain plpgsql scripts that raise on a failed expectation. Incico uses pgTAP through the CLI instead: plan(), is(), throws_ok() and results_eq() give a count and a TAP report, so a file that stops early is a failure rather than a short run.
Edge functions have 132 Deno tests beside every handler and every shared module, run with:
deno test --allow-env --allow-net=127.0.0.1 supabase/functions
.github/workflows/backend.yml runs both suites on every push and pull request touching supabase/**, after deno lint and deno fmt --check, and finishes with a supabase db reset so a broken seed.sql fails the build.
The v1 Playwright scenarios, ported
- Register three users, complete onboarding, add and remove each detail type.
- Circle CRUD: cancel discards, duplicate names get “(2)”, delete needs confirmation and is blocked while members exist, rename inline, toggle per-detail circles.
- Send invitations with circle and relation.
- Accept and decline; verify field-level visibility immediately; move a contact between circles.
- Nickname editing and cancel semantics.
- Changing a circle’s shared details propagates to the other party’s view.
- Connect a service (device contacts on the emulator), pick circles, run sync, verify buckets, auto-numbered service names.
- Pending-sync badges only on services whose circles include the changed detail.
- Account deletion cascades and removes the user from other people’s lists.
The nine are part of the larger catalogue in End-to-end test process, which also covers sign-in, the profile editors, notifications, settings, offline behaviour, tablets, languages and accessibility. Each scenario exists as a Compose instrumented test; the XCUITest port follows when iOS resumes, sharing the IDs and step names.
Golden vectors
docs/vectors/phone-format.json and address-format.json exist and are loaded by both test suites (Gradle resources.srcDir("../../../docs/vectors"), Xcode folder reference), so +31612345678 formats identically on both platforms and a Dutch address renders as one line the same way. Both are generated or checked rather than hand-kept: tools/gen-phone-vectors.mjs produces every phone expectation from libphonenumber-js, and tools/vectors-check.mjs recomputes every address display from the rule in mobile/design/address-formats.json and fails CI on a mismatch. docs/vectors/search-tokens.json and avatar-colour.json joined them in phase 4: the first pins the three rules a device must apply before it calls search_profiles (a device that hashes differently finds nobody), the second pins the initials-avatar colour, so the same person is never orange on one screen and green on another. Both are recomputed by tools/vectors-check.mjs, and the search tokens are asserted a second time against the SQL functions by supabase/tests/12_phase4_contacts.sql. snapshot-hash.json and circle-dots.json follow with the features that need them.
Naming and structure
Action_WithCondition_ShouldResult, Arrange-Act-Assert with comments, one test class per source class, builders in Builders/ beside the tests, files under 400 lines, both success and failure paths. Fakes over mocks on iOS; MockK on Android where a fake would be bigger than the test.
Release gate
A release requires: CI green on main; E2E suites green on the emulator and on the simulator; manual test.md items checked; supabase db diff empty against the staging project after deploy.