fix(GRO-2342): portal waitlist card populates service {id, name}
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>
This commit is contained in:
@@ -42,6 +42,7 @@ let selectAppointmentRow: Record<string, unknown> | null = null;
|
||||
let selectWaitlistRows: Record<string, unknown>[] = [];
|
||||
let selectPetRows: Record<string, unknown>[] = [];
|
||||
let selectStaffRows: Record<string, unknown>[] = [];
|
||||
let selectServiceRows: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
|
||||
function resetMock() {
|
||||
@@ -50,6 +51,7 @@ function resetMock() {
|
||||
selectWaitlistRows = [];
|
||||
selectPetRows = [];
|
||||
selectStaffRows = [];
|
||||
selectServiceRows = [];
|
||||
updatedValues = [];
|
||||
}
|
||||
|
||||
@@ -83,6 +85,7 @@ vi.mock("@groombook/db", () => {
|
||||
const waitlistEntries = mkTable("waitlistEntries");
|
||||
const pets = mkTable("pets");
|
||||
const staff = mkTable("staff");
|
||||
const services = mkTable("services");
|
||||
|
||||
return {
|
||||
getDb: () => ({
|
||||
@@ -103,6 +106,9 @@ vi.mock("@groombook/db", () => {
|
||||
if (table._name === "staff") {
|
||||
return makeChainable(selectStaffRows);
|
||||
}
|
||||
if (table._name === "services") {
|
||||
return makeChainable(selectServiceRows);
|
||||
}
|
||||
return makeChainable([]);
|
||||
},
|
||||
}),
|
||||
@@ -126,6 +132,7 @@ vi.mock("@groombook/db", () => {
|
||||
waitlistEntries,
|
||||
pets,
|
||||
staff,
|
||||
services,
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
@@ -198,6 +205,56 @@ describe("GET /portal/appointments (waitlist surfacing — GRO-2319)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// GRO-2342: GET /portal/appointments must populate the synthetic waitlist
|
||||
// card's `service` object with the full service record (id + name) — same
|
||||
// shape the appointments join returns — so the portal renders the real
|
||||
// service name in place of the fallback "Service" label.
|
||||
describe("GET /portal/appointments (waitlist service name — GRO-2342)", () => {
|
||||
it("returns service {id, name} on the synthetic waitlist card", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT };
|
||||
selectWaitlistRows = [
|
||||
{
|
||||
id: "22222222-2222-2222-2222-222222222222",
|
||||
petId: "pet-1",
|
||||
serviceId: "svc-1",
|
||||
preferredDate: "2099-01-01",
|
||||
preferredTime: "13:00:00",
|
||||
},
|
||||
];
|
||||
selectPetRows = [{ id: "pet-1", name: "Rex", photoKey: null }];
|
||||
selectServiceRows = [{ id: "svc-1", name: "Full Groom" }];
|
||||
|
||||
const res = await app.request("/portal/appointments", {
|
||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const waitlistCard = body.appointments.find(
|
||||
(a: { status: string }) => a.status === "waitlisted",
|
||||
);
|
||||
expect(waitlistCard).toBeTruthy();
|
||||
expect(waitlistCard.service).toEqual({ id: "svc-1", name: "Full Groom" });
|
||||
});
|
||||
|
||||
it("returns service {id, name} on the appointment card (same shape)", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
selectAppointmentRow = { ...APPOINTMENT, serviceId: "svc-appt" };
|
||||
selectServiceRows = [{ id: "svc-appt", name: "Bath & Brush" }];
|
||||
|
||||
const res = await app.request("/portal/appointments", {
|
||||
headers: { "X-Impersonation-Session-Id": SESSION_ID },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const apptCard = body.appointments.find(
|
||||
(a: { status: string }) => a.status === "scheduled",
|
||||
);
|
||||
expect(apptCard).toBeTruthy();
|
||||
expect(apptCard.service).toEqual({ id: "svc-appt", name: "Bath & Brush" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /portal/appointments/:id/notes", () => {
|
||||
it("returns updated appointment with safe fields only", async () => {
|
||||
selectSessionRow = ACTIVE_SESSION;
|
||||
|
||||
Reference in New Issue
Block a user