import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx"; const UPCOMING_APPT = { id: "appt-1", petId: "pet-1", petName: "Buddy", groomerId: "groomer-1", groomerName: "Sarah", services: ["Bath & Brush"], serviceId: "service-1", addOns: [], date: "2027-01-01", time: "10:00 AM", duration: 60, price: 50, status: "confirmed" as const, notes: "", customerNotes: "", confirmationStatus: "pending" as const, }; const PAST_APPT = { ...UPCOMING_APPT, id: "appt-2", date: "2025-01-01", time: "10:00 AM", status: "completed" as const, }; 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(); 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 Authorization 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({ "Authorization": "Bearer test-session-id", }), }) ); }); }); it("does not send Authorization 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({ "Authorization": expect.anything(), }), }) ); }); }); 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(); }); }); 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 Authorization 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({ "Authorization": "Bearer test-session-id", }), }) ); }); }); it("does not send Authorization 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({ "Authorization": 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(); }); }); });