277f459237
Cosmetic follow-up to GRO-2319 (Phase 4 review by CTO). The synthetic
waitlist card on GET /portal/appointments returned service: {id} only,
so the portal fell back to the literal 'Service' label. CMPO spec did
not call for a service name on the waitlist card, but populating the
real name is non-urgent and closes the cosmetic gap.
- src/routes/portal.ts: include a services SELECT (in addition to
pets and staff) covering both appointment and waitlist serviceIds.
serviceMap feeds a service.name lookup. The synthetic waitlist
card's service object is now {id, name} — same shape the
appointments join returns — so the portal renders the real name.
The appointments join also gains a name (consistent shape, no
regression for the existing path).
- src/__tests__/portal.test.ts: mock the services table and assert
service: {id, name} on both the synthetic waitlist card and the
appointment card.
- UAT_PLAYBOOK.md: TC-API-8.20 covering the waitlist card service
name (TC-API-8.19 retained verbatim for the original GRO-2319
surfacing contract).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
458 lines
52 KiB
Markdown
458 lines
52 KiB
Markdown
# 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):
|
||
|
||
```bash
|
||
SUPER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
|
||
-o jsonpath='{.data.super-password}' | base64 -d)
|
||
GROOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
|
||
-o jsonpath='{.data.groomer-password}' | base64 -d)
|
||
TESTER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
|
||
-o jsonpath='{.data.tester-password}' | base64 -d)
|
||
CUSTOMER=$(kubectl get secret seed-uat-passwords -n groombook-uat \
|
||
-o jsonpath='{.data.customer-password}' | base64 -d)
|
||
```
|
||
|
||
**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:
|
||
|
||
```sql
|
||
SELECT user_id, role FROM staff WHERE user_id = '<test-user-id>';
|
||
```
|
||
|
||
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](/GRO/issues/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.13a | Batch geocode — `?limit` cap enforced (GRO-2294) | As manager, `POST /api/clients/geocode-batch?limit=100000` on a DB with un-geocoded clients | 200 OK; the request is **clamped to the documented max of 500** — `processed` ≤ 500 (never the raw 100000). A fractional `?limit` (e.g. `49.9`) is floored to `49`. Confirms a manager cannot hold one synchronous request open / accrue unbounded Google API cost via an oversized limit |
|
||
| 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-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
|
||
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
|
||
| 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 `TRUNCATE`s `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](/GRO/issues/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 |
|
||
| TC-API-8.17 | SSO portal session slides on activity (GRO-2234) | Establish a portal session (TC-API-8.8). Note the returned `sessionId`. Make any authenticated portal call (e.g. `GET /api/portal/me`) several times spaced over ≥1 minute, each with `X-Impersonation-Session-Id: {sessionId}`. | Every call returns 200; the session's `expiresAt` is extended (slid forward to ~30 min from each request) so the session stays valid during continuous use — it does NOT lapse mid-session. SSO-bridge sessions mint with a 30-min idle TTL bounded by an 8h absolute cap from `startedAt`. |
|
||
| TC-API-8.18 | Slow-wizard Book New submit succeeds (GRO-2234) | Establish a portal session (TC-API-8.8). Wait >2 minutes while making at least one intervening authenticated portal call (mimicking the multi-step Book New wizard: pet/service/groomer/date GETs). Then `POST /api/portal/waitlist` with a valid pet+service payload and the same `X-Impersonation-Session-Id`. | 201 Created — the deliberately-paced wizard no longer 401s on submit because activity slid the session forward. (Regression guard for the GRO-2234 "session TTL too short → 401" defect.) |
|
||
| TC-API-8.19 | Portal appointments surface active waitlist entries (GRO-2319) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. In addition to the customer's appointments, the response includes the seeded ACTIVE waitlist entry as a synthetic card: `status: "waitlisted"`, `id` prefixed `waitlist:`, `confirmationStatus: null`, a non-null derived `startTime` (from the entry's preferred date/time), and the entry's `pet`. Cancelled/notified/expired waitlist entries are NOT surfaced. |
|
||
| TC-API-8.20 | Portal waitlist card populates service {id, name} (GRO-2342) | As `uat-customer@groombook.dev`, establish a portal session, then `GET /api/portal/appointments`. | 200 OK. The synthetic `waitlisted` card returned for the active waitlist entry has `service: {id: "<serviceId>", name: "<serviceName>"}` (full service record, not just `{id}`), matching the shape the appointments join returns. The portal Upcoming list therefore renders the actual service name in place of the fallback "Service" label. |
|
||
|
||
### 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. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the projection (GRO-2294, defense-in-depth); non-secret fields (`businessName`, colors, `routeOptimizationProvider`, etc.) are still present |
|
||
| TC-API-13.2 | Update business settings | PATCH /api/admin/settings with updated values | 200 OK, settings updated. Response body **must NOT include `googleMapsApiKey`** — the encrypted secret is redacted from the PATCH response symmetrically with the GET projection (GRO-2299, defense-in-depth); non-secret updated fields are still returned |
|
||
| 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 (GRO-2225 — zero-touch; no manual PATCH/geocoding needed).** A fresh UAT reset+seed now provisions a deterministic route cohort, so §4.16 runs directly against seed data:
|
||
- **Groomer:** `uat-groomer@groombook.dev` (staffId `00000000-0000-0000-0000-000000000004`). Resolve its id via `GET /api/staff` or sign in as the groomer and omit `staffId`.
|
||
- **Date:** `2026-09-15` (fixed). On this date the groomer has **12** confirmed appointments: **10 pre-geocoded** clients clustered in the Seattle metro (multi-stop route) + **2 intentionally un-geocoded** clients (exercise the skip-and-surface path, TC-API-16.4). Cohort clients are named `Route Demo — …` (emails `route-client-NN@uat.groombook.dev`).
|
||
- **Receptionist (TC-API-16.9 403):** sign in as `uat-receptionist@groombook.dev` (password from the `seed-uat-passwords` secret, key `SEED_UAT_RECEPTIONIST_PASSWORD`) — a standing receptionist login; no hand-built session required.
|
||
|
||
| # | 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`) |
|
||
|
||
### 4.18 Route Optimization — Navigation Export (GRO-2157, Phase 2.3)
|
||
|
||
Builds on §4.16/§4.17. Two read-only endpoints turn an optimized route into a native-navigation deep-link URL the frontend opens on the groomer's phone:
|
||
|
||
- `GET /api/routes/:routeId/export/google-maps` → Google Maps URLs API link (`https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=…&destination=…&waypoints=…`)
|
||
- `GET /api/routes/:routeId/export/apple-maps` → Apple Maps URL scheme (`maps://?saddr=…&daddr=<first>+to:<next>…&dirflg=d`)
|
||
|
||
Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = first stop, destination = last stop, the rest are ordered intermediate waypoints**. Each response body is `{ platform, url, stopCount, waypointCount }` where `waypointCount` = stops minus origin and destination. Waypoint limits are validated per platform: **Google Maps ≤ 9**, **Apple Maps ≤ 15** intermediate waypoints; over-limit routes return 400. **Auth: manager (any route) or groomer (own route only); receptionists have no access.**
|
||
|
||
| ID | Scenario | Steps | Expected |
|
||
|----|----------|-------|----------|
|
||
| TC-API-18.1 | Google Maps export of a multi-stop route | As manager, optimize a multi-stop day (§4.16), then `GET /api/routes/{routeId}/export/google-maps` | 200 OK; `platform:"google-maps"`, `url` starts `https://www.google.com/maps/dir/?api=1`, contains `travelmode=driving`, `origin`/`destination` are the first/last stop coords, `waypoints` lists the middle stops in order (pipe-separated). `stopCount` = total stops, `waypointCount` = `stopCount − 2` |
|
||
| TC-API-18.2 | Apple Maps export of a multi-stop route | As manager, `GET /api/routes/{routeId}/export/apple-maps` for the same route | 200 OK; `platform:"apple-maps"`, `url` starts `maps://?saddr=`, `daddr` chains the remaining stops with `+to:`, ends `&dirflg=d`; `stopCount`/`waypointCount` as above |
|
||
| TC-API-18.3 | Single-stop route | Export a route (google-maps and apple-maps) that has exactly one stop | 200 OK; `waypointCount:0`. Google url has `destination` and no `waypoints=`; Apple url is `maps://?daddr=<coord>&dirflg=d` (no `saddr`) |
|
||
| TC-API-18.4 | Empty route rejected | Export a route with no stops (a fresh `draft` route) | 400 `{ error: "route has no stops to export" }` |
|
||
| TC-API-18.5 | Google waypoint limit | Export (google-maps) a route with >11 stops (>9 intermediate waypoints) | 400 with an `error` mentioning Google Maps' limit of 9 |
|
||
| TC-API-18.6 | Apple waypoint limit | Export (apple-maps) a route with >17 stops (>15 intermediate waypoints) | 400 with an `error` mentioning Apple Maps' limit of 15 |
|
||
| TC-API-18.7 | Unknown route | `GET /api/routes/{randomUuid}/export/google-maps` | 404 `{ error: "Route not found" }` |
|
||
| TC-API-18.8 | Invalid routeId | `GET /api/routes/not-a-uuid/export/apple-maps` | 400 `{ error: "routeId must be a UUID" }` |
|
||
| TC-API-18.9 | Groomer exports own route | As **groomer**, export a route owned by self | 200 OK; deep-link returned |
|
||
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
||
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
|
||
|
||
## 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").
|