UAT Playbook — GroomBook API
Overview
GroomBook API is a Hono-based REST service (TypeScript/Node.js) powering the pet grooming management platform. Handles authentication, client/pet management, appointment scheduling, invoicing, payments, staff management, and the customer portal.
Environments
| Environment |
URL |
| Dev |
dev.groombook.dev |
| UAT |
uat.groombook.dev |
| Prod |
demo.groombook.app |
Pre-conditions
- UAT environment accessible and healthy
- Test accounts seeded (manager, staff, client personas)
- OIDC authentication provider configured
- Seed data present (clients, pets, services, staff)
Source of truth for UAT passwords (GRO-2000)
The UAT_SUPER_PASSWORD / UAT_GROOMER_PASSWORD / UAT_TESTER_PASSWORD / UAT_CUSTOMER_PASSWORD env vars the test orchestrator uses must be pulled from the live seed-uat-passwords Secret in the UAT cluster — never from a captured shell value, a previous run's .env, or a copy of the SealedSecret committed before the latest rotation.
Canonical recipe (works from any host with kubectl + cluster credentials):
Why: the Bitnami SealedSecret apps/overlays/uat/ss-seed-uat-passwords.yaml (in groombook/infra) is the single source of truth. The UAT reset-demo-data CronJob re-hashes these values into the account table on every run (idempotent — GRO-1977). A captured env var from a previous generation will not match the current hash, producing 401 INVALID_EMAIL_OR_PASSWORD. If the live login still 401s after pulling from the SealedSecret, the seed Job is stale — trigger kubectl create job --from=cronjob/reset-demo-data -n groombook-uat manual-seed-$$ and retry.
How to apply: at the start of every UAT run that touches TC-API-1.4 / 1.5 / 1.6 / 1.7 / 3.18 / 3.21 / 3.23, refresh these four env vars from the cluster before issuing the sign-in request.
rbac auto-provision for Better-Auth customers (GRO-2052)
Applies to TC-API-3.16 / 3.19a / 3.19b / 3.19c (customer-as-owner profile-summary paths) and any future case where the test user authenticates via Better-Auth email/password and the route relies on resolveStaffMiddleware to resolve a staff row.
Pre-condition (rbac auto-provision): The test user must have a row in the Better-Auth user table (email/password sign-in creates this automatically — see TC-API-1.6 / 1.7). On first authenticated call, resolveStaffMiddleware (./src/middleware/rbac.ts) auto-provisions a groomer staff row keyed by staff.user_id = user.id (Better-Auth branch fires before the legacy OIDC account branch).
Verify the auto-provision fired by querying the DB after the first authenticated call:
Expected: one row, role = 'groomer'. If zero rows return, the request hit the OIDC account branch and 403'd, or the user has no user row — fix the test sign-in path before re-running.
Why this matters: without the auto-provision branch, Better-Auth email/password customers (e.g. uat-customer@groombook.dev) have no account row for the OIDC providers, so resolveStaffMiddleware falls through to 403 "Forbidden: no staff record found for authenticated user" before pets.ts can run the owner-bypass added in GRO-2013. The owner-bypass code is unreachable unless the auto-provision has fired. A green TC-API-3.19a therefore implicitly proves the auto-provision worked; if 3.19a fails with the pre-fix 403, the auto-provision branch is missing from the deployed ./src tree (see GRO-2052).
How to apply: for every run of TC-API-3.16 / 3.19a / 3.19b / 3.19c, sign in via TC-API-1.6 (email+password) first to guarantee the user row exists, then run the profile-summary call, then assert the staff row above before declaring pass.
Test Cases
4.0 Health Check
| # |
Scenario |
Steps |
Expected |
| TC-API-0.1 |
Unauthenticated health check |
GET /api/health |
200 OK, {"status":"ok"} |
Note (GRO-1544): Health endpoint registered on api basePath before auth middleware at /api/health. The old path /health was incorrect (routed to web pod via HTTPRoute /* rule).
4.1 Authentication
| # |
Scenario |
Steps |
Expected |
| TC-API-1.1 |
Login via OIDC |
POST to OIDC provider callback, verify JWT token issued |
200 OK, JWT returned with valid claims |
| TC-API-1.4 |
Email+password login (UAT) |
POST /api/auth/sign-in/email with uat-super@groombook.dev + SEED_UAT_SUPER_PASSWORD |
200 OK, session cookie returned |
| TC-API-1.5 |
Email+password login — groomer |
POST /api/auth/sign-in/email with uat-groomer@groombook.dev + SEED_UAT_GROOMER_PASSWORD |
200 OK, session cookie returned |
| TC-API-1.6 |
Email+password login — customer |
POST /api/auth/sign-in/email with uat-customer@groombook.dev + SEED_UAT_CUSTOMER_PASSWORD |
200 OK, session cookie returned |
| TC-API-1.7 |
Email+password login — tester |
POST /api/auth/sign-in/email with uat-tester@groombook.dev + SEED_UAT_TESTER_PASSWORD |
200 OK, session cookie returned |
| TC-API-1.8 |
Email+password — invalid password |
POST /api/auth/sign-in/email with wrong password |
400 Bad Request, error returned |
| TC-API-1.9 |
Email+password — unknown user |
POST /api/auth/sign-in/email with non-existent email |
400 Bad Request, error returned |
| TC-API-1.10 |
Auto-provision on first OIDC login |
First login as a Better-Auth user with no existing staff record |
200 OK, access granted; groomer staff record auto-created with name/email from user table |
Note (GRO-1977): Seed credential provisioning is idempotent — re-running the seed with updated SEED_UAT_*_PASSWORD env vars rotates stored credential hashes. TC-API-1.4 through TC-API-1.7 now return 200 for all 4 UAT personas (previously returned 401 due to frozen-hash bug).
| TC-API-1.11 | Existing staff unaffected by OIDC login | Login as uat-groomer@groombook.dev (email+password), then GET /api/staff to find that record | 200 OK, staff record unchanged — no duplicate created, original role and isSuperUser preserved |
| TC-API-1.12 | Auto-provisioned role and superUser flags | After TC-API-1.10, GET /api/staff and inspect the auto-created record | role = "groomer", isSuperUser = false, active = true |
| TC-API-1.13 | Name fallback — user.name present | Auto-provision where Better-Auth user has name set | Staff name = user.name value from user table |
| TC-API-1.14 | Name fallback — no name, email present | Auto-provision where Better-Auth user has name = null, email = "test@example.com" | Staff name = "test" (email prefix before @) |
| TC-API-1.15 | Name fallback — no name, no email | Auto-provision where Better-Auth user has name = null, email = null | Staff name = "Unknown" |
| TC-API-1.16 | OIDC login — Terraform-provisioned user | Initiate OIDC login as any UAT persona (uat-super, uat-groomer, uat-customer, uat-tester), complete authentik callback | 200 OK, session created — no account_not_linked error |
SSO Login Journey (Authentik OIDC end-to-end)
| # |
Scenario |
Steps |
Pass Criteria |
Fail Criteria |
| TC-API-1.17 |
SSO redirect to Authentik |
Navigate to app → sign-in page shown → click "Sign in with SSO" |
Redirected to Authentik at auth.farh.net |
403 error, redirect loop, no SSO button |
| TC-API-1.18 |
Authenticate with valid OIDC credentials |
At Authentik login page, enter valid credentials and authenticate |
Redirected back to app with valid session |
Redirect loop, 403, missing session cookie |
| TC-API-1.19 |
SSO user auto-provisioned as groomer |
Complete SSO login as a user with no pre-existing staff record |
200 response; groomer staff record auto-created; session active |
403 Forbidden, staff record not created |
| TC-API-1.20 |
Existing staff record resolves correctly |
Complete SSO login as uat-groomer (pre-existing staff) |
200 OK, correct staff identity resolved, no duplicate record created |
403, duplicate record, wrong staff data |
| TC-API-1.21 |
SSO session grants dashboard access |
After TC-API-1.18 SSO login, GET /api/staff/me |
200 OK, valid staff record returned, correct role displayed |
401/403, missing session, wrong identity |
OOBE Flow Post-Login
| # |
Scenario |
Steps |
Pass Criteria |
Fail Criteria |
| TC-API-1.22 |
Fresh DB reports needsSetup |
On a fresh DB (no super user), GET /api/setup/status |
needsSetup: true returned |
needsSetup: false when it should be true |
| TC-API-1.23 |
Configure OIDC via auth-provider endpoint |
POST /api/setup/auth-provider with valid OIDC config |
200 OK, auth provider configured, no 403 |
403, setup blocked, invalid config rejected |
| TC-API-1.24 |
Complete setup creates super user |
POST /api/setup with business name (after TC-API-1.23) |
First user becomes super user, setup completes |
Setup errors, 403 on admin endpoints |
| TC-API-1.25 |
Super user accesses admin features |
After TC-API-1.24, GET /api/staff/me and verify isSuperUser: true |
isSuperUser: true, admin endpoints accessible |
403 on admin, isSuperUser: false |
| TC-API-1.26 |
Auto-provision skipped during OOBE |
During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes |
No duplicate staff, OOBE completes successfully |
Duplicate staff record, 403 before setup, auto-provision interferes with OOBE |
4.2 Client Management
| # |
Scenario |
Steps |
Expected |
| TC-API-2.1 |
List clients |
GET /api/clients |
200 OK, list of active clients returned |
| TC-API-2.2 |
Get client details |
GET /api/clients/{id} |
200 OK, client details returned |
| TC-API-2.3 |
Create client |
POST /api/clients with valid data |
201 Created, client record created |
| TC-API-2.4 |
Update client |
PATCH /api/clients/{id} with updated fields |
200 OK, client updated |
| TC-API-2.5 |
Disable client |
PATCH /api/clients/{id} with status: "disabled" |
200 OK, client marked as disabled |
| TC-API-2.6 |
Delete client |
DELETE /api/clients/{id}?confirm=true |
200 OK, client deleted (if no appointments) |
Client Geocoding — Route Optimization (GRO-2154, Phase 1.3)
Geocoding turns a client's street address into latitude/longitude + geocodedAt. Provider is driven by businessSettings.routeOptimizationProvider (default Nominatim/OpenStreetMap, 1 req/sec; optional Google fallback). All explicit geocode endpoints are manager-only.
| # |
Scenario |
Steps |
Expected |
| TC-API-2.7 |
Geocode single client (success) |
As manager, POST /api/clients/{id}/geocode for a client with a valid, real address (e.g. a seed client) |
200 OK; body { status: "geocoded", latitude, longitude, geocodedAt, formattedAddress, provider }. Subsequent GET /api/clients/{id} shows the same non-null latitude/longitude/geocodedAt persisted |
| TC-API-2.8 |
Geocode single client — no address |
As manager, POST /api/clients/{id}/geocode for a client whose address is null/blank |
422; { status: "no_address", message: "...no address on file..." } (clear, actionable) |
| TC-API-2.9 |
Geocode single client — unresolvable/ambiguous address |
As manager, set a nonsense address (e.g. "asdkjhqweoui 99999") then POST /api/clients/{id}/geocode |
422; { status: "unresolved", message: "Address could not be resolved..." } so groomers/managers know to correct it |
| TC-API-2.10 |
Geocode single client — not found |
As manager, POST /api/clients/00000000-0000-0000-0000-000000000000/geocode |
404 { error: "Not found" } |
| TC-API-2.11 |
Geocode endpoint is manager-only |
As groomer or receptionist, POST /api/clients/{id}/geocode |
403 Forbidden (role not permitted) |
| TC-API-2.12 |
Batch geocode un-geocoded clients |
As manager, POST /api/clients/geocode-batch?limit=10 on a DB with un-geocoded clients |
200 OK; body { provider, processed, geocoded, unresolved, errors, remaining, outcomes[] }. processed ≤ 10; remaining reflects un-geocoded clients beyond this batch. Re-run while remaining > 0 to finish (throttled to provider rate limit) |
| TC-API-2.13 |
Batch geocode — invalid limit |
As manager, POST /api/clients/geocode-batch?limit=0 (or non-numeric) |
400 { error: "limit must be a positive integer" } |
| TC-API-2.14 |
Batch geocode — manager-only |
As groomer/receptionist, POST /api/clients/geocode-batch |
403 Forbidden |
| TC-API-2.15 |
Auto-geocode on create |
As manager/receptionist, POST /api/clients with a valid address |
201 Created; response includes a geocoding object (status: "geocoded" for a resolvable address) and the persisted client carries latitude/longitude/geocodedAt. Creating without an address succeeds with no geocoding field |
| TC-API-2.16 |
Auto-geocode on address update |
As manager/receptionist, PATCH /api/clients/{id} changing address to a new valid value |
200 OK; response includes a geocoding object and refreshed coordinates. Patching unrelated fields (e.g. name) does NOT re-geocode (no geocoding field) |
| TC-API-2.17 |
Clearing address drops coordinates |
As manager/receptionist, PATCH /api/clients/{id} with address: "" |
200 OK; latitude/longitude/geocodedAt reset to null (no stale pin) |
4.3 Pet Management
| # |
Scenario |
Steps |
Expected |
| TC-API-3.1 |
List pets |
GET /api/pets |
200 OK, list of pets returned |
| TC-API-3.2 |
Get pet details |
GET /api/pets/{id} |
200 OK, pet details including history returned |
| TC-API-3.3 |
Add pet |
POST /api/pets with valid pet data |
201 Created, pet record created |
| TC-API-3.4 |
Update pet |
PATCH /api/pets/{id} with updated fields |
200 OK, pet updated |
| TC-API-3.5 |
Delete pet |
DELETE /api/pets/{id} |
200 OK, pet deleted |
| TC-API-3.6 |
Upload pet photo |
POST /api/pets/{id}/photo/upload-url, then confirm |
200 OK, photo uploaded and key stored |
| TC-API-3.7 |
View pet photo |
GET /api/pets/{id}/photo |
200 OK, presigned URL returned |
| TC-API-3.8 |
Create pet with extended fields |
POST /api/pets with coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts |
201 Created, all extended fields stored and returned |
| TC-API-3.9 |
Update pet extended fields |
PATCH /api/pets/{id} with coatType, temperamentScore, medicalAlerts |
200 OK, extended fields updated |
| TC-API-3.10 |
Reject invalid coatType |
POST /api/pets with coatType: "smooth" |
400 Bad Request, invalid coatType rejected |
| TC-API-3.11 |
Reject out-of-range temperamentScore |
POST /api/pets with temperamentScore: 0 or 6 |
400 Bad Request, score out of range rejected |
| TC-API-3.12 |
Reject invalid medicalAlert severity |
POST /api/pets with medicalAlerts severity: "critical" |
400 Bad Request, invalid severity rejected |
| TC-API-3.13 |
Reject too many temperamentFlags |
POST /api/pets with 21 temperamentFlags |
400 Bad Request, max 20 flags enforced |
| TC-API-3.14 |
Reject too many preferredCuts |
POST /api/pets with 21 preferredCuts |
400 Bad Request, max 20 cuts enforced |
| TC-API-3.15 |
Reject too many medicalAlerts |
POST /api/pets with 51 medicalAlerts |
400 Bad Request, max 50 alerts enforced |
| TC-API-3.16 |
Get pet profile summary |
GET /api/pets/{id}/profile-summary |
200 OK, aggregated profile with grooming history, visit count, upcoming appointment |
| TC-API-3.17 |
Get pet profile summary — groomer restricted |
GET /api/pets/{id}/profile-summary as groomer with no pet linkage |
403 Forbidden |
| TC-API-3.18 |
Get pet profile summary — visitCount returns full count |
GET /api/pets/{id}/profile-summary with 2+ completed appointments |
visitCount >= 2 (not capped at 1) |
| TC-API-3.19 |
Get pet profile summary — upcomingAppointment excludes past |
GET /api/pets/{id}/profile-summary with a past confirmed/scheduled appointment |
upcomingAppointment is null (past appointments filtered by startTime >= now) |
| TC-API-3.19a |
Get pet profile summary — customer owner-bypass (GRO-2013) |
Sign in as uat-customer@groombook.dev; POST /api/portal/session-from-auth; then GET /api/pets/{ownPetId}/profile-summary with header X-Impersonation-Session-Id: {sessionId} for either of the customer's seeded pets (c0000001-0000-0000-0000-000000000002 UAT Pup Alpha, c0000001-0000-0000-0000-000000000003 UAT Pup Beta) |
200 OK, aggregated profile returned (owner-bypass: customer with valid portal session for pet's clientId is allowed even though rbac.ts auto-provisions them as a groomer staff row with no appointment linkage) |
| TC-API-3.19b |
Get pet profile summary — customer cross-tenant blocked (GRO-2013) |
Sign in as uat-customer@groombook.dev; reuse the customer's sessionId from TC-API-3.19a; GET /api/pets/{otherClientPetId}/profile-summary for a pet owned by a different client (c0000002-... or any non-customer pet) |
403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
| TC-API-3.19c |
Get pet profile summary — customer without portal session header |
Same as TC-API-3.19a but omit the X-Impersonation-Session-Id header |
403 Forbidden (no owner-bypass without valid portal session) |
| TC-API-3.19d |
Get pet profile summary — owner-bypass writes audit row (GRO-2063) |
Same setup as TC-API-3.19a (sign in as uat-customer@groombook.dev, establish a portal session for the customer's own clientId, call GET /api/pets/{ownPetId}/profile-summary with X-Impersonation-Session-Id: {sessionId} and a 200 OK response). Then call GET /api/impersonation/sessions/{sessionId}/audit-log and confirm there is exactly one entry with action === "read_profile_summary", pageVisited matching the profile-summary path, and metadata containing petId and actorStaffId for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new read_profile_summary row was written for the cross-tenant attempt. |
200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
| TC-API-3.29 |
Get pet profile summary — unknown UUID returns 404 (GRO-2014) |
GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) |
404 Not Found with body {"error":"Not found"} (was empty-body 500 in GRO-2014) |
| TC-API-3.30 |
Get pet profile summary — malformed UUID returns 404 (GRO-2014) |
GET /api/pets/not-a-uuid/profile-summary while authenticated |
404 Not Found with body {"error":"Not found"} (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
| TC-API-3.31 |
Get pet profile summary — never empty-body 500 (GRO-2014) |
GET /api/pets/{anyId}/profile-summary across the test sweep |
No response has status 500 with an empty body. Any 500 must include a JSON body {"error":"Internal Server Error"} |
Seed Data Verification (GRO-1898)
As of PR #98, UAT seed data populates all 5 extended profile fields for every pet, including the 5 deterministic UAT test client pets (Alpha, Bravo, Charlie, Delta, Echo). This enables manual verification of extended profile rendering without requiring a DB reset.
| # |
Scenario |
Steps |
Expected |
| TC-API-3.20 |
GET /api/clients returns seed data |
GET /api/clients |
200 OK, array with 1+ clients (UAT seed creates 500 + 5 deterministic UAT clients) |
| TC-API-3.21 |
GET /api/pets/{id} returns extended fields for seed pet |
Pick any pet ID from UAT test clients (uat-alpha through uat-echo pet names: TestBuddy, TestMax, TestCooper, TestRocky, TestDuke) and GET /api/pets/{id} |
200 OK; coatType, temperamentScore, temperamentFlags, medicalAlerts, preferredCuts all non-null |
| TC-API-3.22 |
Verify medicalAlerts shape |
GET /api/pets/{id} for any pet with non-empty medicalAlerts |
medicalAlerts is an array; each entry has type, description, severity |
| TC-API-3.23 |
Verify UAT test pet Charlie has behavioral alert |
GET /api/pets/{id} where name = "TestCooper" (pet for uat-charlie@groombook.dev) |
medicalAlerts includes an entry with type: "behavioral", severity: "low" or "high" |
| TC-API-3.24 |
Verify UAT test pet Delta has skin alert |
GET /api/pets/{id} where name = "TestRocky" (pet for uat-delta@groombook.dev) |
medicalAlerts includes an entry with type: "skin" |
| TC-API-3.25 |
Verify 30+ total pets in UAT DB |
GET /api/pets then count total |
30+ pets returned (UAT seed creates 500 random-pool + 5 UAT test clients + 2 UAT customer = 507 total) |
| TC-API-3.26 |
Verify 25-35% medicalAlerts distribution |
GET /api/pets (first 30 pets), count how many have non-empty medicalAlerts |
Ratio is 25-35% (seed uses rand() < 0.3 for ~30% distribution) |
| TC-API-3.27 |
Verify coat_type enum has all seed values |
After UAT seed completes, inspect the coat_type enum on the UAT DB — it must contain: short, medium, long, double, wire, silky, curly, hairless |
UAT seed jobs (reset-demo-data, seed-test-data) complete 1/1 with no enum_in error; coat_type includes all 8 values used by seed.ts coatTypePool |
| TC-API-3.28 |
Verify pet_size_category enum has all seed values |
After UAT seed completes, inspect the pet_size_category enum on the UAT DB — it must contain: small, medium, large, extra_large |
UAT seed jobs (reset-demo-data, seed-test-data) complete 1/1 with no enum_in error; pet_size_category includes all 4 values used by seed.ts petSizeCategoryPool (regression for GRO-1999, mirrors TC-API-3.27) |
| TC-API-3.29 |
Verify reset-demo-data CronJob does not fail with FK 23503 on invoice_tip_splits (GRO-2123) |
Trigger the CronJob manually: kubectl create job --from=cronjob/reset-demo-data verify-gro2123 -n groombook-uat. Wait for pod to terminate. Inspect logs: kubectl logs -n groombook-uat -l job-name=verify-gro2123 |
Pod reaches Completed state; logs show ✓ Acquired seed advisory lock and ✓ Released seed advisory lock from seed.ts; no PostgresError: … violates foreign key constraint "invoice_tip_splits_invoice_id_invoices_id_fk" (code 23503); final counts unchanged (500 clients, ~4000 invoices) |
4.4 Appointment Scheduling
| # |
Scenario |
Steps |
Expected |
| TC-API-4.1 |
List appointments |
GET /api/appointments |
200 OK, list of appointments returned |
| TC-API-4.2 |
Get appointment details |
GET /api/appointments/{id} |
200 OK, appointment details returned |
| TC-API-4.3 |
Create single appointment |
POST /api/appointments with valid data |
201 Created, appointment created |
| TC-API-4.4 |
Create recurring appointment |
POST /api/appointments with recurrence object |
201 Created, series of appointments created |
| TC-API-4.5 |
Update appointment |
PATCH /api/appointments/{id} with updated fields |
200 OK, appointment updated |
| TC-API-4.6 |
Reschedule with cascade |
PATCH /api/appointments/{id} with cascadeMode: "this_and_future" |
200 OK, future appointments updated |
| TC-API-4.7 |
Cancel appointment |
DELETE /api/appointments/{id} |
200 OK, appointment marked as cancelled |
| TC-API-4.8 |
Confirm appointment |
POST /api/appointments/{id}/confirm |
200 OK, confirmation status set to confirmed |
| TC-API-4.9 |
Cancel confirmation |
POST /api/appointments/{id}/cancel |
200 OK, confirmation cancelled |
| TC-API-4.10 |
Conflict detection |
POST /api/appointments with conflicting time |
409 Conflict, error message returned |
4.5 Services
| # |
Scenario |
Steps |
Expected |
| TC-API-5.1 |
List services |
GET /api/services |
200 OK, list of active services returned |
| TC-API-5.2 |
Get service details |
GET /api/services/{id} |
200 OK, service details returned |
| TC-API-5.3 |
Create service |
POST /api/services with valid data |
201 Created, service created |
| TC-API-5.4 |
Update service |
PATCH /api/services/{id} with updated fields |
200 OK, service updated |
| TC-API-5.5 |
Delete service |
DELETE /api/services/{id} |
200 OK, service deleted |
4.5.1 Seed/Reset idempotency (GRO-2064)
Services seeding is now keyed on the deterministic services.id (not name) and
the reset path now TRUNCATEs services alongside the other dynamic tables.
This means:
- Running the seed Job twice in a row (no reset in between) converges to the
same catalogue — no
services_pkey collision.
- A
pnpm reset followed by pnpm seed (or a CronJob reset fire) leaves the
catalogue exactly matching servicesDef (10 rows, ids b0000001-…-001 …
…-00a), regardless of any stale rows that were present beforehand.
- Mixed
seedKnownUsers + full seed() invocations are safe — the
demoSvcs subset (Bath & Brush, Full Groom Small/Medium, Nail Trim) is
keyed on ids …-001, …-002, …-003, …-005 and the upsert target
is services.id, so the same-id / different-name collision that broke
GRO-2033 (id …-004 = "Nail Trim" vs servicesDef …-004 =
"Full Groom — Large") cannot recur.
UAT regression (verify after a new image is rolled out):
| # |
Scenario |
Steps |
Expected |
| TC-SEED-1 |
Reset → seed converges |
kubectl -n groombook exec deploy/api -- pnpm reset && pnpm seed |
Seed completes 1/1, services count = 10, all ids match servicesDef |
| TC-SEED-2 |
Idempotent re-seed |
Re-run pnpm seed without reset |
Seed completes 1/1, no services_pkey errors, services count still 10 |
| TC-SEED-3 |
Catalogue matches servicesDef |
psql -c "SELECT id, name FROM services ORDER BY id" |
Rows …-001……-00a with names "Bath & Brush"…"Sanitary Trim" exactly as in servicesDef |
| TC-SEED-4 |
Demo subset coexists |
Run seedKnownUsers then full seed |
No collision, demo subset (4 services) ends up with the same rows the full seed would write |
4.6 Staff Management
| # |
Scenario |
Steps |
Expected |
| TC-API-6.1 |
List staff |
GET /api/staff |
200 OK, list of active staff returned |
| TC-API-6.2 |
Get staff details |
GET /api/staff/{id} |
200 OK, staff details returned |
| TC-API-6.3 |
Create staff |
POST /api/staff with valid data |
201 Created, staff created |
| TC-API-6.4 |
Update staff |
PATCH /api/staff/{id} with updated fields |
200 OK, staff updated |
| TC-API-6.5 |
Delete staff |
DELETE /api/staff/{id} |
200 OK, staff deleted (if no appointments) |
| TC-API-6.6 |
RBAC check |
Access manager-only endpoint as groomer |
403 Forbidden, error message returned |
4.7 Invoicing & Payments
| # |
Scenario |
Steps |
Expected |
| TC-API-7.1 |
List invoices |
GET /api/invoices |
200 OK, list of invoices returned |
| TC-API-7.2 |
Get invoice details |
GET /api/invoices/{id} |
200 OK, invoice with line items returned |
| TC-API-7.3 |
Create invoice |
POST /api/invoices with line items |
201 Created, invoice created |
| TC-API-7.4 |
Create from appointment |
POST /api/invoices/from-appointment/{appointmentId} |
201 Created, invoice created from appointment |
| TC-API-7.5 |
Update invoice |
PATCH /api/invoices/{id} with status and payment method |
200 OK, invoice updated |
| TC-API-7.6 |
Process payment via Stripe |
POST /api/invoices/{id}/pay with Stripe data |
200 OK, payment intent created |
| TC-API-7.7 |
Save tip splits |
POST /api/invoices/{id}/tip-splits with splits array |
201 Created, tip splits saved |
| TC-API-7.8 |
Process refund |
POST /api/invoices/{id}/refund with amount |
200 OK, refund processed |
4.8 Customer Portal
| # |
Scenario |
Steps |
Expected |
| TC-API-8.1 |
Access portal |
GET /api/portal/me with valid session token |
200 OK, client profile returned |
| TC-API-8.2 |
View portal appointments |
GET /api/portal/appointments |
200 OK, list of client's appointments returned |
| TC-API-8.3 |
Confirm appointment via portal |
POST /api/portal/appointments/{id}/confirm |
200 OK, appointment confirmed |
| TC-API-8.4 |
Cancel appointment via portal |
POST /api/portal/appointments/{id}/cancel |
200 OK, appointment cancelled |
| TC-API-8.5 |
Add waitlist entry |
POST /api/portal/waitlist with pet and service |
201 Created, waitlist entry created |
| TC-API-8.6 |
View portal invoices |
GET /api/portal/invoices |
200 OK, list of client's invoices returned |
| TC-API-8.7 |
Pay multiple invoices |
POST /api/portal/invoices/pay-multiple with invoice IDs |
200 OK, payment intent created |
| TC-API-8.8 |
SSO bridge — valid Better Auth session |
POST /api/portal/session-from-auth with valid Better Auth session cookie (authenticated SSO user with matching client email) |
201 Created, {sessionId, clientId, clientName} returned |
| TC-API-8.9 |
SSO bridge — no Better Auth session |
POST /api/portal/session-from-auth without Better Auth session cookie |
401 Unauthorized |
| TC-API-8.10 |
SSO bridge — no matching client |
POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record |
404 Not Found, error "No client record found for this user" |
| TC-API-8.11 |
SSO bridge — returned session works on portal routes |
After TC-API-8.8, use returned sessionId as X-Impersonation-Session-Id header on GET /api/portal/me |
200 OK, client profile returned |
| TC-API-8.12 |
Portal GET pets returns extended fields (GRO-2187) |
Establish a portal session (TC-API-8.8), then GET /api/portal/pets with X-Impersonation-Session-Id |
200 OK; each pet includes coatType, petSizeCategory, healthAlerts, preferredCuts, medicalAlerts (in addition to id/name/breed/weight/birthDate/photoUrl/notes) |
| TC-API-8.13 |
Portal pet update — owner success + persistence (GRO-2187, fixes GRO-1480 §5.23) |
With a portal session for the pet's owner, PATCH /api/portal/pets/{petId} with body { "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] } |
200 OK; response reflects the update with petSizeCategory: "extra_large" (web xlarge → DB extra_large). A follow-up GET /api/portal/pets shows the persisted values |
| TC-API-8.14 |
Portal pet update — non-owner blocked (GRO-2187) |
PATCH /api/portal/pets/{petId} for a pet owned by a different client, using another client's portal session |
403 Forbidden (or 404 if pet id is unknown); no mutation persisted |
| TC-API-8.15 |
Portal pet update — invalid enum rejected (GRO-2187) |
PATCH /api/portal/pets/{petId} with coatType: "fluffy" or petSizeCategory: "gigantic" |
422 Unprocessable Entity; pet unchanged |
| TC-API-8.16 |
Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) |
With a valid portal session, PATCH /api/portal/pets/not-a-uuid with header X-Impersonation-Session-Id and body {"coatType":"short"} |
404 Not Found with body {"error":"Not found"} (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
4.9 Waitlist
| # |
Scenario |
Steps |
Expected |
| TC-API-9.1 |
List waitlist |
GET /api/waitlist |
200 OK, list of waitlist entries returned |
| TC-API-9.2 |
Add to waitlist |
POST /api/waitlist with client, pet, service |
201 Created, entry added |
| TC-API-9.3 |
Promote from waitlist |
Create appointment from waitlist entry |
201 Created, appointment created, waitlist updated |
4.10 Search
| # |
Scenario |
Steps |
Expected |
| TC-API-10.1 |
Global search clients |
GET /api/search?q={client_name} |
200 OK, matching clients returned |
| TC-API-10.2 |
Global search pets |
GET /api/search?q={pet_name} |
200 OK, matching pets with owners returned |
| TC-API-10.3 |
Search by email |
GET /api/search?q={email} |
200 OK, matching client returned |
| TC-API-10.4 |
Search by phone |
GET /api/search?q={phone} |
200 OK, matching client returned |
4.11 Reports
| # |
Scenario |
Steps |
Expected |
| TC-API-11.1 |
Revenue summary |
GET /api/reports/summary?from={date}&to={date} |
200 OK, revenue KPIs returned |
| TC-API-11.2 |
Revenue by period |
GET /api/reports/revenue?groupBy=day |
200 OK, daily revenue breakdown returned |
| TC-API-11.3 |
Appointment analytics |
GET /api/reports/appointments |
200 OK, appointment stats returned |
| TC-API-11.4 |
Service popularity |
GET /api/reports/services |
200 OK, service usage stats returned |
| TC-API-11.5 |
Client retention |
GET /api/reports/clients |
200 OK, new/returning/churn client data returned |
| TC-API-11.6 |
Tip splits report |
GET /api/reports/tip-splits |
200 OK, tip earnings per staff returned |
| TC-API-11.7 |
Export revenue CSV |
GET /api/reports/export.csv?type=revenue |
200 OK, CSV file downloaded |
4.12 Impersonation
| # |
Scenario |
Steps |
Expected |
| TC-API-12.1 |
Start impersonation session |
POST /api/impersonation/sessions with clientId |
201 Created, session token returned |
| TC-API-12.2 |
Get session details |
GET /api/impersonation/sessions/{id} |
200 OK, session details returned |
| TC-API-12.3 |
Extend session |
POST /api/impersonation/sessions/{id}/extend |
200 OK, session expiry extended |
| TC-API-12.4 |
End session |
POST /api/impersonation/sessions/{id}/end |
200 OK, session marked as ended |
| TC-API-12.5 |
Log audit entry |
POST /api/impersonation/sessions/{id}/log |
201 Created, audit log entry created |
| TC-API-12.6 |
View audit log |
GET /api/impersonation/sessions/{id}/audit-log |
200 OK, audit trail returned |
4.13 Settings & Setup
| # |
Scenario |
Steps |
Expected |
| TC-API-13.1 |
Get business settings |
GET /api/admin/settings |
200 OK, business settings returned |
| TC-API-13.2 |
Update business settings |
PATCH /api/admin/settings with updated values |
200 OK, settings updated |
| TC-API-13.3 |
Upload logo |
POST /api/admin/settings/logo/upload with file |
200 OK, logo uploaded and stored |
| TC-API-13.4 |
View logo |
GET /api/admin/settings/logo |
200 OK, logo image returned |
| TC-API-13.5 |
Delete logo |
DELETE /api/admin/settings/logo |
200 OK, logo removed |
| TC-API-13.6 |
Check setup status |
GET /api/setup/status |
200 OK, setup needs returned |
| TC-API-13.7 |
Complete setup |
POST /api/setup with business name |
201 Created, super user created |
| TC-API-13.8 |
Configure auth provider |
POST /api/setup/auth-provider with OIDC config |
201 Created, auth provider configured |
| TC-API-13.9 |
Test auth provider |
POST /api/setup/auth-provider/test with issuer URL |
200 OK, OIDC discovery successful |
4.14 Appointment Groups
| # |
Scenario |
Steps |
Expected |
| TC-API-14.1 |
List appointment groups |
GET /api/appointment-groups |
200 OK, list of groups returned |
| TC-API-14.2 |
Get group details |
GET /api/appointment-groups/{id} |
200 OK, group with appointments returned |
| TC-API-14.3 |
Create group booking |
POST /api/appointment-groups with client and pets |
201 Created, group and appointments created |
| TC-API-14.4 |
Update group notes |
PATCH /api/appointment-groups/{id} with notes |
200 OK, notes updated |
| TC-API-14.5 |
Cancel group |
DELETE /api/appointment-groups/{id} |
200 OK, all appointments cancelled |
4.15 Buffer Rules
| # |
Scenario |
Steps |
Expected |
| TC-API-15.1 |
List buffer rules |
GET /api/admin/buffer-rules |
200 OK, list of active buffer rules returned |
| TC-API-15.2 |
Create buffer rule |
POST /api/admin/buffer-rules with service, species, sizeCategory, bufferMinutes |
201 Created, buffer rule created |
| TC-API-15.3 |
Update buffer rule |
PATCH /api/admin/buffer-rules/{id} with updated bufferMinutes |
200 OK, buffer rule updated |
| TC-API-15.4 |
Delete buffer rule |
DELETE /api/admin/buffer-rules/{id} |
200 OK, buffer rule removed |
| TC-API-15.5 |
Reject invalid bufferMinutes |
POST /api/admin/buffer-rules with bufferMinutes: -5 |
400 Bad Request, invalid bufferMinutes rejected |
| TC-API-15.6 |
Reject missing required fields |
POST /api/admin/buffer-rules with service only |
400 Bad Request, species and sizeCategory required |
| TC-API-15.7 |
Booking uses buffer |
Book appointment for pet with sizeCategory; verify duration reflects buffer |
201 Created, appointment duration includes buffer time |
4.16 Route Optimization — Route CRUD + Optimize (GRO-2155, Phase 2.1)
A groomer's daily route is one row per (staffId, routeDate) in groomer_routes, with ordered route_stops. POST /api/routes/optimize pulls the day's non-cancelled appointments whose client is geocoded (GRO-2154), orders them (Google Directions optimizeWaypoints when a key is configured in businessSettings.googleMapsApiKey, else an offline nearest-neighbor heuristic), and persists stopOrder, travelMinsFromPrev, travelDistanceKmFromPrev plus route totalTravelMins/totalDistanceKm/optimizedAt. Auth: manager (any groomer's route) or groomer (own route only); receptionists have no access. Pre-condition: at least one geocoded client with appointments on the target date for the staff member (use §4.2 geocoding + a seed groomer).
| # |
Scenario |
Steps |
Expected |
| TC-API-16.1 |
Fetch daily route (auto-create draft) |
As manager, GET /api/routes/daily?staffId={groomerId}&date=YYYY-MM-DD for a date with no existing route |
200 OK; body { route, stops }. route.status is "draft", route.staffId/routeDate match, stops is []. Re-calling returns the same route row (no duplicate) |
| TC-API-16.2 |
Optimize a multi-stop day |
As manager, with ≥2 geocoded appointments for the groomer on the date, POST /api/routes/optimize body { "staffId": "{groomerId}", "date": "YYYY-MM-DD" } |
200 OK; route.status: "optimized", optimizedAt set, totalTravelMins/totalDistanceKm populated. stops ordered by stopOrder (1..N); first stop has travelMinsFromPrev: null, the rest positive. provider is "nearest_neighbor" (no Google key in UAT). The first stop carries bufferMins: 0 (no predecessor); every later stop carries bufferMins = businessSettings.defaultTravelBufferMins (default 15). Response also includes hasConflicts / conflictCount and each stop a conflict object (GRO-2156, see §4.17) |
| TC-API-16.3 |
Re-optimize replaces prior order |
As manager, run TC-API-16.2 twice |
Second call returns 200; stops fully replaced (no duplicate route_stops, stopOrder still contiguous 1..N), optimizedAt refreshed |
| TC-API-16.4 |
Skips un-geocoded appointments |
As manager, optimize a day where one appointment's client has no coordinates |
200 OK; that appointment is absent from stops and listed under skipped[] with reason: "client address is not geocoded"; a corresponding entry appears in warnings[] |
| TC-API-16.5 |
Empty / single-stop day |
As manager, optimize a date with 0 (or 1) geocoded appointments |
200 OK; route.status: "optimized", totalTravelMins: 0, totalDistanceKm: "0.00". For 1 stop, stops has one entry with travelMinsFromPrev: null |
| TC-API-16.6 |
>25 stops chunked with warning |
As manager, optimize a day with >25 geocoded appointments |
200 OK; chunked: true, subRouteCount ≥ 2, a warnings[] entry mentions sub-routes; all appointments appear exactly once with contiguous stopOrder |
| TC-API-16.7 |
Groomer reads own route |
As groomer, GET /api/routes/daily?date=YYYY-MM-DD (omit staffId, or pass own id) |
200 OK; route resolves to the groomer's own staffId |
| TC-API-16.8 |
Groomer cannot access another's route |
As groomer, GET /api/routes/daily?staffId={otherGroomerId}&date=... or POST /api/routes/optimize with another staffId |
403 Forbidden (groomers may only access their own route) |
| TC-API-16.9 |
Receptionist denied |
As receptionist, GET /api/routes/daily?... or POST /api/routes/optimize |
403 Forbidden (role not permitted) |
| TC-API-16.10 |
Manager must supply staffId |
As manager, POST /api/routes/optimize body { "date": "YYYY-MM-DD" } (no staffId) |
400 { error: "staffId is required" } |
| TC-API-16.11 |
Invalid date rejected |
GET /api/routes/daily?staffId=...&date=06-08-2026 (wrong format) |
400 validation error (date must be YYYY-MM-DD) |
4.17 Route Optimization — Travel Buffer + Reorder (GRO-2156, Phase 2.2)
Builds on §4.16. After optimization each consecutive leg carries a travel bufferMins (= businessSettings.defaultTravelBufferMins, default 15; the first stop is 0). The API derives a per-stop conflict object at read time on GET /api/routes/daily, POST /api/routes/optimize, and PATCH /api/routes/:routeId/reorder:
conflict.scheduleGapMins — minutes between the previous appointment's endTime and this appointment's startTime (null for the first stop)
conflict.requiredGapMins — travelMinsFromPrev + bufferMins (null for the first stop)
conflict.shortfallMins — requiredGapMins − scheduleGapMins (positive ⇒ tight)
conflict.hasConflict — true when shortfallMins > 0 ("tight schedule"); appointments are never auto-moved, only flagged
PATCH /api/routes/:routeId/reorder accepts { "stopOrder": ["<routeStopId>", …] } (every current stop id, exactly once, first-to-last), persists the new stopOrder, re-estimates each leg's travel offline for the new adjacency, re-applies buffers, recomputes route totals, and returns the route with refreshed conflict flags. Auth: manager (any route) or groomer (own route only).
| ID |
Scenario |
Steps |
Expected |
| TC-API-17.1 |
Conflict flags on optimize |
As manager, optimize a day with ≥2 geocoded appointments whose times are close together |
200 OK; top-level hasConflicts (bool) + conflictCount (int). First stop conflict.hasConflict:false with null gap fields. A later stop whose scheduleGapMins < travelMinsFromPrev + bufferMins has conflict.hasConflict:true and positive shortfallMins |
| TC-API-17.2 |
No false conflict on a roomy schedule |
Optimize a day where appointment gaps comfortably exceed travel + buffer |
200 OK; hasConflicts:false, conflictCount:0, every conflict.shortfallMins ≤ 0 |
| TC-API-17.3 |
Reorder persists new order |
As manager, take an optimized route, PATCH /api/routes/{routeId}/reorder with the stop ids in a new order |
200 OK; stops returned in the requested order with contiguous stopOrder 1..N; first stop travelMinsFromPrev:null/bufferMins:0, others recomputed; route.totalTravelMins/totalDistanceKm updated |
| TC-API-17.4 |
Reorder re-flags conflicts |
Reorder so a far-apart pair becomes adjacent |
200 OK; conflict flags recomputed for the new adjacency (hasConflicts/conflictCount reflect the new order) |
| TC-API-17.5 |
Reorder validation — wrong stop set |
PATCH …/reorder with a missing, extra, duplicate, or unknown stop id |
400 with an explanatory error (e.g. "must list every stop exactly once", "unknown stop id", "duplicate stop id") |
| TC-API-17.6 |
Reorder unknown route |
PATCH /api/routes/{randomUuid}/reorder with any body |
404 { error: "Route not found" } |
| TC-API-17.7 |
Reorder invalid routeId |
PATCH /api/routes/not-a-uuid/reorder |
400 { error: "routeId must be a UUID" } |
| TC-API-17.8 |
Groomer cannot reorder another's route |
As groomer, reorder a route owned by a different groomer |
403 Forbidden (groomers may only access their own route) |
Pass/Fail Criteria
Pass:
- All test cases execute without errors
- Expected results match actual results
- No regressions in previously working features
- API responses have correct status codes and data structures
- Authentication and authorization enforced correctly
- Business rules (conflicts, validations) work as expected
Fail:
- Any unexpected result or error
- API returns incorrect status codes
- Data integrity issues
- Authentication/authorization bypass
- Business rules not enforced
- Severity documented with steps to reproduce and screenshot
Update Policy
Any PR that changes user-facing behaviour MUST update this file. Test cases must be added, modified, or removed to reflect the new behaviour. The PR description must reference which playbook section was updated (e.g., "Updated UAT_PLAYBOOK.md §4.4 — new appointment rescheduling flow").