Automated testing¶
What it does¶
The mcc-systems codebase includes an automated test suite that runs on every push to GitHub. It catches regressions mechanically — verifying that existing behaviour still works correctly after every code change, without relying on anyone remembering to check manually.
Current coverage: 727 tests across 72 files — 663 unit tests across 51 files plus 64 integration tests across 21 files.
In plain terms, the test suite automatically checks that:
- Pricing is correct — charge-out rates, superannuation, workers comp, GST, and the full rollup from individual shift → service → opportunity total all produce the right numbers
- Access control works — the right people can see the right pages, and restricted areas are locked to the right user groups
- Duplicating a service clones everything it should — shifts, areas, tasks, and items are all deep-copied without accidentally sharing data between the original and the copy
- Auto-populate works end-to-end — shift areas, default tasks, and default items are pre-filled correctly from area type configurations, with the right filtering by service type. (Site-level auto-populate of areas and items from site type defaults is currently disabled behind feature flags in
siteAreaOperations.ts— the functions and tests are retained.) - Proposals and contracts enforce their rules — locking, sending, unlocking, signing, and the guards that prevent invalid actions (like signing an incomplete contract) all behave correctly
If any of these break after a code change, the tests fail immediately and we know before it reaches production.
The rest of this page lists, file by file, exactly what each test suite covers.
How it works¶
Tests are written with Vitest, a fast test runner that understands the same module system as the apps. Each test file lives beside the source file it tests — for example, pricingCalculations.ts has a pricingCalculations.test.ts right next to it. This makes it obvious which files have test coverage and which don't.
Unit tests run in isolation with mocked Supabase responses (fast, deterministic, no network). Integration tests run against the real dev Supabase branch (slower, but they catch regressions that mocks structurally cannot — foreign-key ordering, array-column filters, cascading deletes).
When tests run¶
- Every push to GitHub (any branch): All 663 unit tests run automatically via
test.yml. Takes a few seconds. If anything fails, GitHub sends a notification. - Every push to master + nightly at 3am AEST + manual trigger: All 64 integration tests (BMS + Edge Function) run via
test-integration.yml. Takes about 15-20 seconds. If anything fails, GitHub sends a notification.
You don't have to remember to run tests — they run themselves. If broken code is pushed, you'll know within a couple of minutes.
BMS unit tests — 257 tests across 26 files¶
Pricing and shift timing (Phase 1 — pure functions)¶
pricingCalculations.test.ts
Why this matters: If these tests fail, quoted prices shown to clients could be wrong — incorrect super, workers comp, or GST calculations would flow through to every proposal and contract. These tests guarantee the pricing math is correct before it reaches Tom or a client.
calculateChargeOutRates verifies the full cost chain: base rate → superannuation → workers comp and payroll tax additive on after-super → long-service-leave levy on base (not after-super) → total direct cost → markup → GST inclusive. Edge cases: zero base rate, zero margin, 100% margin, two-decimal rounding. calculateServicePricing verifies hours-per-visit (4dp rounding), per-visit ex/inc GST (2dp rounding), and annual multiplication by frequency count × periods per year.
deriveShiftTiming.test.ts
Why this matters: The system charges different hourly rates for weekday, after-hours, Saturday, and Sunday shifts. If this logic breaks, a Saturday clean could be costed at the weekday rate — undercharging the client and eating into margin without anyone noticing until the P&L review.
Day-of-week + time-of-day → hourly-rate timing category. Monday business hours → Weekday (biz hours); Monday after hours → Weekday (after hours); Saturday / Sunday → Saturday / Sunday regardless of time-of-day; missing day or invalid combination → null. Plus isWeekendDay for Saturday/Sunday/weekdays/null/empty-string.
route-permissions.test.ts
Why this matters: These tests ensure that users only see pages they're allowed to access. If they fail, a Sales user could reach admin-only pages like org settings, or a regular user could access restricted areas of the BMS.
canAccessRoute for Admin (full access, including restricted routes), Sales (opportunities but not org settings), regular users (unrestricted routes only), UUID-segment normalisation so /sites/<uuid> matches /sites/:id, and unknown paths (deny by default).
opportunityStatusColors.test.ts
Why this matters: Status colours are how Tom visually scans opportunity lists to spot what needs attention. If the colour mapping breaks, statuses could all appear the same colour or show misleading colours, making the list useless at a glance.
getStatusColors returns the configured colours for known statuses, the default red for unknowns, and still returns colours for hidden statuses.
exportCsv.test.ts
Why this matters: When Tom or Glenn exports data to CSV (e.g. for Deb or for a client report), the file needs to be correctly formatted so it opens cleanly in Excel. If these tests fail, exported files could have broken columns, missing quotes, or garbled special characters.
CSV row formatting: header, quoting, escaping, known input rows → expected CSV string.
Opportunity lock and summary (Phase 2a)¶
useOpportunityLock.test.ts
Why this matters: The lock mechanism prevents Tom from accidentally editing a proposal that's already been sent to a client. If these tests fail, a sent proposal could appear editable (or an unsent one could appear locked), leading to either client-visible changes to a live proposal or an inability to edit a draft.
isLocked follows the is_locked column; isSent follows sent_at; isWon matches the Won status ID; a null opportunity id disables the query and returns defaults.
getSummary.test.ts
Why this matters: The opportunity summary is the at-a-glance card that shows status, client, site count, and total value. If it breaks, Tom sees missing or incorrect information when reviewing opportunities, which slows down his workflow and risks miscommunication with clients.
Opportunity summary rendering: status label, status colours, client name, site count, formatted total value, and fall-through text when fields are missing.
Shift and service pricing hooks (Phase 2b)¶
useShiftPricing.test.ts
Why this matters: This hook feeds the per-shift price into the service and opportunity totals. If it breaks, individual shift prices could be wrong — and because everything aggregates upward, every service total and opportunity total downstream would also be wrong. The tests also verify that edge cases (missing data, zero minutes, weekend rates) produce safe results rather than crashing the page.
Fetches cost parameters and the matching hourly rate, then calls calculateChargeOutRates and calculateServicePricing. Missing serviceTypeId, missing frequency, zero shiftCount, zero totalMinutes all produce null pricing. Weekend shifts use the weekend hourly rate. The hook always passes frequencyCount: 1 to the per-shift calculation (each shift row = one occurrence per period — a documented design decision).
useServicePricingAggregated.test.ts
Why this matters: Services can be quoted by shift, area, task, or item — each pulling estimated minutes from a different level of the hierarchy. If this aggregation breaks, the service-level price shown to Tom (and ultimately to the client in the proposal) won't match the sum of the underlying work. These tests verify that every quoting method rolls up correctly.
Branches on the service's quote_by: 'shift' uses shift est_minutes directly; 'area' sums shift_area est_minutes; 'task' sums shift_area_task est_minutes; 'item' sums minutes_per_unit × quantity. Mixed timings across shifts → commonChargeOutRate is null; uniform timings → it's populated. Totals (per-visit, annual ex/inc GST, visits per year) are correct sums.
Opportunity pricing, auto-save, permissions, setup fee (Phase 2c)¶
useOpportunityPricing.test.ts
Why this matters: This is the final aggregation — the total opportunity value that appears on the opportunity page and flows into proposals. If it breaks, Tom sees the wrong total, discounts may not apply, and the proposal sent to the client could show an incorrect price.
Aggregates across services: single service matches shift pricing; multiple services sum; discount applied; services with no shifts contribute zero; serviceAnnualValues keyed by service id; hasPricing only true when total > 0.
useAutoSave.test.ts
Why this matters: Auto-save means Tom doesn't have to click a save button — changes persist as he types. If the debounce or save logic breaks, edits could be silently lost (Tom thinks he saved but didn't) or the system could spam Supabase with redundant updates on every keystroke.
Single field change → 500ms debounce → .update().eq('id', id); rapid changes batch into one update; Supabase error → status 'error'; success cycle 'saving' → 'saved' → 'idle' after 2s; object-valued fields produce a console.warn but still save (documented B2 audit decision); missing id → flush is a no-op.
useServicePermissions.test.ts
Why this matters: These tests ensure that only users with the right role can edit or delete services, and that deletion is only allowed when the opportunity is in an early stage. If they fail, a regular user could delete a service from a sent proposal, or Tom could be locked out of editing a draft.
Admin canEdit: true, Sales canEdit: true, regular users canEdit: false; canDelete: true only when canEdit && status in ['New', 'Revising'].
useSetupFeeAutoSet.test.ts
Why this matters: When Tom adds a C5 service to an opportunity, the account setup fee should auto-populate from the organisation's default. If this breaks, the fee could be missing from proposals (lost revenue) or could override a manual adjustment Tom deliberately made.
C5 service present and org has a default fee → fee is auto-set to the org default; no C5 services → fee returns to 0; user's manual edit disables further auto-setting; first load records the DB value without returning early (documented design decision).
CRUD operations on shift tasks, shift items, and site-area items (Phase 3)¶
shiftTaskOperations.test.ts
Why this matters: These tests protect the add, delete, bulk-delete, and sort-order logic for shift tasks. If they break, tasks could appear in the wrong order after drag-and-drop, fail to delete, or new tasks could be inserted at the wrong position — all of which would confuse Tom when building out a service's scope of work.
removeShiftTask deletes from shift_area_tasks by id; bulkRemoveShiftTasks deletes by id-in-list; backfillTaskSortOrder rewrites null sort_orders to sequential indices (0, 1, 2…) when any are null and is a no-op otherwise; addShiftTask reads the current count and inserts a new row at sort_order: count with the correct shift_area_id, task_description, default_frequency, and a generated UUID.
shiftItemOperations.test.ts
Why this matters: Identical protection to shift tasks, but for shift items (consumables and equipment attached to a shift). If bulk-delete or sort-order backfill breaks, items could disappear unexpectedly or appear in a jumbled order.
Parallel coverage for shift items: removeShiftItem / bulkRemoveShiftItems / backfillItemSortOrder (with-nulls and all-set cases) / addShiftItem (count-then-insert on shift_area_items with site_area_item_id and sort_order: count).
siteAreaItemOperations.test.ts
Why this matters: Site area items are shared across shifts, so deleting them requires a specific order — the child records in shift_area_items must be removed before the parent in site_area_items, or the database rejects the operation. These tests verify that cascading delete order is correct, preventing a foreign-key error that would block Tom from removing items.
bulkRemoveSiteAreaItems verifies the cascading delete order: shift_area_items (by site_area_item_id) is deleted FIRST, then site_area_items (by id) — reversing the order would violate the FK constraint. backfillItemSortOrder rewrites nulls on site_area_items. addSiteAreaItem inserts with site_area_id, item_type_id, name, quantity, and sort_order: count.
Date formatting (Phase 5 — timezone lockdown)¶
dateFormat.test.ts
Why this matters: All dates shown to users — in the BMS, the portal, and in emails — go through these formatting functions. If they break, dates could display in the wrong timezone (showing yesterday's date instead of today's near midnight), use inconsistent formats, or crash on null/undefined input. The midnight boundary tests specifically guard against the UTC-vs-Sydney date-shift bug that the timezone lockdown was built to prevent.
All 7 exported functions (formatDate, formatShortDate, formatLongDate, formatTime, formatDateTime, formatLongDateTime, toDateString) tested with: null/undefined/NaN/invalid inputs → empty string; date-only strings (YYYY-MM-DD) treated as local date; AEST winter formatting; AEDT summer formatting; midnight boundary (UTC date differs from Sydney date); DST transitions (April and October 2026); numeric and Date object inputs; non-Sydney timezone verification (proves the timezone parameter works, not hardcoded).
Realtime subscription correctness (Realtime rollout)¶
useOpportunityActiveAiJobs.test.ts
Why this matters: This hook drives the "AI quote incomplete" placeholder on the opportunity pricing section — Tom needs to see when an AI generation is in flight on any of the opportunity's services so he doesn't act on stale pricing. The hook uses a hand-rolled Realtime subscription on service_ai_jobs filtered by the opportunity's service_ids. If subscription wiring breaks — wrong channel name, wrong filter shape, missed cleanup on unmount — the placeholder either never appears (Tom acts on stale data) or never goes away (Tom can't proceed). These tests lock in the contract.
Six cases: channel mounts with the right name (opportunity-ai-jobs:<oppId>) and filter shape (service_id=in.(<id1>,<id2>)) when both opportunityId and serviceIds are populated; no channel mount when opportunityId is null; no channel mount when the opportunity has no services; the postgres_changes callback invalidates the two-element prefix queryKey (not the three-element full key — the prefix is deliberate so cache freshness propagates across serviceIds changes when a service is added); removeChannel fires on unmount; channel tears down and re-mounts when opportunityId changes mid-life.
Client portal unit tests — 90 tests across 5 files¶
formatters.test.ts
Why this matters: These are the formatting functions that clients see — dollar amounts and truncated text on proposals and contracts. If they break, a client could see a price without the dollar sign or a garbled reference number, which undermines professionalism and trust.
Currency formatting (2dp, $ prefix, negative values) and string truncation.
dateFormat.test.ts
Why this matters: Same coverage as the BMS dateFormat.test.ts — all 7 date formatting functions tested with invalid inputs, timezone boundaries, DST transitions, and non-Sydney timezone verification. Ensures the portal's date utility file stays in sync with the BMS version.
customerDetails.test.ts
Why this matters: The contract page shows the client's legal name, trading name, and ABN/ACN. After signing, these come from a frozen snapshot (so they reflect what was agreed at the time); before signing, they come from live organisation data. If this logic breaks, a signed contract could show updated details that differ from what was actually agreed, or an unsigned contract could show stale or missing details.
Deriving legalName / tradingName / abnAcn from the contract's snapshot when signed, falling back to the organisation's live values when the proposal is unsigned. Handles all combinations of present/missing fields.
uiStatePersistence.test.ts
Why this matters: When a client expands or collapses sections on the portal (e.g. viewing specific services), those choices persist for the session so they don't have to redo them on every page load. If this breaks, the portal feels janky — sections reset every time the client navigates, which is frustrating during a detailed review.
Session-level persistence of UI state (expanded sections, chosen filters) to sessionStorage: set/get round-trip, schema-version gating so stale keys are ignored, corrupted JSON returns the default, and cross-tab independence.
storage.test.ts
Why this matters: This is the low-level wrapper that other portal features use to store data in the browser. If it fails silently (e.g. in private browsing mode), features that depend on it would crash instead of gracefully falling back to defaults.
localStorage wrapper: get/set with JSON serialisation, graceful handling when storage is disabled (private browsing), and key-prefixing to avoid collisions with other apps on the same origin.
Edge Function unit tests — 316 tests across 20 files¶
Shared utilities (Phase 4a)¶
_shared/utils.test.ts
Why this matters: These are shared helpers used across multiple Edge Functions — date formatting, currency formatting, and Airtable image URL resolution. If they break, the impact cascades: dates could display in the wrong timezone, dollar amounts could be malformed, and images in proposals could show broken links.
Date helpers (formatAUDate, formatAUMoney, timezone-aware today-in-Sydney), Airtable attachment URL resolution (prefers thumbnails.large, falls back to full-size URL), and small parsing utilities.
_shared/xero.test.ts
Why this matters: The Xero integration generates invoices. If the token-refresh logic breaks, the system loses its connection to Xero and invoices stop being created — which means Deb doesn't get paid on time and has to chase it manually.
Xero token-refresh flow: posts to the refresh endpoint, handles success vs error responses, persists the new access token and its expiry, and short-circuits when the stored token is still valid.
portal-scope-of-work/helpers.test.ts
Why this matters: The scope of work page shows clients exactly what services they're getting at each site. If the grouping, sorting, or filtering logic breaks, a client could see services listed under the wrong site, missing consumables, or incorrect cleans-per-week numbers — any of which could lead to a dispute after signing.
Scope-of-work data shaping: grouping services by site, sorting within each site, computing per-service cleans-per-week, filtering consumables by accepted state, and producing the JSON shape the portal page expects.
_shared/dateFormat.test.ts
Why this matters: Same coverage as the BMS and portal dateFormat.test.ts — all 7 date formatting functions tested with invalid inputs, timezone boundaries, DST transitions, and non-Sydney timezone verification. Ensures the Edge Function's date utility stays in sync with the frontend versions.
Proposal and contract handlers (Phase 4b)¶
portal-unlock-proposal/helpers.test.ts
Why this matters: When a client requests changes to a proposal, they must provide a meaningful reason (not just "x" or "n/a"). These tests ensure the validation catches junk input while accepting genuine reasons. If it breaks in one direction, clients can unlock proposals without explaining why; in the other direction, legitimate requests get rejected.
isReasonValid: accepts meaningful reasons, including exactly-5-character inputs and whitespace-trimmed inputs; rejects empty, whitespace-only, shorter than 5 characters, punctuation-only, repeated x characters, placeholder text (n/a, none, test).
portal-proposal-index/handler.test.ts
Why this matters: This is the main proposal page the client sees. These tests verify that the right client sees the right proposal in the right state — not-yet-sent proposals are hidden, locked proposals can't be edited, staff previews work correctly, and one client can never see another client's proposal. A failure here could expose confidential pricing to the wrong organisation.
404 when the opportunity is missing; 403 when orgId doesn't match; access granted via the contract_organisation_id fallback when it matches even if organisation_id doesn't; reference formatting (MCC-NNNN zero-padded); notYetSent when sent_at is null; beingEdited when sent but unlocked; both flags false when sent and locked; account-manager shape when present vs null; contractSigned + signedAt for signed contracts; staff view bypasses both display gates and adds a staffView object with proposalStatus (not_yet_sent / sent_locked / unlocked / signed).
portal-contract/handleGet.test.ts
Why this matters: Same security and state logic as the proposal page, but for the contract. These tests verify org-level access control, correct display of contract terms (duration, setup fee, service names), and that signed contracts show the frozen execution data. If the org check fails, a client could see another organisation's contract terms.
BMS path only (legacy Airtable path is frozen and covered by integration tests if needed). 404 when the opportunity is missing; 403 on org mismatch; access via contract_organisation_id fallback; full response shape for a sent-and-locked contract including durationOfAgreement, accountSetupFee, proposalReference, organisation fields; notYetSent with live-services prefix stripping ("C5: Regular clean" → "Regular clean"); beingEdited when sent but unlocked; staff view bypasses both gates; signed contract returns execution data and contractCustomerDetails.
portal-contract/handleSign.test.ts
Why this matters: Contract signing is the most consequential action in the portal — it locks in the agreement. These guard-condition tests ensure a client can't sign with missing fields, sign a contract that's currently being edited, or sign when they haven't accepted any services. Without these guards, an incomplete or invalid contract could be marked as signed.
Guard conditions: missing required fields → 400; contract not found → 404; orgId mismatch → 404; opportunity unlocked (being edited) → 400; no services or consumables accepted → 400. The full post-sign orchestration (opportunity Won, service/consumable status cascades, proposal_events insert, legacy email) is covered by integration tests, not here.
Integration tests — 64 tests across 21 files¶
Integration tests run real code against the dev Supabase branch, using a service-role key that bypasses row-level security.
BMS integration tests — 44 tests across 12 files¶
Each test seeds a small scaffold of records (all prefixed __TEST__ so orphans are easy to spot), exercises the function under test, asserts the resulting database state, then cleans up in foreign-key-safe order.
opportunityServicesDuplicateHandler.integration.test.ts
Why this matters: When Tom duplicates a service (e.g. to create a variation), the entire hierarchy — shifts, areas, tasks, items — needs to clone correctly. If it doesn't, the duplicate could be missing shifts or tasks, or worse, could share references with the original so that editing one silently changes the other.
Duplicating an opportunity's service clones the shift, service-area, shift-area, tasks, and items; the underlying site-area and site-area-items are reused, not cloned, because they belong to the site not the service. The clone copies every other column on the source row verbatim — same site, same notes, same discount, same start/end dates, same visits-per-year and annual values, plus the shift's pricing snapshot columns and the eight ai_* columns on each shift-area — so the duplicate displays correct prices and any AI estimate immediately, without waiting for the next save. The clone's status_id is always set to "Proposed" regardless of the source's status (a duplicate is a fresh draft, not a continuation of a Won/Agreed parent), and sort orders are rewritten so the clone sits immediately after the original in the list. A service with no shifts still duplicates into a new service (with zero children).
shiftAreaOperations.integration.test.ts
Why this matters: Auto-populate is the mechanism that pre-fills a shift's areas, tasks, and items based on the site's configuration and area-type defaults. If it breaks, Tom has to manually add every area and task to every shift — a process that should take seconds but would take minutes per service, across hundreds of services.
autoPopulateShiftAreas: when a shift has no areas, it creates one shift-area per service-area and populates tasks from area_type_default_tasks (filtered by service type) and items from site_area_items (filtered by allowed item types per area-type defaults). Returns empty and is a no-op if the shift already has areas. addSingleShiftArea: adds one area with its tasks and items, slotted at the end.
shiftAreasDuplicateHandler.integration.test.ts
Why this matters: Duplicating a shift area also clones the underlying site area so the copy is independent. If this fails, the duplicate would share the same site area as the original — meaning renaming or modifying one would silently affect the other, with no visible indication to Tom.
Duplicating a shift-area also clones the underlying site-area with a (copy) suffix and creates a new service-area pointing at the cloned site-area, so the duplicate is a genuine new scope item rather than a shared reference. Tasks clone; items keep the same site_area_item_id (the item isn't duplicated, only the junction row). Sort orders: original at 0, clone at 1.
areaOperations.integration.test.ts
Why this matters: Auto-populating service areas links a service to every relevant site area that isn't already linked. If this breaks, new services could miss areas that should be in scope, or the "unlinked area" count could report incorrectly, leaving Tom unaware that areas are missing.
autoPopulateServiceAreas creates service-areas for every site-area not yet linked, filtered to area types whose default tasks are applicable to the service's service type; returns empty when all site-areas are already linked. getUnlinkedSiteAreaCount returns 0 when fully linked.
siteAreaOperations.integration.test.ts
Current state: The site-level auto-populate cascade (site_type → site_areas → site_area_items via autoPopulateFromSiteType and populateItems) is currently disabled behind two feature flags in siteAreaOperations.ts: AUTO_POPULATE_AREAS_ENABLED and AUTO_POPULATE_ITEMS_ENABLED, both set to false. The flags gate four UI trigger points: the auto-populate useEffect in SiteAreas.tsx, the populateItems call in handleAdd, the "Add default areas" button, and the "Add default items" button on SiteAreaEdit.tsx. Re-enabling is a one-line change per flag.
The functions and these integration tests still exist and pass — they test the underlying logic directly, not the UI gates. autoPopulateFromSiteType creates site-areas from site_type_area_types defaults and auto-populates site_area_items via the internal populateItems call (items come from area_type_default_items). Skips area-types already present when passed in existingAreaTypeIds.
opportunityConsumables.integration.test.ts
Why this matters: When Tom adds a consumable to an opportunity in one tab, the consumables count badge on the opportunity page must update in another tab without a refresh. This test asserts both halves of the contract: the main consumables list refreshes (the subscription's own-key invalidation), AND the count cache used by the badge refreshes (the subscription's invalidateKeys fan-out). If either breaks, the UI shows stale state to Tom — either the new consumable doesn't appear, or the count badge doesn't update.
Seeds organisation → opportunity → one consumable; mounts an inline test hook with the production subscription against opportunity_consumables plus a sibling plain count query; INSERTs a second consumable; asserts both main.data.length=2 AND count.data=2 converge within 1.5 seconds. The plain count query (rather than the production useRealtimeQuery shape) is deliberate: it isolates the invalidateKeys propagation path, so a future regression dropping the array would fail this test specifically.
opportunitiesConcurrency.integration.test.ts
Why this matters: When two tabs are open on the same opportunity and one of them changes a field — even via a third source like an external script — both tabs must converge to the new value within ~1 second. This is the cross-tab UX promise of the Realtime rollout. If the broker drops events to one of the subscribed channels, or if either tab's subscription cleanup breaks, one tab is left showing stale data and the user can act on it.
Seeds organisation → opportunity; mounts the production subscription against opportunities twice under two independent QueryClientProviders (representing two browser tabs) with distinct channel names; UPDATEs the opportunity's quote_title via the admin client (external to both tabs); asserts both tabs' caches converge to the new value within 1.6 seconds. The channel-name override is a documented test-only adaptation for the single-process test environment — production's two-tab UX uses two separate SupabaseClient instances which don't share a channel registry.
Edge Function integration tests — 20 tests across 9 files¶
portal-ticket-detail/dateFormatting.integration.test.ts
Why this matters: The unit tests prove the formatting functions produce correct output in isolation. This integration test proves the full deployed pipeline works: getTimezone() reads the timezone from organisation_settings on dev, passes it to the formatting functions, and the response contains correctly formatted dates. If someone breaks the wiring between getTimezone() and the formatting calls, or if a deployment bundles stale shared code, this test catches it.
Calls the real portal-ticket-detail Edge Function on dev with a known Airtable org+ticket. For every timeline event, verifies displayDate === formatDateTime(timestamp, TZ). For the ticket, verifies createdDate === formatLongDate(createdTimestamp, TZ).
Running tests locally¶
From the repo root:
npm run test:bms # BMS unit tests only
npm run test:portal # Client portal unit tests only
npm run test:edge # Edge Function unit tests only
npm run test # All three unit suites
npm run test:integration # BMS integration tests (requires .env.test)
npm run test:integration:edge # Edge Function integration tests (requires .env.test)
The integration suite requires a .env.test file at the repo root with the dev branch's URL and service role key:
SUPABASE_URL=https://euhornpsmtwosgcpxuda.supabase.co
SUPABASE_SERVICE_ROLE_KEY=<dev service role key>
.env.test is gitignored and must never be committed. A template lives at .env.test.example. Integration test files use the .integration.test.ts suffix and a separate vitest config, so unit-test runs never pick them up (and vice versa).
If you're working on a file that has a co-located .test.ts file, run the relevant suite after your changes to make sure nothing broke.
CI¶
test.yml— runs the three unit suites on every push to any branch and on PRs to master. Fast, network-free, deterministic.test-integration.yml— runs BMS and Edge Function integration suites on every push to master, nightly at 3am AEST, and on manual dispatch. Nightly runs catch schema drift on dev (e.g. a reference table being emptied or a column renamed) even when no code has changed. ReadsSUPABASE_DEV_URLandSUPABASE_DEV_SERVICE_ROLE_KEYfrom GitHub Actions secrets into a temporary.env.testbefore running.
Related: weekly documentation review¶
Separate from the test suite, a weekly documentation review runs every Sunday at 4am AEST / 5am AEDT (Saturday 6pm UTC). It is not a test suite — it uses the Claude API to scan reference documentation against current source code for accuracy issues.
It catches a different class of problem: reference docs that have drifted from the code they describe — wrong field names, stale file paths, contradicted statements, and missing file references. If new issues are found, it creates a GitHub Issue with the documentation label.
Full detail: docs/admin/automated-doco-review.md
What's not tested (yet)¶
The current test suite covers unit tests and integration tests. One future phase will extend coverage:
- End-to-end browser tests (Phase 7) — tests using Playwright that click through the actual UI the way a real user would. For example: Tom creates an opportunity, adds services, sends the proposal, a client opens the portal link, reviews the contract, and signs.
Where to learn more¶
The full test plan, including phase details and architectural decisions, lives at docs/plans/automated-test-plan.md in the repo.