From 6c6eba4011544c9b5a7eb5f4e3cb78067b6c1f1e Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Wed, 25 Mar 2026 11:12:59 +0000 Subject: [PATCH] fix: thread sessionId as prop instead of sessionStorage CustomerNotesSection was reading sessionStorage for the impersonation session ID, but CustomerPortal stores it in React state. Pass sessionId as a prop through AppointmentsSection and AppointmentCard instead. Also update tests to pass sessionId prop and add test for null sessionId case. Co-Authored-By: Paperclip --- apps/web/src/__tests__/Appointments.test.tsx | 54 +++++++++++-------- apps/web/src/portal/CustomerPortal.tsx | 2 +- apps/web/src/portal/sections/Appointments.tsx | 18 +++---- 3 files changed, 42 insertions(+), 32 deletions(-) diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx index 344ea97..ade71a7 100644 --- a/apps/web/src/__tests__/Appointments.test.tsx +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -28,15 +28,6 @@ const PAST_APPT: Appointment = { status: "completed", }; -const mockSessionStorage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), -}; - -vi.stubGlobal("sessionStorage", mockSessionStorage); - describe("parseTimeTo24Hour", () => { it("converts AM times correctly", () => { expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00"); @@ -73,17 +64,16 @@ describe("isUpcoming", () => { describe("CustomerNotesSection", () => { beforeEach(() => { vi.clearAllMocks(); - mockSessionStorage.getItem.mockReturnValue("test-session-id"); global.fetch = vi.fn(); }); it("renders textarea with existing notes", () => { - render(); + render(); expect(screen.getByRole("textbox")).toHaveValue("Test note"); }); it("renders Save Notes button", () => { - render(); + render(); expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument(); }); @@ -93,7 +83,7 @@ describe("CustomerNotesSection", () => { json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }), } as Response); - render(); + render(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); @@ -109,6 +99,28 @@ describe("CustomerNotesSection", () => { }); }); + 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", customerNotes: "Updated", updatedAt: new Date().toISOString() }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + "/api/portal/appointments/appt-1/notes", + expect.objectContaining({ + headers: expect.not.objectContaining({ + "X-Impersonation-Session-Id": expect.anything(), + }), + }) + ); + }); + }); + it("shows error message when save fails", async () => { vi.mocked(global.fetch).mockResolvedValue({ ok: false, @@ -116,7 +128,7 @@ describe("CustomerNotesSection", () => { json: async () => ({ error: "Unauthorized" }), } as Response); - render(); + render(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); @@ -131,7 +143,7 @@ describe("CustomerNotesSection", () => { json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }), } as Response); - render(); + render(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } }); fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); @@ -141,12 +153,12 @@ describe("CustomerNotesSection", () => { }); it("disables button when notes unchanged", () => { - render(); + render(); expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled(); }); it("enforces 500 character limit", () => { - render(); + render(); const textarea = screen.getByRole("textbox"); const longText = "a".repeat(600); fireEvent.change(textarea, { target: { value: longText } }); @@ -154,12 +166,12 @@ describe("CustomerNotesSection", () => { }); it("displays character count", () => { - render(); + render(); expect(screen.getByText(/0\/500/)).toBeInTheDocument(); }); it("shows exceeded character count in red when limit exceeded", () => { - render(); + render(); const textarea = screen.getByRole("textbox"); // Type characters one by one to exceed limit const longText = "a".repeat(501); @@ -171,12 +183,12 @@ describe("CustomerNotesSection", () => { }); it("does not render save button for completed appointments", () => { - render(); + render(); expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); }); it("does not render save button for cancelled appointments", () => { - render(); + render(); expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/apps/web/src/portal/CustomerPortal.tsx b/apps/web/src/portal/CustomerPortal.tsx index b74e297..65a17e3 100644 --- a/apps/web/src/portal/CustomerPortal.tsx +++ b/apps/web/src/portal/CustomerPortal.tsx @@ -114,7 +114,7 @@ export function CustomerPortal() { case "dashboard": return ; case "appointments": - return ; + return ; case "pets": return ; case "reports": diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index a937e08..bd38475 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -7,6 +7,7 @@ const MAX_CUSTOMER_NOTES = 500; interface Props { readOnly: boolean; + sessionId?: string | null; } export function formatDate(dateStr: string): string { @@ -40,7 +41,7 @@ const STATUS_COLORS: Record = { cancelled: "bg-red-100 text-red-600", }; -export function AppointmentsSection({ readOnly }: Props) { +export function AppointmentsSection({ readOnly, sessionId }: Props) { const [showBooking, setShowBooking] = useState(false); const [expandedId, setExpandedId] = useState(null); const [tab, setTab] = useState<"upcoming" | "past">("upcoming"); @@ -82,6 +83,7 @@ export function AppointmentsSection({ readOnly }: Props) { expanded={expandedId === appt.id} onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} + sessionId={sessionId} /> ))} {UPCOMING_APPOINTMENTS.length === 0 && ( @@ -99,6 +101,7 @@ export function AppointmentsSection({ readOnly }: Props) { expanded={expandedId === appt.id} onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)} readOnly={readOnly} + sessionId={sessionId} /> ))} @@ -115,9 +118,9 @@ export function AppointmentsSection({ readOnly }: Props) { } function AppointmentCard({ - appointment: appt, expanded, onToggle, readOnly, + appointment: appt, expanded, onToggle, readOnly, sessionId, }: { - appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; + appointment: Appointment; expanded: boolean; onToggle: () => void; readOnly: boolean; sessionId?: string | null; }) { return (
@@ -160,7 +163,7 @@ function AppointmentCard({

{appt.notes}

)} {isUpcoming(appt) && !readOnly && ( - + )} {appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
@@ -185,7 +188,7 @@ function AppointmentCard({ ); } -export function CustomerNotesSection({ appointment: appt }: { appointment: Appointment }) { +export function CustomerNotesSection({ appointment: appt, sessionId }: { appointment: Appointment; sessionId?: string | null }) { const [notes, setNotes] = useState(appt.customerNotes || ""); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -193,16 +196,11 @@ export function CustomerNotesSection({ appointment: appt }: { appointment: Appoi const isDisabled = appt.status === "completed" || appt.status === "cancelled"; - function getSessionId(): string | null { - return sessionStorage.getItem("impersonationSessionId"); - } - async function handleSave() { setSaving(true); setError(null); setSaved(false); try { - const sessionId = getSessionId(); const headers: Record = { "Content-Type": "application/json" }; if (sessionId) { headers["X-Impersonation-Session-Id"] = sessionId;