This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/api/src/__tests__/portal.test.ts
T
groombook-engineer[bot] 9eb0c3d151 fix(gro66): E2E selector fix + groomer isolation + portal confirm/cancel
* Implement confirm/cancel in customer portal (GRO-50)

Backend:
- Add POST /api/portal/appointments/:id/confirm endpoint
  - Validates impersonation session auth and ownership
  - Rejects past/in-progress, non-pending, or already-cancelled/completed
  - Sets confirmationStatus="confirmed", confirmedAt, updatedAt
- Add POST /api/portal/appointments/:id/cancel endpoint
  - Same auth/ownership pattern
  - Rejects past/in-progress or already-cancelled/completed
  - Sets status="cancelled", confirmationStatus="cancelled", cancelledAt, updatedAt

Frontend (Appointments.tsx):
- Add confirmationStatus field to Appointment type and mock data
- Add ConfirmationSection component: shows status badge + confirm button
- Add CancelAppointmentButton: wires to cancel API with loading/error state
- Wire existing Cancel button to CancelAppointmentButton
- Show confirmation status badge in expanded view for upcoming appointments

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(gro-48): row-level data scoping for groomer role (RBAC Phase 2)

Filter query results at the route handler level when staff role is groomer:

- GET /api/appointments: WHERE staffId = groomer OR batherStaffId = groomer
- GET /api/appointments/🆔 403 if not assigned to groomer (as staff or bather)
- GET /api/clients: Clients with ≥1 appointment for this groomer (via exists subquery)
- GET /api/clients/🆔 403 if no appointment linkage
- GET /api/pets: Pets owned by groomer-linked clients (via exists subquery)
- GET /api/pets/:petId: 403 if no appointment linkage

Managers and receptionists: no change.

Added exists to @groombook/db exports (was missing from re-export).
Added groomerIsolation unit tests for role guard and filter logic.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-50): add portal confirm/cancel tests and fix ConfirmationSection state

- Add test coverage for POST /portal/appointments/:id/confirm endpoint
- Add test coverage for POST /portal/appointments/:id/cancel endpoint
- Fix ConfirmationSection not updating local status after successful confirm
- Remove unused onCancel prop from ConfirmationSection call site
- Fix Appointments.test.tsx missing confirmationStatus field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(gro-50): add ConfirmationSection UI component tests

Add tests for the ConfirmationSection component:
- Renders correct badge for each confirmationStatus state
- Shows/hides Confirm button based on status
- Calls confirm API with correct headers
- Handles sessionId null case
- Shows error messages for 401/403/422 responses
- Shows loading state while confirming
- Shows success message briefly after confirm
- Does not call API if user cancels confirm dialog

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-48): address QA review feedback — staffRow?.role and portal TS guards

- appointments.ts: use staffRow?.role (consistent with clients.ts/pets.ts)
  to handle undefined staff context safely
- portal.ts: add null guards on .returning() results for confirm and cancel
  endpoints (TS18048: 'updated' is possibly undefined)
- All 188 tests passing; TypeScript typecheck clean

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro66): use specific selector for banner visibility assertion

Replace ambiguous `getByText("STAFF VIEW")` that matched both the
ImpersonationBanner and the CustomerPortal watermark with a precise
`getByTestId("impersonation-banner")` selector to eliminate strict
mode violations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-66): add missing afterEach to vitest import

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-48): add icalToken to MANAGER mock after rebase

After rebasing onto origin/main (which added icalToken to the staff
schema via GRO-107), the MANAGER mock in groomerIsolation.test.ts was
missing the new required field. Added icalToken: null to the MANAGER
constant. factories.ts is clean (no duplicate icalToken after rebase).

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(gro-47): add non-null assertions on Drizzle RETURNING results

Drizzle's update().returning() types the array element as T | undefined.
After the if (!appt) guard, updated is still typed as possibly undefined
because RETURNING can succeed with no rows. Add ! assertions since
we already guard with the existence check.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Flea Flicker <fleaflicker@groombook.ai>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Flea Flicker <flea-flicker@paperclip.ing>
2026-03-27 14:23:19 +00:00

423 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001";
const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002";
const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003";
const futureDate = () => new Date(Date.now() + 30 * 60 * 1000);
const pastDate = () => new Date(Date.now() - 5 * 60 * 1000);
const ACTIVE_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: futureDate(),
createdAt: new Date(),
};
const EXPIRED_SESSION = {
id: SESSION_ID,
clientId: CLIENT_ID,
status: "active" as const,
expiresAt: pastDate(),
createdAt: new Date(),
};
const APPOINTMENT = {
id: APPOINTMENT_ID,
clientId: CLIENT_ID,
startTime: futureDate(),
endTime: futureDate(),
customerNotes: null,
confirmationToken: "secret-token-leak-test",
status: "scheduled" as const,
confirmationStatus: "pending" as const,
confirmedAt: null,
cancelledAt: null,
};
let selectSessionRow: Record<string, unknown> | null = null;
let selectAppointmentRow: Record<string, unknown> | null = null;
let updatedValues: Record<string, unknown>[] = [];
function resetMock() {
selectSessionRow = null;
selectAppointmentRow = null;
updatedValues = [];
}
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 = new Proxy(
{ _name: "impersonationSessions" },
{ get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) }
);
const appointments = new Proxy(
{ _name: "appointments" },
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
);
return {
getDb: () => ({
select: () => ({
from: (table: { _name: string }) => {
if (table._name === "impersonationSessions") {
return makeChainable(selectSessionRow ? [selectSessionRow] : []);
}
if (table._name === "appointments") {
return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []);
}
return makeChainable([]);
},
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => ({
returning: () => {
if (selectAppointmentRow) {
const updated = { ...selectAppointmentRow, ...vals };
updatedValues.push(vals);
return [updated];
}
return [];
},
}),
}),
}),
}),
impersonationSessions,
appointments,
eq: vi.fn(),
and: vi.fn(),
};
});
const { portalRouter } = await import("../routes/portal.js");
const app = new Hono();
app.route("/portal", portalRouter);
function jsonPatch(path: string, body: unknown, headers?: Record<string, string>) {
return app.request(path, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...headers,
},
body: JSON.stringify(body),
});
}
beforeEach(() => resetMock());
describe("PATCH /portal/appointments/:id/notes", () => {
it("returns updated appointment with safe fields only", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Please be gentle with Fido" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("id");
expect(body).toHaveProperty("customerNotes", "Please be gentle with Fido");
expect(body).toHaveProperty("updatedAt");
expect(body).not.toHaveProperty("confirmationToken");
expect(body).not.toHaveProperty("clientId");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 401 with ended session", async () => {
selectSessionRow = null;
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
const body = await res.json();
expect(body.error).toBe("Unauthorized");
});
it("returns 403 when appointment belongs to different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.error).toBe("Forbidden");
});
it("returns 422 for past appointment", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.error).toMatch(/past|in-progress|cannot edit/i);
});
it("returns 422 when appointment is in progress", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: new Date(Date.now() - 2 * 60 * 1000) };
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 404 when appointment not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = null;
const res = await jsonPatch(
`/portal/appointments/nonexistent-id/notes`,
{ customerNotes: "Test note" },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
it("accepts notes at exactly 500 characters", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
const longNote = "a".repeat(500);
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: longNote },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.customerNotes).toBe(longNote);
});
it("rejects notes exceeding 500 characters", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT };
const longNote = "a".repeat(501);
const res = await jsonPatch(
`/portal/appointments/${APPOINTMENT_ID}/notes`,
{ customerNotes: longNote },
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(400);
});
});
// ─── POST /portal/appointments/:id/confirm ────────────────────────────────────
function jsonPost(path: string, headers?: Record<string, string>) {
return app.request(path, {
method: "POST",
headers,
});
}
describe("POST /portal/appointments/:id/confirm", () => {
it("confirms a pending appointment and returns updated status", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.confirmationStatus).toBe("confirmed");
expect(body).toHaveProperty("confirmedAt");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/confirm`);
expect(res.status).toBe(401);
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
});
it("returns 403 when appointment belongs to a different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
});
it("returns 422 when appointment is in the past", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when appointment is not pending confirmation", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "confirmed" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when cancelling an already-cancelled appointment", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 404 when appointment not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = null;
const res = await jsonPost(
`/portal/appointments/nonexistent-id/confirm`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
});
// ─── POST /portal/appointments/:id/cancel ─────────────────────────────────────
describe("POST /portal/appointments/:id/cancel", () => {
it("cancels a pending appointment and returns updated status", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, confirmationStatus: "pending" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("cancelled");
expect(body.confirmationStatus).toBe("cancelled");
expect(body).toHaveProperty("cancelledAt");
});
it("returns 401 without X-Impersonation-Session-Id header", async () => {
const res = await jsonPost(`/portal/appointments/${APPOINTMENT_ID}/cancel`);
expect(res.status).toBe(401);
});
it("returns 401 with expired session", async () => {
selectSessionRow = EXPIRED_SESSION;
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(401);
});
it("returns 403 when appointment belongs to a different client", async () => {
selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" };
selectAppointmentRow = { ...APPOINTMENT };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(403);
});
it("returns 422 when appointment is in the past", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, startTime: pastDate() };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when appointment is already cancelled", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, status: "cancelled", confirmationStatus: "cancelled" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 422 when appointment is already completed", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = { ...APPOINTMENT, status: "completed" };
const res = await jsonPost(
`/portal/appointments/${APPOINTMENT_ID}/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(422);
});
it("returns 404 when appointment not found", async () => {
selectSessionRow = ACTIVE_SESSION;
selectAppointmentRow = null;
const res = await jsonPost(
`/portal/appointments/nonexistent-id/cancel`,
{ "X-Impersonation-Session-Id": SESSION_ID }
);
expect(res.status).toBe(404);
});
});