From e98d7c0d3629a727ca5c277ac61294b439e05498 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Fri, 27 Mar 2026 06:43:57 +0000 Subject: [PATCH] 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 --- apps/web/src/__tests__/Appointments.test.tsx | 189 ++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx index e2365ab..dace552 100644 --- a/apps/web/src/__tests__/Appointments.test.tsx +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import type { Appointment } from "../portal/mockData.js"; -import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection } from "../portal/sections/Appointments.js"; +import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.js"; const UPCOMING_APPT: Appointment = { id: "appt-1", @@ -192,4 +192,191 @@ describe("CustomerNotesSection", () => { render(); expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); }); +}); + +describe("ConfirmationSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch = vi.fn(); + vi.stubGlobal("confirm", vi.fn(() => true)); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders pending badge when confirmationStatus is pending", () => { + render(); + expect(screen.getByText("Pending confirmation")).toBeInTheDocument(); + }); + + it("renders confirmed badge when confirmationStatus is confirmed", () => { + render(); + expect(screen.getByText("✓ Confirmed")).toBeInTheDocument(); + }); + + it("renders cancelled badge when confirmationStatus is cancelled", () => { + render(); + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + }); + + it("shows Confirm Appointment button when status is pending", () => { + render(); + expect(screen.getByRole("button", { name: /Confirm Appointment/i })).toBeInTheDocument(); + }); + + it("does not show Confirm button when already confirmed", () => { + render(); + expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument(); + }); + + it("does not show Confirm button when cancelled", () => { + render(); + expect(screen.queryByRole("button", { name: /Confirm Appointment/i })).not.toBeInTheDocument(); + }); + + it("calls confirm API and updates local status on success", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/portal/appointments/appt-1/confirm", + expect.objectContaining({ method: "POST" }) + ); + }); + await waitFor(() => { + expect(screen.getByText("✓ Confirmed")).toBeInTheDocument(); + }); + }); + + it("sends X-Impersonation-Session-Id header when session exists", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/portal/appointments/appt-1/confirm", + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Impersonation-Session-Id": "test-session-id", + }), + }) + ); + }); + }); + + it("does not send X-Impersonation-Session-Id header when sessionId is null", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/portal/appointments/appt-1/confirm", + expect.objectContaining({ + headers: expect.not.objectContaining({ + "X-Impersonation-Session-Id": expect.anything(), + }), + }) + ); + }); + }); + + it("shows error message when confirm API returns 401", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Unauthorized" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument(); + }); + }); + + it("shows error message when confirm API returns 403", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Forbidden" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(screen.getByText(/Forbidden/i)).toBeInTheDocument(); + }); + }); + + it("shows error message when confirm API returns 422 (invalid state)", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 422, + json: async () => ({ error: "Cannot confirm - appointment is not in pending state" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(screen.getByText(/Cannot confirm/i)).toBeInTheDocument(); + }); + }); + + it("does not call confirm API if user cancels the confirmation dialog", async () => { + vi.stubGlobal("confirm", vi.fn(() => false)); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("shows loading state while confirming", async () => { + vi.mocked(global.fetch).mockReturnValue(new Promise(() => {})); // Never resolves + + render(); + // Get button reference before clicking + const btn = screen.getByRole("button", { name: /Confirm Appointment/i }); + fireEvent.click(btn); + + await waitFor(() => { + expect(screen.getByText(/Confirming.../i)).toBeInTheDocument(); + }); + // Button is disabled while loading + expect(btn).toBeDisabled(); + }); + + it("shows success message briefly after confirm", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", confirmationStatus: "confirmed" }), + } as Response); + + render(); + fireEvent.click(screen.getByRole("button", { name: /Confirm Appointment/i })); + + await waitFor(() => { + expect(screen.getByText(/Confirmed!/i)).toBeInTheDocument(); + }); + }); }); \ No newline at end of file