Skip to content

Latest commit

 

History

History
194 lines (150 loc) · 46.5 KB

testing.md

File metadata and controls

194 lines (150 loc) · 46.5 KB

Testing

Most of Potluck's risk sits in two places: the money math and the database rules that decide who sees what. The test suite is weighted toward those.

flowchart TB
  e2e["End to end<br/>Playwright in apps/web/e2e<br/>journeys and axe audits against the local stack"]
  db["Database (pgTAP)<br/>supabase/tests/*.test.sql<br/>RLS, RPCs, triggers, cron, storage"]
  unit["Unit (Vitest)<br/>core, i18n, tokens, ui, api, web<br/>pure functions and components"]
  e2e --> db --> unit
Loading
Layer Tool Where Runs in CI
Unit Vitest 5; Node's test runner for the edge function helpers packages/*/src/**/*.test.ts(x), apps/web/src/**/*.test.tsx, supabase/functions/**/*.test.ts ci.yml check job
DB pgTAP via supabase test db supabase/tests/*.test.sql ci.yml db job
E2E Playwright + axe-core apps/web/e2e/*.spec.ts ci.yml e2e job

Unit tests

pnpm test                               # every workspace, through turbo
pnpm test:functions                     # edge function helpers (Node's test runner)
pnpm --filter @potluck/core test        # one package
cd packages/core && pnpm vitest         # watch mode

What each package covers:

  • core: every split type, allocate remainders and tie-breaks, computeBalances, simplifyDebts, currency parsing and conversion, buildSlots across DST changes, rankSlots, heatLevel, nextOccurrence month-end clamping, the can() role matrix and the zod schemas. Money code uses property tests with fast-check (for example: an equal split always sums to the total, shares differ by at most one cent, and input order does not matter).
  • i18n: t() lookups, plurals and placeholders, formatMoney, formatDate in an explicit zone. dictionary.test.ts checks every string: no em or en dashes, no blanks, well formed placeholders, plural pairs, and a message for every split error, schema error, activity verb and notification type.
  • tokens: brand hues, WCAG contrast ratios, focus ring visibility and the heat scale steps.
  • ui: components in jsdom with Testing Library (button, field, money-input, sheet, segmented-control, ...).
  • web: screens and hooks in jsdom (vite.config.ts sets environment: 'jsdom'). src/lib/server-codes.test.ts reads the migrations and fails when an error code the database raises has no wording or is missing from the error list in architecture.md, or when a new migration raises P0002.

Supabase fakes

Fake Use it for
packages/api/src/testing/fake-supabase.ts api tests: an in-memory recorder. Seed tables, queue answers with .respond('rpc:name', ...), assert with queries(table) and argsOf(call, method)
apps/web/src/test/supabase.ts screen tests that fetch: vi.mock('@/lib/supabase', () => import('@/test/supabase')). Wraps the api fake and adds emitChange to replay realtime changes
apps/web/src/features/expenses/fake-supabase.ts components fed from a seeded query cache, with writes mocked at the @potluck/api function boundary

Fixtures: apps/web/src/test/fixtures.ts, src/test/plan-fixtures.ts and src/features/expenses/test-utils.ts. The hand-built Expense rows there must gain any new column (fx_rate is decimal text, as the api reads it).

House rules:

  • Test behavior through the public interface: call splitPercent, render <MoneyInput>, and assert on results. No tests that read or snapshot source code.
  • Money assertions use exact integers. If a test needs toBeCloseTo, the code under test is wrong.
  • Rules that exist in both core and SQL (splits, balances, debt simplification, slot ranking, recurrence) need tests on both sides with the same cases, so the two implementations stay in step.

Coverage

Every package with logic has @vitest/coverage-v8, a test:coverage script and coverage.thresholds in its Vitest config, so the run fails below target. CI runs pnpm test:coverage and writes a table per package to the job summary from coverage/coverage-summary.json.

Package Statements and lines Branches Functions Config
packages/core 90% 90% 90% packages/core/vitest.config.ts
packages/i18n 90% 90% 90% packages/i18n/vitest.config.ts
packages/ui 70% 85% 90% packages/ui/vitest.config.ts
packages/api 60% 90% 90% packages/api/vitest.config.ts
apps/web 70% 75% 85% apps/web/vite.config.ts
pnpm test:coverage                          # every package, through turbo
pnpm --filter @potluck/web test:coverage    # one package

Reports (text-summary, lcov, json-summary) go to each package's coverage/ folder, which is git-ignored. Open coverage/lcov-report/index.html to see the uncovered lines.

Left out of the numbers, and why:

  • apps/web/src/routes/**, routeTree.gen.ts, main.tsx: file routes are thin wrappers around feature screens, and the Playwright journeys cover them.
  • packages/api/src/database.types.ts: generated by pnpm db:types.
  • Test helpers (src/test/**, src/testing/**, fake-supabase.ts, *test-utils.ts).

If a change drops a package below its threshold, add tests for the new behavior rather than lowering the number.

Database tests

pnpm db:start   # once
pnpm db:test    # supabase test db: every file in supabase/tests, in name order

Each file runs in its own transaction and rolls back, so tests do not leak into each other or into your seed data. 000_setup.test.sql installs helpers in a tests schema (create_user, login, login_anon, group_with, people, ...); see development.md for the full list and an example.

File Covers
01_schema.test.sql tables, RLS on everywhere, RPC signatures and privileges, the anon allowlist, no function raising P0002, indexes, seeded config, buckets, realtime, cron. Most schema changes update it; its header lists when
02_profiles_social.test.sql profiles, friendships, blocks, reports
03_groups_rls.test.sql group, member and invite policies for owner, admin, member, outsider and anon
04_group_rpcs.test.sql create group, roles, remove, leave, transfer, direct groups
05_invites.test.sql create_invite, invite_preview, accept_invite
06_expenses.test.sql expense RPCs, split math, the split sum constraint, expense RLS
07_balances_settlements.test.sql group_balances, settle_up, delete_settlement, simplified_debts
08_events_availability.test.sql events RLS, availability RPCs, best_slots, scheduling, RSVPs, my_timeline
09_platform.test.sql activity, comments, notifications, push tokens, app config, calendar connections, busy blocks, recurring templates
10_storage.test.sql object policies for avatars, group covers and receipts
11_account.test.sql delete_my_account
12_cron.test.sql recurring materialization, reminders, finishing past events
13_edge_hooks.test.sql calendar_feed_token, push hook is a no-op without Vault config
14_query_performance.test.sql inlinable money helpers, array form member read policies, balances and debts after the performance migration
14_review_fixes.test.sql archived groups refuse money, plan, comment, RSVP and recurring template writes, delete_group and the open balance guard, resuming a paused recurring template, templates stopping for closed accounts, RSVP deadlines, editing an expense that involves a former member
15_integrity.test.sql blocked joins and invite previews, splits-only edits logging and notifying, edits, deletes, restores and payments involving people who left, former members staying readable, an owner leaving with only closed accounts, rescheduling reminders, closing an account across groups, the recurring job lock order, FX precision
16_availability_lock.test.sql set_availability holds a per event and viewer advisory lock around its replace, so two requests from the same viewer (a save in flight and the flush sent as the page is left) apply one after the other
17_notification_prefs.test.sql notification settings (defaults, one channel at a time, who can read and write), routing to push and email, the email flag, the outbox hold and folding per expense, batches, delivery reports, retries with backoff and the attempt cap, unsubscribe tokens with preview and an undo that restores the exact prior settings, expense.deleted, expense.restored, member.added, settings dropped with the account
18_monthly_summary.test.sql private.monthly_summary (balances in group currency, the month's expenses and payments) and the monthly job: who gets one, no duplicates, inbox only while email is off, email opt-out keeps the card, closed accounts
19_expense_entry.test.sql several payers (balances, sum check, v1 keeping them), refunds, the adjustment split, stored items with tax, tip and discount, the default split, RLS on expense_payers and expense_items, departed members, archived groups, recurring payers; same cases as core
22_guest_links.test.sql guest links: creating (expiry, cap, who), revoking, the anon preview and save RPCs, the cap and rate limits, expired, revoked, archived and scheduled links, who can read guests and tokens, removing a guest, best_slots counting guests, and what the preview leaves out
23_payments.test.sql settle_up_v2 (either direction, between others for admins, a picked day in the caller's zone with that day's rate), update_settlement, restore_settlement and who they notify (payments category, push settings), comments on payments, payment_methods RLS and handle rules, private.pairwise_debts with the core cases (several payers, refunds), settle_all, friend_balances, settle_with_friend (all, picked groups, part payments), remind_debt with a note, departed and archived rules
27_placeholder_members.test.sql placeholders (ADR 19): adding, reading (masked email), adding an email already on Potluck, money with and through a claim, payments for a seat, claiming by confirmed email and by link, refusals (wrong email, unconfirmed, already in the group, blocked, removed), departed and archived groups, quiet and muting blocks, group and owner leaving, notifications and the invite email, friend links, add and remove friends, blocks, reports
24_recurring_editing.test.sql every 2 weeks (recurring_step, the job), recurring_next_from with the core cases, update_recurring_expense (who may, payers, adjustment and item splits, future copies only, every error code), departed members (details editable, money and resume refused until they are taken out), archived groups, skip_recurring_expense, make_expense_recurring (no back-charging, payers, items and percentages carried over), the heads-up job (09:00 local from UTC+14 to UTC-12 and UTC+5:30, late but before the post, late at night inbox only, who hears, their share, once per occurrence, paused, switched off, archived), who may edit (anyone involved), notes carried to posted copies, the anchor day kept across interval changes, the caller's own today, the job pausing and reporting a template naming someone who left
25_search_insights.test.sql search_money (who sees what across groups, every kind of match, filters, paging, input errors), deleted_items (order, who deleted, members only), friend_activity (only what involves both people), expense_ledger (shares add up, paid minus share is the balance, several payers, refunds, no cross-group rows), spending_summary (the same flat as the core test, net equals the balance, per viewer, converted into one currency at each date, through a common base, missing rates counted), trigram index use
28_groups.test.sql group type and trip dates (create_group_v2, the dates check, who may change them), deleting that waits 30 days (hidden from every policy and RPC, invites closed and reopened, no new expenses, members or notifications), restore_group by any member, my_deleted_groups, the purge job, cover storage (who uploads and lists, two-step sign in, deleted groups), the whiteboard (who reads and writes, version conflicts, the size cap, one activity line per sitting, archived and deleted groups); deleted groups leaving the timeline, plans, RSVPs, availability and the monthly summary, the delete notice and restore notice, the delete line in the feed, one-on-one tabs that cannot be deleted, covers under their own folder, debts in a circle, guest links closing and reopening, backups and imports leaving deleted groups alone
29_expense_attachments.test.sql expense_attachments and set_expense_attachments (order, five at most, group folder only, uploaded files only, who may, deleted and archived, an edit right after the create logged, a create with files making exactly one entry and nothing when a file is refused), the receipt_path mirror for older clients, storage from other groups, anon and an unfinished two-step session, deleting files by uploader only, the orphan sweep

Every table in public needs tests for both who can and who cannot read and write it. A new RPC needs tests for each error code it can raise.

CI runs supabase start on a fresh runner, which applies all migrations and the seed, then supabase test db. A migration that fails to apply fails the job before any test runs.

End-to-end tests

Playwright lives in apps/web/e2e and runs against a production build served by vite preview on port 4173, talking to the local Supabase stack. CI does the same.

pnpm db:start                                        # once, if the stack is not running
pnpm --filter @potluck/web exec playwright install chromium webkit   # once
pnpm e2e                                             # build, serve on :4173, run every project
pnpm --filter @potluck/web exec playwright test e2e/expenses.spec.ts --project=desktop-chromium
pnpm --filter @potluck/web exec playwright show-report               # after a failure

Locally, a server already listening on :4173 is reused, even if another checkout or worktree started it, so the tests may run another branch's build. If you change app code while one is running, rebuild with VITE_APP_URL=http://localhost:4173 pnpm --filter @potluck/web build or stop it first. E2E_PORT=4174 runs on another port beside it. E2E_WORKERS sets the local worker count (default 2). The local Realtime tenant drops Postgres changes past 100 a second (MessagePerSecondRateLimitReached in docker logs supabase_realtime_potluck), which looks like a live update that never arrives. CI raises it in the e2e job; locally, restart the Realtime container (it reseeds the defaults when it starts) and then run the same update _realtime.tenants from ci.yml as supabase_admin (postgres may not write it), before opening the app.

Projects (apps/web/playwright.config.ts):

Project Device
mobile-chromium 390x844, touch, mobile
desktop-chromium 1440x900
mobile-webkit iPhone sized WebKit, 390x844

Every context runs in UTC with en-US, and service workers are blocked so the PWA cache never serves a stale shell. Failures keep a trace and a screenshot (one per signed in page in multi user tests) in test-results/ and the HTML report in playwright-report/, both git-ignored. CI uploads the report as an artifact.

Where the test data comes from:

  • e2e/global-setup.ts reads the API URL and publishable key from .env.local and the service role key from supabase status -o env at run time (or E2E_SERVICE_ROLE_KEY if set). No key is written to the repository. It also sets today's EUR to USD rate to 1.1 in the local database, which every checkout shares, so manual numbers can change after an e2e run.
  • e2e/support/fixtures.ts gives each test newUser(), which creates a fresh confirmed and onboarded user through the auth admin API, and openAs(user), which opens a new browser context already signed in (the session is placed in local storage, no login form). openAs(null) is a signed out visitor.
  • e2e/support/api.ts sets up groups and expenses through @potluck/api, as those users.

Every test creates its own users and groups with unique names, so tests run in parallel and never depend on seed rows or on each other. Selectors are roles and labels built with t() from @potluck/i18n, never CSS classes: copy changes do not break them, and a missing label does.

Spec Covers
onboarding.spec.ts landing page, sign up, onboarding (name, time zone, currency), skipping the first group step, home greets you by name
groups-invites.spec.ts create a group, create an invite link, a signed out friend opens /join/<code>, signs in and joins, the owner sees them
expenses.spec.ts equal, exact, percent, shares and itemized splits with each person's share on the detail page, editing the amount, delete with Undo, a EUR expense in a USD group showing the converted amount
expense-entry.spec.ts two payers agree across row, detail, balances and CSV; payers that do not add up are refused; adjustment split and edit history; a refund; the two-person shortcut; by item reopening its items; default split and last currency
people.spec.ts add Sam by name and email, split with him, Sam opens his claim link signed out, signs up with that email and finds the group, expense and balance; a friend link makes friends, blocking from the friend page, Unblock on Me; a friend page row shows only what is between the two (not the viewer's whole lead), the menu exports the one-friend spreadsheet, and a block turns Add expense off
balances.spec.ts balances follow an expense, settle up reaches all square ($0.00), activity lists the payment
settle-up.spec.ts "Ben paid me" on a past day and Ben's notification, comment, delete and restore a payment, a saved PayPal.me handle as a prefilled pay button, Remind with a note twice in a day, settling with a friend across three groups
plans.spec.ts create a plan, a second user paints availability with the keyboard, the host's heatmap updates live (realtime), best slot listed, lock it in, RSVP, download the .ics
members.spec.ts promote to admin, a member who owes money cannot be removed, remove a settled member, admins get no owner actions, members get no member actions and no delete button
me.spec.ts theme choice survives a reload, calendar feed link shown, deleting the account signs out and the password stops working
notifications.spec.ts turning Payments push off on the Me page survives a reload; a payment then still lands in the inbox but is not routed to push, while a new expense is
guest-links.spec.ts a member makes a guest link (cap and expiry) and copies it, a signed out guest opens /a/<code>, names themselves and paints, comes back to edit, the host sees them live with a Guest tag; a full link turns a second guest away; an expired link says it has closed
insights.spec.ts search from the app bar finds an expense in every group the viewer is in and nothing else; filtering a group to one person, a month and a category shows the matching total and keeps the filters on the Expenses tab; Totals adds up the viewer's share, a month column picked by keyboard, the table view, restoring a deleted expense and a deleted payment; a group's activity link; activity with a friend; Spending converted into your currency
a11y.spec.ts axe (WCAG 2.1 A and AA) on landing, home, group expenses, add expense, balances, event page, the signed out guest link page, Me, Friends (owing both ways) and the unsubscribe page: fails on serious or critical violations and on any sideways page scroll
account.spec.ts change password (wrong current refused, other devices signed out, a security email sent), sign out everywhere, two-step setup (codes only close with "I saved them") and sign in with a code (wrong code refused, API and Storage refused at aal1), turning it off clears the codes, a recovery code, five wrong recovery tries then a pause, email change through Mailpit links and calling one off, the language switching live (text and amounts) and following the account to a new device
recurring.spec.ts editing a repeating rent changes the series and leaves the posted copy; someone else sees it read only; making an expense repeat every 2 weeks from its page, then skipping the next one
receipts.spec.ts scan the fixture receipt (e2e/fixtures/receipt.png) on the device, check and edit what it read, fill the by-item editor, add a second photo, save, open both full screen; group members can sign the files, an outsider cannot
group-life.spec.ts a trip with dates and a cover that a member and the signed out join page show, the invite as a QR code; notes typed by one member appear live for another and in the activity; a deleted group waits under Recently deleted and a member restores it; the category follows the description ("Uber" is Transport, a description used before keeps its category)

The landing audit emulates reduced motion: the hero demo is decorative and loops fade-ins, so the audit checks its settled state.

Keep e2e to journeys. Anything that can be tested lower in the pyramid should be.

Before you push

pnpm verify      # everything in the CI check job
pnpm verify:db   # everything in the CI db job; run pnpm db:reset first
pnpm e2e         # if you changed a screen or a journey (or one spec, see above)

CI runs the same scripts (scripts/verify.mjs), so a green run locally is a green check job:

Script Steps, in order
pnpm verify prettier --check ., eslint, route tree, agent map and skills copy are up to date, typecheck, test:coverage (thresholds), test:functions, production build with placeholder VITE_* values
pnpm verify:db supabase db lint --fail-on error, supabase test db, supabase/schema/current.sql and packages/api/src/database.types.ts match a fresh generation from the local database

--concurrency=1 passes through to turbo on a small machine, --only=lint,typecheck runs a subset and --list prints the steps. The database checks run against whatever the shared local stack was last reset to (see development.md).