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 <noreply@paperclip.ing>
This commit is contained in:
@@ -28,15 +28,6 @@ const PAST_APPT: Appointment = {
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSessionStorage = {
|
|
||||||
getItem: vi.fn(),
|
|
||||||
setItem: vi.fn(),
|
|
||||||
removeItem: vi.fn(),
|
|
||||||
clear: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.stubGlobal("sessionStorage", mockSessionStorage);
|
|
||||||
|
|
||||||
describe("parseTimeTo24Hour", () => {
|
describe("parseTimeTo24Hour", () => {
|
||||||
it("converts AM times correctly", () => {
|
it("converts AM times correctly", () => {
|
||||||
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
|
||||||
@@ -73,17 +64,16 @@ describe("isUpcoming", () => {
|
|||||||
describe("CustomerNotesSection", () => {
|
describe("CustomerNotesSection", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockSessionStorage.getItem.mockReturnValue("test-session-id");
|
|
||||||
global.fetch = vi.fn();
|
global.fetch = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders textarea with existing notes", () => {
|
it("renders textarea with existing notes", () => {
|
||||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Test note" }} />);
|
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Test note" }} sessionId="test-session-id" />);
|
||||||
expect(screen.getByRole("textbox")).toHaveValue("Test note");
|
expect(screen.getByRole("textbox")).toHaveValue("Test note");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders Save Notes button", () => {
|
it("renders Save Notes button", () => {
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeInTheDocument();
|
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() }),
|
json: async () => ({ id: "appt-1", customerNotes: "Updated", updatedAt: new Date().toISOString() }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
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(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId={null} />);
|
||||||
|
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 () => {
|
it("shows error message when save fails", async () => {
|
||||||
vi.mocked(global.fetch).mockResolvedValue({
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -116,7 +128,7 @@ describe("CustomerNotesSection", () => {
|
|||||||
json: async () => ({ error: "Unauthorized" }),
|
json: async () => ({ error: "Unauthorized" }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New note" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
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() }),
|
json: async () => ({ id: "appt-1", customerNotes: "Saved", updatedAt: new Date().toISOString() }),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } });
|
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Saved note" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
fireEvent.click(screen.getByRole("button", { name: /Save Notes/i }));
|
||||||
|
|
||||||
@@ -141,12 +153,12 @@ describe("CustomerNotesSection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("disables button when notes unchanged", () => {
|
it("disables button when notes unchanged", () => {
|
||||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Existing" }} />);
|
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, customerNotes: "Existing" }} sessionId="test-session-id" />);
|
||||||
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled();
|
expect(screen.getByRole("button", { name: /Save Notes/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("enforces 500 character limit", () => {
|
it("enforces 500 character limit", () => {
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
const textarea = screen.getByRole("textbox");
|
const textarea = screen.getByRole("textbox");
|
||||||
const longText = "a".repeat(600);
|
const longText = "a".repeat(600);
|
||||||
fireEvent.change(textarea, { target: { value: longText } });
|
fireEvent.change(textarea, { target: { value: longText } });
|
||||||
@@ -154,12 +166,12 @@ describe("CustomerNotesSection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("displays character count", () => {
|
it("displays character count", () => {
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
expect(screen.getByText(/0\/500/)).toBeInTheDocument();
|
expect(screen.getByText(/0\/500/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows exceeded character count in red when limit exceeded", () => {
|
it("shows exceeded character count in red when limit exceeded", () => {
|
||||||
render(<CustomerNotesSection appointment={UPCOMING_APPT} />);
|
render(<CustomerNotesSection appointment={UPCOMING_APPT} sessionId="test-session-id" />);
|
||||||
const textarea = screen.getByRole("textbox");
|
const textarea = screen.getByRole("textbox");
|
||||||
// Type characters one by one to exceed limit
|
// Type characters one by one to exceed limit
|
||||||
const longText = "a".repeat(501);
|
const longText = "a".repeat(501);
|
||||||
@@ -171,12 +183,12 @@ describe("CustomerNotesSection", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not render save button for completed appointments", () => {
|
it("does not render save button for completed appointments", () => {
|
||||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "completed" }} />);
|
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "completed" }} sessionId="test-session-id" />);
|
||||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render save button for cancelled appointments", () => {
|
it("does not render save button for cancelled appointments", () => {
|
||||||
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} />);
|
render(<CustomerNotesSection appointment={{ ...UPCOMING_APPT, status: "cancelled" }} sessionId="test-session-id" />);
|
||||||
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /Save Notes/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -114,7 +114,7 @@ export function CustomerPortal() {
|
|||||||
case "dashboard":
|
case "dashboard":
|
||||||
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
|
return <Dashboard onNavigate={handleNavClick} readOnly={!!isReadOnly} />;
|
||||||
case "appointments":
|
case "appointments":
|
||||||
return <AppointmentsSection readOnly={!!isReadOnly} />;
|
return <AppointmentsSection readOnly={!!isReadOnly} sessionId={session?.id ?? null} />;
|
||||||
case "pets":
|
case "pets":
|
||||||
return <PetProfiles readOnly={!!isReadOnly} />;
|
return <PetProfiles readOnly={!!isReadOnly} />;
|
||||||
case "reports":
|
case "reports":
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const MAX_CUSTOMER_NOTES = 500;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
sessionId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(dateStr: string): string {
|
export function formatDate(dateStr: string): string {
|
||||||
@@ -40,7 +41,7 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
cancelled: "bg-red-100 text-red-600",
|
cancelled: "bg-red-100 text-red-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppointmentsSection({ readOnly }: Props) {
|
export function AppointmentsSection({ readOnly, sessionId }: Props) {
|
||||||
const [showBooking, setShowBooking] = useState(false);
|
const [showBooking, setShowBooking] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [tab, setTab] = useState<"upcoming" | "past">("upcoming");
|
const [tab, setTab] = useState<"upcoming" | "past">("upcoming");
|
||||||
@@ -82,6 +83,7 @@ export function AppointmentsSection({ readOnly }: Props) {
|
|||||||
expanded={expandedId === appt.id}
|
expanded={expandedId === appt.id}
|
||||||
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
|
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
sessionId={sessionId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{UPCOMING_APPOINTMENTS.length === 0 && (
|
{UPCOMING_APPOINTMENTS.length === 0 && (
|
||||||
@@ -99,6 +101,7 @@ export function AppointmentsSection({ readOnly }: Props) {
|
|||||||
expanded={expandedId === appt.id}
|
expanded={expandedId === appt.id}
|
||||||
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
|
onToggle={() => setExpandedId(expandedId === appt.id ? null : appt.id)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
sessionId={sessionId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -115,9 +118,9 @@ export function AppointmentsSection({ readOnly }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppointmentCard({
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
||||||
@@ -160,7 +163,7 @@ function AppointmentCard({
|
|||||||
<p className="text-sm text-stone-600 bg-stone-50 rounded-lg px-3 py-2 mb-3">{appt.notes}</p>
|
<p className="text-sm text-stone-600 bg-stone-50 rounded-lg px-3 py-2 mb-3">{appt.notes}</p>
|
||||||
)}
|
)}
|
||||||
{isUpcoming(appt) && !readOnly && (
|
{isUpcoming(appt) && !readOnly && (
|
||||||
<CustomerNotesSection appointment={appt} />
|
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
||||||
)}
|
)}
|
||||||
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
|
{appt.status !== "completed" && appt.status !== "cancelled" && !readOnly && (
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
@@ -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 [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);
|
||||||
@@ -193,16 +196,11 @@ export function CustomerNotesSection({ appointment: appt }: { appointment: Appoi
|
|||||||
|
|
||||||
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" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
headers["X-Impersonation-Session-Id"] = sessionId;
|
headers["X-Impersonation-Session-Id"] = sessionId;
|
||||||
|
|||||||
Reference in New Issue
Block a user