fix(portal): address QA review - isUpcoming time parsing and session header
- Fixed parseTimeTo24Hour to handle 12-hour AM/PM format correctly - Added X-Impersonation-Session-Id header to CustomerNotesSection fetch - Added comprehensive tests for CustomerNotesSection and time parsing - Fixed TypeScript strict null checks for parseTimeTo24Hour Fixes QA review issues: - isUpcoming() now correctly parses 12-hour time format - CustomerNotesSection sends session ID header for auth - Added unit tests for new UI component Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Test note" }} />);
|
||||||
|
expect(screen.getByRole("textbox")).toHaveValue("Test note");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Save Notes button", () => {
|
||||||
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
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(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
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(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
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(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
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(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Existing" }} />);
|
||||||
|
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces 500 character limit", () => {
|
||||||
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
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(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
expect(screen.getByText(/0\/500/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows exceeded character count in red when limit exceeded", () => {
|
||||||
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
||||||
|
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(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "completed" }} />);
|
||||||
|
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render save button for cancelled appointments", () => {
|
||||||
|
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} />);
|
||||||
|
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,13 +9,26 @@ interface Props {
|
|||||||
readOnly: boolean;
|
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" });
|
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 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";
|
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 [notes, setNotes] = useState(appt.customerNotes || "");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saved, setSaved] = 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";
|
const isDisabled = appt.status === "completed" || appt.status === "cancelled";
|
||||||
|
|
||||||
|
function getSessionId(): string | null {
|
||||||
|
return sessionStorage.getItem("impersonationSessionId");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
try {
|
try {
|
||||||
|
const sessionId = getSessionId();
|
||||||
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (sessionId) {
|
||||||
|
headers["X-Impersonation-Session-Id"] = sessionId;
|
||||||
|
}
|
||||||
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
const res = await fetch(`/api/portal/appointments/${appt.id}/notes`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify({ customerNotes: notes }),
|
body: JSON.stringify({ customerNotes: notes }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user