diff --git a/apps/api/src/__tests__/portal.test.ts b/apps/api/src/__tests__/portal.test.ts index 907d879..73f05ff 100644 --- a/apps/api/src/__tests__/portal.test.ts +++ b/apps/api/src/__tests__/portal.test.ts @@ -31,6 +31,10 @@ const APPOINTMENT = { endTime: futureDate(), customerNotes: null, confirmationToken: "secret-token-leak-test", + status: "scheduled" as const, + confirmationStatus: "pending" as const, + confirmedAt: null, + cancelledAt: null, }; let selectSessionRow: Record | null = null; @@ -246,4 +250,174 @@ describe("PATCH /portal/appointments/:id/notes", () => { ); expect(res.status).toBe(400); }); +}); + +// ─── POST /portal/appointments/:id/confirm ──────────────────────────────────── + +function jsonPost(path: string, headers?: Record) { + 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); + }); }); \ No newline at end of file diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx index ade71a7..e2365ab 100644 --- a/apps/web/src/__tests__/Appointments.test.tsx +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -18,6 +18,7 @@ const UPCOMING_APPT: Appointment = { status: "confirmed", notes: "", customerNotes: "", + confirmationStatus: "pending", }; const PAST_APPT: Appointment = { diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index ba27732..04c2bc1 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -172,7 +172,7 @@ function AppointmentCard({ )} {isUpcoming(appt) && ( - {}} /> + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
@@ -199,6 +199,8 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm const [confirming, setConfirming] = useState(false); const [confirmError, setConfirmError] = useState(null); const [confirmSuccess, setConfirmSuccess] = useState(false); + // Local state mirrors confirmationStatus so the badge updates immediately after confirm + const [localStatus, setLocalStatus] = useState(appt.confirmationStatus); async function handleConfirm() { if (!window.confirm("Confirm this appointment?")) return; @@ -217,6 +219,7 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm const err = await res.json().catch(() => ({ error: "Failed to confirm" })); throw new Error(err.error || `HTTP ${res.status}`); } + setLocalStatus("confirmed"); setConfirmSuccess(true); setTimeout(() => setConfirmSuccess(false), 2000); } catch (e) { @@ -226,9 +229,10 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm } } - const statusLabel = appt.confirmationStatus === "confirmed" + const currentStatus = localStatus ?? appt.confirmationStatus; + const statusLabel = currentStatus === "confirmed" ? "✓ Confirmed" - : appt.confirmationStatus === "pending" + : currentStatus === "pending" ? "Pending confirmation" : "Cancelled"; @@ -236,11 +240,11 @@ export function ConfirmationSection({ appointment: appt, sessionId }: { appointm
- + {statusLabel}
- {!confirmSuccess && appt.confirmationStatus === "pending" && ( + {!confirmSuccess && currentStatus === "pending" && (