Compare commits

..

3 Commits

Author SHA1 Message Date
Flea Flicker 7bdb96631f ci: re-trigger checks (transient Alpine CDN DNS flake in docker build)
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 32s
CI / Build & Push Docker Images (pull_request) Successful in 1m5s
2026-06-08 23:48:02 +00:00
Flea Flicker d06c3b8d31 fix(GRO-2235): return 409 on duplicate portal waitlist submit
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Failing after 48s
An exact duplicate active waitlist entry (same client/pet/service/
preferred date+time) violates the partial unique index
idx_waitlist_active_unique, which postgres-js surfaces as SQLSTATE
23505. The POST /portal/waitlist handler did not catch it, so the
duplicate returned a generic 500. Catch 23505 specifically and return
409 with a friendly message; unrelated errors still surface as 500 and
the first insert still returns 201.

Adds integration coverage: 201 first insert, 409 duplicate, 500 unrelated.
2026-06-08 23:44:15 +00:00
Flea Flicker 27e6674b9a feat(GRO-2225): UAT seed route cohort + receptionist credential (#187)
CI / Test (push) Successful in 30s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 45s
2026-06-08 23:15:51 +00:00
4 changed files with 396 additions and 13 deletions
+6 -3
View File
@@ -165,8 +165,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| 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"}` |
@@ -365,7 +363,12 @@ This means:
### 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).
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 |
|---|----------|-------|----------|
+210
View File
@@ -456,6 +456,36 @@ async function seedUatStaffAccounts(
}
}
// ── Staff: UAT Receptionist (GRO-2225) ──────────────────────────────────────
// Standing receptionist staff record so the route-optimization 403 path
// (TC-API-16.9: receptionist GET/POST /api/routes → 403) is reproducible
// without a hand-built session. The matching Better-Auth credential is
// provisioned below from SEED_UAT_RECEPTIONIST_PASSWORD. Created here (gated
// on the password env) so the credential loop's staff-link step finds it.
if (process.env.SEED_UAT_RECEPTIONIST_PASSWORD) {
const UAT_RECEPTIONIST_STAFF_ID = "00000000-0000-0000-0000-000000000099";
const [existingReceptionist] = await db
.select()
.from(schema.staff)
.where(eq(schema.staff.email, "uat-receptionist@groombook.dev"))
.limit(1);
if (existingReceptionist) {
console.log(`✓ Staff 'UAT Receptionist' already exists — skipping`);
} else {
await db.insert(schema.staff).values({
id: UAT_RECEPTIONIST_STAFF_ID,
name: "UAT Receptionist",
email: "uat-receptionist@groombook.dev",
oidcSub: "uat-receptionist@groombook.dev",
role: "receptionist",
isSuperUser: false,
active: true,
});
console.log(`✓ Created staff 'UAT Receptionist' (uat-receptionist@groombook.dev)`);
}
}
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
@@ -495,6 +525,8 @@ async function seedUatStaffAccounts(
{ email: "uat-groomer@groombook.dev", name: "UAT Staff Groomer", passwordEnv: "SEED_UAT_GROOMER_PASSWORD", staffEmail: "uat-groomer@groombook.dev" },
{ email: "uat-customer@groombook.dev", name: "UAT Customer", passwordEnv: "SEED_UAT_CUSTOMER_PASSWORD", staffEmail: null },
{ email: "uat-tester@groombook.dev", name: "UAT Tester", passwordEnv: "SEED_UAT_TESTER_PASSWORD", staffEmail: "uat-tester@groombook.dev" },
// GRO-2225: standing receptionist login for the route-optimization 403 path (TC-API-16.9).
{ email: "uat-receptionist@groombook.dev", name: "UAT Receptionist", passwordEnv: "SEED_UAT_RECEPTIONIST_PASSWORD", staffEmail: "uat-receptionist@groombook.dev" },
];
for (const acct of uatPasswordAccounts) {
@@ -798,6 +830,179 @@ async function seedUatGroomerLinkage(
);
}
// ── GRO-2225: deterministic route-optimization cohort ────────────────────────
/**
* GRO-2225: seed a deterministic, pre-geocoded client cohort + a fixed-date set
* of appointments for the UAT groomer so the route-optimization endpoints
* (`GET /api/routes/daily`, `POST /api/routes/optimize`, UAT §4.16
* TC-API-16.1…16.11) are exercisable with ZERO manual PATCHing.
*
* Design (no live geocoder — UAT has no Google Maps key, provider is
* nearest_neighbor; coordinates are hand-picked fixtures clustered in the
* Seattle metro):
* - All appointments are on a FIXED calendar date (ROUTE_DATE) and assigned to
* the UAT groomer (`uat-groomer@groombook.dev`). The optimize endpoint pulls
* non-cancelled appointments in [date 00:00Z, +24h) joined to client coords.
* - 10 clients carry deterministic lat/lng → a multi-stop optimized route.
* - 2 clients are intentionally left UN-geocoded so the "skipped + surfaced"
* path (TC-API-16.5) stays reproducible.
*
* Idempotent: clients/pets are upserted by fixed UUID (they are NOT truncated on
* reset); appointments are upserted by fixed UUID too (they ARE truncated on
* reset, but the upsert keeps re-runs safe in non-truncating dev/test paths).
* Skips cleanly when the UAT groomer staff record is absent (e.g. prod/demo or a
* dev seed without the UAT personas).
*/
async function seedUatRouteCohort(db: ReturnType<typeof drizzle>): Promise<void> {
// Fixed calendar date the UAT playbook hardcodes for §4.16. Times are UTC so
// they fall inside the optimize endpoint's [date 00:00Z, +24h) day window.
const ROUTE_DATE = "2026-09-15";
const [uatGroomer] = await db
.select({ id: schema.staff.id })
.from(schema.staff)
.where(eq(schema.staff.email, "uat-groomer@groombook.dev"))
.limit(1);
if (!uatGroomer) {
console.log("✓ GRO-2225: uat-groomer not present — skipping route cohort");
return;
}
// Resolve a service for the appointments: prefer Bath & Brush, else any active.
const BATH_AND_BRUSH_ID = "b0000001-0000-0000-0000-000000000001";
const [bathService] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.id, BATH_AND_BRUSH_ID))
.limit(1);
let serviceId: string;
if (bathService) {
serviceId = bathService.id;
} else {
const [fallback] = await db
.select({ id: schema.services.id })
.from(schema.services)
.where(eq(schema.services.active, true))
.limit(1);
if (!fallback) {
console.warn("⚠ GRO-2225: no active services found — skipping route cohort");
return;
}
serviceId = fallback.id;
}
// Hand-picked fixture coordinates clustered in the Seattle metro. `coords:null`
// marks an intentionally un-geocoded client (skip-and-surface path TC-16.5).
const cohort: Array<{
n: number;
name: string;
coords: { lat: number; lng: number } | null;
}> = [
{ n: 1, name: "Route Demo — Ada Lovelace", coords: { lat: 47.6097, lng: -122.3331 } },
{ n: 2, name: "Route Demo — Grace Hopper", coords: { lat: 47.6205, lng: -122.3493 } },
{ n: 3, name: "Route Demo — Alan Turing", coords: { lat: 47.5990, lng: -122.3300 } },
{ n: 4, name: "Route Demo — Katherine Johnson", coords: { lat: 47.6150, lng: -122.3200 } },
{ n: 5, name: "Route Demo — Edsger Dijkstra", coords: { lat: 47.6280, lng: -122.3550 } },
{ n: 6, name: "Route Demo — Barbara Liskov", coords: { lat: 47.5920, lng: -122.3150 } },
{ n: 7, name: "Route Demo — Donald Knuth", coords: { lat: 47.6350, lng: -122.3400 } },
{ n: 8, name: "Route Demo — Margaret Hamilton", coords: { lat: 47.6050, lng: -122.3600 } },
{ n: 9, name: "Route Demo — Ken Thompson", coords: { lat: 47.6420, lng: -122.3250 } },
{ n: 10, name: "Route Demo — Radia Perlman", coords: { lat: 47.5880, lng: -122.3450 } },
// Intentionally un-geocoded — exercises the skip-and-surface path.
{ n: 11, name: "Route Demo — Ungeocoded One", coords: null },
{ n: 12, name: "Route Demo — Ungeocoded Two", coords: null },
];
// Stagger appointments 45 min apart starting 15:00Z on ROUTE_DATE.
const dayStartMs = new Date(`${ROUTE_DATE}T15:00:00.000Z`).getTime();
const SLOT_MS = 45 * 60 * 1000;
let geocodedCount = 0;
let ungeocodedCount = 0;
for (const c of cohort) {
const pad = String(c.n).padStart(2, "0");
const clientId = `d0000000-0000-0000-0000-0000000000${pad}`;
const petId = `d0000000-0000-0000-0000-0000000001${pad}`;
const apptId = `d0000000-0000-0000-0000-0000000002${pad}`;
const geocodedAt = c.coords ? new Date(`${ROUTE_DATE}T00:00:00.000Z`) : null;
await db.insert(schema.clients)
.values({
id: clientId,
name: c.name,
email: `route-client-${pad}@uat.groombook.dev`,
phone: `(206) 555-01${pad}`,
address: `${100 + c.n} Pike Street, Seattle, WA 98101`,
status: "active",
latitude: c.coords?.lat ?? null,
longitude: c.coords?.lng ?? null,
geocodedAt,
})
.onConflictDoUpdate({
target: schema.clients.id,
set: {
name: c.name,
address: `${100 + c.n} Pike Street, Seattle, WA 98101`,
latitude: c.coords?.lat ?? null,
longitude: c.coords?.lng ?? null,
geocodedAt,
},
});
await db.insert(schema.pets)
.values({
id: petId,
clientId,
name: `Route Pup ${c.n}`,
species: "Dog",
breed: "Mixed",
weightKg: "18.00",
})
.onConflictDoUpdate({
target: schema.pets.id,
set: { clientId, name: `Route Pup ${c.n}`, species: "Dog" },
});
const startTime = new Date(dayStartMs + (c.n - 1) * SLOT_MS);
const endTime = new Date(startTime.getTime() + SLOT_MS);
await db.insert(schema.appointments)
.values({
id: apptId,
clientId,
petId,
serviceId,
staffId: uatGroomer.id,
batherStaffId: null,
status: "confirmed",
startTime,
endTime,
notes: "GRO-2225: deterministic route-optimization cohort appointment.",
priceCents: null,
confirmationStatus: "confirmed",
})
.onConflictDoUpdate({
target: schema.appointments.id,
set: {
clientId,
petId,
serviceId,
staffId: uatGroomer.id,
status: "confirmed",
startTime,
endTime,
},
});
if (c.coords) geocodedCount++;
else ungeocodedCount++;
}
console.log(
`✓ GRO-2225: seeded route cohort for ${ROUTE_DATE}${geocodedCount} geocoded + ${ungeocodedCount} un-geocoded appointment(s) for uat-groomer (${uatGroomer.id})`,
);
}
// ── Known-users-only seed (prod/demo) ───────────────────────────────────────
/**
@@ -1169,6 +1374,11 @@ async function runSeedBody(
// the time seedUatStaffAccounts() returns).
await seedUatGroomerLinkage(db, uatCustomerClientId);
// GRO-2225: deterministic pre-geocoded route cohort + fixed-date appointments
// for the UAT groomer. Must run AFTER services are seeded (it looks up a
// service id for the appointments). Skips cleanly if uat-groomer is absent.
await seedUatRouteCohort(db);
// ── Clients & Pets ──
const now = new Date();
const appointmentsBackDate = new Date(now);
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// GRO-2235: a duplicate active waitlist entry violates the partial unique index
// idx_waitlist_active_unique. postgres-js surfaces it as SQLSTATE 23505 — the
// handler must return a friendly 409, not a generic 500. The first insert still
// returns 201, and unrelated errors still surface as 500.
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const PET_ID = "880e8400-e29b-41d4-a716-446655440004";
const SERVICE_ID = "990e8400-e29b-41d4-a716-446655440005";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
reason: "manual",
startedAt: new Date(),
expiresAt: futureDate(),
createdAt: new Date(),
};
// Behaviour knob for the waitlist insert: "ok" returns a row, "duplicate" throws
// a postgres-js-shaped unique-violation, "other" throws an unrelated error.
let waitlistInsertMode: "ok" | "duplicate" | "other" = "ok";
function resetMock() {
waitlistInsertMode = "ok";
}
function tableProxy(name: string) {
return new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
}
vi.mock("@groombook/db", () => {
function makeChainable(data: unknown[]): unknown {
const arr = [...data];
const chain = new Proxy(arr, {
get(target, prop) {
if (prop === "where" || prop === "orderBy" || prop === "limit") {
return () => chain;
}
// @ts-expect-error proxy
return target[prop];
},
});
return chain;
}
const impersonationSessions = tableProxy("impersonationSessions");
const waitlistEntries = tableProxy("waitlistEntries");
const impersonationAuditLogs = tableProxy("impersonationAuditLogs");
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable([ACTIVE_SESSION]);
}
return makeChainable([]);
},
}),
insert: (table: { _name: string }) => ({
values: (vals: Record<string, unknown>) => ({
returning: () => {
if (table._name === "waitlistEntries") {
if (waitlistInsertMode === "duplicate") {
throw Object.assign(new Error("duplicate key value"), { code: "23505" });
}
if (waitlistInsertMode === "other") {
throw Object.assign(new Error("not null violation"), { code: "23502" });
}
return [{ id: "entry-1", ...vals }];
}
// impersonationAuditLogs and anything else: succeed silently.
return [{ id: "audit-1", ...vals }];
},
}),
}),
update: () => ({
set: () => ({ where: () => Promise.resolve() }),
}),
}),
impersonationSessions,
waitlistEntries,
impersonationAuditLogs,
appointments: tableProxy("appointments"),
clients: tableProxy("clients"),
pets: tableProxy("pets"),
services: tableProxy("services"),
staff: tableProxy("staff"),
invoices: tableProxy("invoices"),
invoiceLineItems: tableProxy("invoiceLineItems"),
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
};
});
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
function postWaitlist(body: unknown) {
return app.request("/portal/waitlist", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Impersonation-Session-Id": SESSION_ID,
},
body: JSON.stringify(body),
});
}
const VALID_BODY = {
petId: PET_ID,
serviceId: SERVICE_ID,
preferredDate: "2026-07-01",
preferredTime: "09:00",
};
beforeEach(() => resetMock());
describe("POST /portal/waitlist duplicate handling (GRO-2235)", () => {
it("returns 201 for the first insert", async () => {
waitlistInsertMode = "ok";
const res = await postWaitlist(VALID_BODY);
expect(res.status).toBe(201);
});
it("returns 409 with a friendly message for a duplicate (23505)", async () => {
waitlistInsertMode = "duplicate";
const res = await postWaitlist(VALID_BODY);
expect(res.status).toBe(409);
const json = (await res.json()) as { error: string };
expect(json.error).toBe(
"You already have a booking for this pet at that date and time."
);
});
it("still surfaces unrelated DB errors as 500", async () => {
waitlistInsertMode = "other";
const res = await postWaitlist(VALID_BODY);
expect(res.status).toBe(500);
});
});
+26 -10
View File
@@ -596,16 +596,32 @@ portalRouter.post(
const body = c.req.valid("json");
const clientId = c.get("portalClientId");
const [entry] = await db
.insert(waitlistEntries)
.values({
clientId,
petId: body.petId,
serviceId: body.serviceId,
preferredDate: body.preferredDate,
preferredTime: normalizeTime(body.preferredTime),
})
.returning();
let entry;
try {
[entry] = await db
.insert(waitlistEntries)
.values({
clientId,
petId: body.petId,
serviceId: body.serviceId,
preferredDate: body.preferredDate,
preferredTime: normalizeTime(body.preferredTime),
})
.returning();
} catch (err) {
// An exact duplicate active waitlist entry violates the partial unique
// index idx_waitlist_active_unique (client_id, pet_id, service_id,
// preferred_date, preferred_time WHERE status='active'). postgres-js
// surfaces this as SQLSTATE 23505 — return a friendly 409 rather than a
// generic 500 (GRO-2235). Unrelated errors still surface as 500.
if ((err as { code?: string })?.code === "23505") {
return c.json(
{ error: "You already have a booking for this pet at that date and time." },
409
);
}
throw err;
}
return c.json(entry, 201);
}