diff --git a/apps/web/src/__tests__/Appointments.test.tsx b/apps/web/src/__tests__/Appointments.test.tsx new file mode 100644 index 0000000..344ea97 --- /dev/null +++ b/apps/web/src/__tests__/Appointments.test.tsx @@ -0,0 +1,182 @@ +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"; + +const UPCOMING_APPT: Appointment = { + id: "appt-1", + petId: "pet-1", + petName: "Buddy", + groomerId: "groomer-1", + groomerName: "Sarah", + services: ["Bath & Brush"], + addOns: [], + date: "2027-01-01", + time: "10:00 AM", + duration: 60, + price: 50, + status: "confirmed", + notes: "", + customerNotes: "", +}; + +const PAST_APPT: Appointment = { + ...UPCOMING_APPT, + id: "appt-2", + date: "2025-01-01", + time: "10:00 AM", + 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"); + expect(parseTimeTo24Hour("10:00 AM")).toBe("10:00:00"); + expect(parseTimeTo24Hour("12:00 AM")).toBe("00:00:00"); + }); + + it("converts PM times correctly", () => { + expect(parseTimeTo24Hour("1:00 PM")).toBe("13:00:00"); + expect(parseTimeTo24Hour("2:00 PM")).toBe("14:00:00"); + expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00"); + expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00"); + }); +}); + +describe("isUpcoming", () => { + it("returns true for future confirmed appointments", () => { + expect(isUpcoming(UPCOMING_APPT)).toBe(true); + }); + + it("returns false for past appointments", () => { + expect(isUpcoming(PAST_APPT)).toBe(false); + }); + + it("returns false for cancelled appointments", () => { + expect(isUpcoming({ ...UPCOMING_APPT, status: "cancelled" })).toBe(false); + }); + + it("returns false for completed appointments", () => { + expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false); + }); +}); + +describe("CustomerNotesSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSessionStorage.getItem.mockReturnValue("test-session-id"); + global.fetch = vi.fn(); + }); + + it("renders textarea with existing notes", () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue("Test note"); + }); + + it("renders Save Notes button", () => { + render(); + expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument(); + }); + + it("sends X-Impersonation-Session-Id header when session exists", 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.objectContaining({ + "X-Impersonation-Session-Id": "test-session-id", + }), + }) + ); + }); + }); + + it("shows error message when save fails", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Unauthorized" }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(screen.getByText(/Unauthorized/i)).toBeInTheDocument(); + }); + }); + + it("shows success message when save succeeds", async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }), + } as Response); + + render(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } }); + fireEvent.click(screen.getByRole("button", { name: /Save Notes/i })); + + await waitFor(() => { + expect(screen.getByText(/Saved!/i)).toBeInTheDocument(); + }); + }); + + it("disables button when notes unchanged", () => { + render(); + expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled(); + }); + + it("enforces 500 character limit", () => { + render(); + const textarea = screen.getByRole("textbox"); + const longText = "a".repeat(600); + fireEvent.change(textarea, { target: { value: longText } }); + expect(textarea).toHaveValue("a".repeat(500)); + }); + + it("displays character count", () => { + render(); + expect(screen.getByText(/0\/500/)).toBeInTheDocument(); + }); + + it("shows exceeded character count in red when limit exceeded", () => { + render(); + const textarea = screen.getByRole("textbox"); + // Type characters one by one to exceed limit + const longText = "a".repeat(501); + fireEvent.change(textarea, { target: { value: longText } }); + // The textarea value is truncated to 500, so counter shows 500/500 + // The class check would need to verify text-red-500 appears + // Since the onChange truncates, we test that limit is enforced + expect(textarea).toHaveValue("a".repeat(500)); + }); + + it("does not render save button for completed appointments", () => { + render(); + expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); + }); + + it("does not render save button for cancelled appointments", () => { + render(); + expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 2b96952..a937e08 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -9,13 +9,26 @@ interface Props { readOnly: boolean; } -function formatDate(dateStr: string): string { +export function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); } -function isUpcoming(appt: Appointment): boolean { +export function parseTimeTo24Hour(time: string): string { + const parts = time.split(" "); + const hoursMinutes = parts[0] ?? ""; + const period = parts[1] ?? ""; + const [hoursStr, minutesStr] = hoursMinutes.split(":"); + const hours = parseInt(hoursStr ?? "0", 10); + const minutes = parseInt(minutesStr ?? "0", 10); + let hours24 = hours; + if (period === "PM" && hours !== 12) hours24 += 12; + if (period === "AM" && hours === 12) hours24 = 0; + return `${hours24.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:00`; +} + +export function isUpcoming(appt: Appointment): boolean { const now = new Date(); - const apptDate = new Date(`${appt.date}T${appt.time}`); + const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`); return apptDate > now && appt.status !== "cancelled" && appt.status !== "completed"; } @@ -172,7 +185,7 @@ function AppointmentCard({ ); } -function CustomerNotesSection({ appointment: appt }: { appointment: Appointment }) { +export function CustomerNotesSection({ appointment: appt }: { appointment: Appointment }) { const [notes, setNotes] = useState(appt.customerNotes || ""); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); @@ -180,14 +193,23 @@ function CustomerNotesSection({ appointment: appt }: { appointment: Appointment 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; + } const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ customerNotes: notes }), }); if (!res.ok) {