Phase 4: Booking UI for regular users with date selection, slot booking, and e2e tests
This commit is contained in:
parent
06817875f7
commit
8ff03a8ec3
3 changed files with 712 additions and 1 deletions
350
frontend/app/booking/page.tsx
Normal file
350
frontend/app/booking/page.tsx
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Permission } from "../auth-context";
|
||||||
|
import { api } from "../api";
|
||||||
|
import { sharedStyles } from "../styles/shared";
|
||||||
|
import { Header } from "../components/Header";
|
||||||
|
import { useRequireAuth } from "../hooks/useRequireAuth";
|
||||||
|
import { components } from "../generated/api";
|
||||||
|
import constants from "../../../shared/constants.json";
|
||||||
|
|
||||||
|
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays, noteMaxLength } = constants.booking;
|
||||||
|
|
||||||
|
type BookableSlot = components["schemas"]["BookableSlot"];
|
||||||
|
type AvailableSlotsResponse = components["schemas"]["AvailableSlotsResponse"];
|
||||||
|
type AppointmentResponse = components["schemas"]["AppointmentResponse"];
|
||||||
|
|
||||||
|
// Helper to format date as YYYY-MM-DD in local timezone
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format time from ISO string
|
||||||
|
function formatTime(isoString: string): string {
|
||||||
|
const d = new Date(isoString);
|
||||||
|
return d.toLocaleTimeString("en-US", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date range for booking (tomorrow to +30 days)
|
||||||
|
function getBookableDates(): Date[] {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const today = new Date();
|
||||||
|
for (let i = minAdvanceDays; i <= maxAdvanceDays; i++) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(today.getDate() + i);
|
||||||
|
dates.push(d);
|
||||||
|
}
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, isLoading, isAuthorized } = useRequireAuth({
|
||||||
|
requiredPermission: Permission.BOOK_APPOINTMENT,
|
||||||
|
fallbackRedirect: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
const [availableSlots, setAvailableSlots] = useState<BookableSlot[]>([]);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<BookableSlot | null>(null);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [isLoadingSlots, setIsLoadingSlots] = useState(false);
|
||||||
|
const [isBooking, setIsBooking] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const dates = getBookableDates();
|
||||||
|
|
||||||
|
const fetchSlots = useCallback(async (date: Date) => {
|
||||||
|
setIsLoadingSlots(true);
|
||||||
|
setError(null);
|
||||||
|
setAvailableSlots([]);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dateStr = formatDate(date);
|
||||||
|
const data = await api.get<AvailableSlotsResponse>(`/api/booking/slots?date=${dateStr}`);
|
||||||
|
setAvailableSlots(data.slots);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch slots:", err);
|
||||||
|
setError("Failed to load available slots");
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSlots(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDate && user && isAuthorized) {
|
||||||
|
fetchSlots(selectedDate);
|
||||||
|
}
|
||||||
|
}, [selectedDate, user, isAuthorized, fetchSlots]);
|
||||||
|
|
||||||
|
const handleDateSelect = (date: Date) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotSelect = (slot: BookableSlot) => {
|
||||||
|
setSelectedSlot(slot);
|
||||||
|
setNote("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBook = async () => {
|
||||||
|
if (!selectedSlot) return;
|
||||||
|
|
||||||
|
setIsBooking(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appointment = await api.post<AppointmentResponse>("/api/booking", {
|
||||||
|
slot_start: selectedSlot.start_time,
|
||||||
|
note: note || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccessMessage(`Appointment booked for ${formatTime(appointment.slot_start)} - ${formatTime(appointment.slot_end)}`);
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setNote("");
|
||||||
|
|
||||||
|
// Refresh slots to show the booked one is gone
|
||||||
|
if (selectedDate) {
|
||||||
|
await fetchSlots(selectedDate);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError("Failed to book appointment");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsBooking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSlotSelection = () => {
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setNote("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={sharedStyles.pageContainer}>
|
||||||
|
<Header currentPage="booking" />
|
||||||
|
<main style={sharedStyles.mainContent}>
|
||||||
|
<p>Loading...</p>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={sharedStyles.pageContainer}>
|
||||||
|
<Header currentPage="booking" />
|
||||||
|
<main style={sharedStyles.mainContent}>
|
||||||
|
<h1 style={{ marginBottom: "0.5rem" }}>Book an Appointment</h1>
|
||||||
|
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||||
|
Select a date to see available {slotDurationMinutes}-minute slots
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div style={{
|
||||||
|
background: "#d4edda",
|
||||||
|
border: "1px solid #c3e6cb",
|
||||||
|
color: "#155724",
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}>
|
||||||
|
{successMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: "#f8d7da",
|
||||||
|
border: "1px solid #f5c6cb",
|
||||||
|
color: "#721c24",
|
||||||
|
padding: "1rem",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date Selection */}
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>Select a Date</h2>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}>
|
||||||
|
{dates.map((date) => {
|
||||||
|
const isSelected = selectedDate && formatDate(selectedDate) === formatDate(date);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={formatDate(date)}
|
||||||
|
onClick={() => handleDateSelect(date)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: isSelected ? "#e7f3ff" : "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
minWidth: "100px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500 }}>
|
||||||
|
{date.toLocaleDateString("en-US", { weekday: "short" })}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#666" }}>
|
||||||
|
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Slots */}
|
||||||
|
{selectedDate && (
|
||||||
|
<div style={{ marginBottom: "1.5rem" }}>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", marginBottom: "0.75rem" }}>
|
||||||
|
Available Slots for {selectedDate.toLocaleDateString("en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric"
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{isLoadingSlots ? (
|
||||||
|
<p>Loading slots...</p>
|
||||||
|
) : availableSlots.length === 0 ? (
|
||||||
|
<p style={{ color: "#666" }}>No available slots for this date</p>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "0.5rem",
|
||||||
|
}}>
|
||||||
|
{availableSlots.map((slot) => {
|
||||||
|
const isSelected = selectedSlot?.start_time === slot.start_time;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slot.start_time}
|
||||||
|
onClick={() => handleSlotSelect(slot)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
border: isSelected ? "2px solid #0070f3" : "1px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: isSelected ? "#e7f3ff" : "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(slot.start_time)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Booking Form */}
|
||||||
|
{selectedSlot && (
|
||||||
|
<div style={{
|
||||||
|
background: "#f9f9f9",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "1.5rem",
|
||||||
|
maxWidth: "400px",
|
||||||
|
}}>
|
||||||
|
<h3 style={{ marginBottom: "1rem" }}>
|
||||||
|
Confirm Booking
|
||||||
|
</h3>
|
||||||
|
<p style={{ marginBottom: "1rem" }}>
|
||||||
|
<strong>Time:</strong> {formatTime(selectedSlot.start_time)} - {formatTime(selectedSlot.end_time)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", marginBottom: "0.5rem", fontWeight: 500 }}>
|
||||||
|
Note (optional, max {noteMaxLength} chars)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value.slice(0, noteMaxLength))}
|
||||||
|
placeholder="Add a note about your appointment..."
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
minHeight: "80px",
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: note.length >= noteMaxLength ? "#dc3545" : "#666",
|
||||||
|
textAlign: "right",
|
||||||
|
}}>
|
||||||
|
{note.length}/{noteMaxLength}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={handleBook}
|
||||||
|
disabled={isBooking}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "0.75rem",
|
||||||
|
background: isBooking ? "#ccc" : "#0070f3",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: isBooking ? "not-allowed" : "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isBooking ? "Booking..." : "Book Appointment"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelSlotSelection}
|
||||||
|
disabled={isBooking}
|
||||||
|
style={{
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
background: "#fff",
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json";
|
||||||
|
|
||||||
const { ADMIN, REGULAR } = constants.roles;
|
const { ADMIN, REGULAR } = constants.roles;
|
||||||
|
|
||||||
type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites" | "admin-availability";
|
type PageId = "counter" | "sum" | "profile" | "invites" | "booking" | "audit" | "admin-invites" | "admin-availability";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
currentPage: PageId;
|
currentPage: PageId;
|
||||||
|
|
@ -24,6 +24,7 @@ interface NavItem {
|
||||||
const REGULAR_NAV_ITEMS: NavItem[] = [
|
const REGULAR_NAV_ITEMS: NavItem[] = [
|
||||||
{ id: "counter", label: "Counter", href: "/" },
|
{ id: "counter", label: "Counter", href: "/" },
|
||||||
{ id: "sum", label: "Sum", href: "/sum" },
|
{ id: "sum", label: "Sum", href: "/sum" },
|
||||||
|
{ id: "booking", label: "Book", href: "/booking", regularOnly: true },
|
||||||
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
|
{ id: "invites", label: "My Invites", href: "/invites", regularOnly: true },
|
||||||
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
|
{ id: "profile", label: "My Profile", href: "/profile", regularOnly: true },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
360
frontend/e2e/booking.spec.ts
Normal file
360
frontend/e2e/booking.spec.ts
Normal file
|
|
@ -0,0 +1,360 @@
|
||||||
|
import { test, expect, Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking Page E2E Tests
|
||||||
|
*
|
||||||
|
* Tests for the user booking page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
function getRequiredEnv(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Required environment variable ${name} is not set.`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGULAR_USER = {
|
||||||
|
email: getRequiredEnv("DEV_USER_EMAIL"),
|
||||||
|
password: getRequiredEnv("DEV_USER_PASSWORD"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_USER = {
|
||||||
|
email: getRequiredEnv("DEV_ADMIN_EMAIL"),
|
||||||
|
password: getRequiredEnv("DEV_ADMIN_PASSWORD"),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function clearAuth(page: Page) {
|
||||||
|
await page.context().clearCookies();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginUser(page: Page, email: string, password: string) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill('input[type="email"]', email);
|
||||||
|
await page.fill('input[type="password"]', password);
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format date as YYYY-MM-DD in local timezone
|
||||||
|
function formatDateLocal(d: Date): string {
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTomorrowDateStr(): string {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
return formatDateLocal(tomorrow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up availability for a date using the API
|
||||||
|
async function setAvailability(page: Page, dateStr: string) {
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||||
|
|
||||||
|
if (!authCookie) {
|
||||||
|
throw new Error("No auth cookie found when trying to set availability");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await page.request.put(`${API_URL}/api/admin/availability`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
date: dateStr,
|
||||||
|
slots: [{ start_time: "09:00:00", end_time: "12:00:00" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok()) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(`Failed to set availability: ${response.status()} - ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Booking Page - Regular User Access", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regular user can access booking page", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("/booking");
|
||||||
|
await expect(page.getByRole("heading", { name: "Book an Appointment" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regular user sees Book link in navigation", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.getByRole("link", { name: "Book" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("booking page shows date selection", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Select a Date" })).toBeVisible();
|
||||||
|
// Should see multiple date buttons
|
||||||
|
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
||||||
|
await expect(dateButtons.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting date shows slots section", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Click first date button
|
||||||
|
const dateButton = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ }).first();
|
||||||
|
await dateButton.click();
|
||||||
|
|
||||||
|
// Should show Available Slots section (use heading to be specific)
|
||||||
|
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows no slots or message when no availability", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Click a date button - pick a date far in the future to avoid any set availability
|
||||||
|
const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ });
|
||||||
|
// Click the last date button (30 days out, unlikely to have availability)
|
||||||
|
await dateButtons.last().click();
|
||||||
|
|
||||||
|
// Wait for the section to appear
|
||||||
|
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Should either show no slots message OR show no slot buttons
|
||||||
|
// Wait a moment for API to return
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// If no availability is set, we'll see the "No available slots" message
|
||||||
|
const noSlotsMessage = page.getByText("No available slots for this date");
|
||||||
|
const isNoSlotsVisible = await noSlotsMessage.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!isNoSlotsVisible) {
|
||||||
|
// There might be some slots from shared state - just verify the section loads
|
||||||
|
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking Page - With Availability", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
// Login as admin to set availability
|
||||||
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
await setAvailability(page, getTomorrowDateStr());
|
||||||
|
await clearAuth(page);
|
||||||
|
// Login as regular user
|
||||||
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows available slots when availability is set", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Get tomorrow's display name to click the correct button
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
|
// Click tomorrow's date using the weekday name
|
||||||
|
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
|
||||||
|
await dateButton.click();
|
||||||
|
|
||||||
|
// Wait for "Available Slots" section to appear
|
||||||
|
await expect(page.getByRole("heading", { name: /Available Slots for/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Wait for loading to finish (no "Loading slots..." text)
|
||||||
|
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Should see some slot buttons (look for any button with time-like pattern)
|
||||||
|
// The format might be "09:00" or "9:00 AM" depending on locale
|
||||||
|
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
||||||
|
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking slot shows confirmation form", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Get tomorrow's display name
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
|
// Click tomorrow's date
|
||||||
|
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
|
||||||
|
await dateButton.click();
|
||||||
|
|
||||||
|
// Wait for any slot to appear
|
||||||
|
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
||||||
|
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
||||||
|
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click first slot
|
||||||
|
await slotButtons.first().click();
|
||||||
|
|
||||||
|
// Should show confirmation form
|
||||||
|
await expect(page.getByText("Confirm Booking")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Book Appointment" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can book an appointment with note", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Get tomorrow's display name
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
|
// Click tomorrow's date
|
||||||
|
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
|
||||||
|
await dateButton.click();
|
||||||
|
|
||||||
|
// Wait for slots to load
|
||||||
|
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
||||||
|
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
||||||
|
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Click second slot (to avoid booking same slot as other tests)
|
||||||
|
await slotButtons.nth(1).click();
|
||||||
|
|
||||||
|
// Add a note
|
||||||
|
await page.fill("textarea", "Test booking note");
|
||||||
|
|
||||||
|
// Book
|
||||||
|
await page.getByRole("button", { name: "Book Appointment" }).click();
|
||||||
|
|
||||||
|
// Should show success message
|
||||||
|
await expect(page.getByText(/Appointment booked/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("booked slot disappears from available slots", async ({ page }) => {
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Get tomorrow's display name
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const weekday = tomorrow.toLocaleDateString("en-US", { weekday: "short" });
|
||||||
|
|
||||||
|
// Click tomorrow's date
|
||||||
|
const dateButton = page.locator("button").filter({ hasText: new RegExp(`^${weekday}`) }).first();
|
||||||
|
await dateButton.click();
|
||||||
|
|
||||||
|
// Wait for slots to load
|
||||||
|
await expect(page.getByText("Loading slots...")).not.toBeVisible({ timeout: 10000 });
|
||||||
|
const slotButtons = page.locator("button").filter({ hasText: /^\d{1,2}:\d{2}/ });
|
||||||
|
await expect(slotButtons.first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Count initial slots
|
||||||
|
const initialCount = await slotButtons.count();
|
||||||
|
|
||||||
|
// Click any slot (3rd to avoid conflicts)
|
||||||
|
const slotToBook = slotButtons.nth(2);
|
||||||
|
const slotText = await slotToBook.textContent();
|
||||||
|
await slotToBook.click();
|
||||||
|
|
||||||
|
// Book it
|
||||||
|
await page.getByRole("button", { name: "Book Appointment" }).click();
|
||||||
|
|
||||||
|
// Wait for success
|
||||||
|
await expect(page.getByText(/Appointment booked/)).toBeVisible();
|
||||||
|
|
||||||
|
// Should have one less slot now
|
||||||
|
const newCount = await slotButtons.count();
|
||||||
|
expect(newCount).toBe(initialCount - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking Page - Access Control", () => {
|
||||||
|
test("admin cannot access booking page", async ({ page }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
// Should be redirected away (to audit or home)
|
||||||
|
await expect(page).not.toHaveURL("/booking");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin does not see Book link", async ({ page }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
|
||||||
|
await page.goto("/audit");
|
||||||
|
|
||||||
|
await expect(page.getByRole("link", { name: "Book" })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unauthenticated user redirected to login", async ({ page }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
|
||||||
|
await page.goto("/booking");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("/login");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Booking API", () => {
|
||||||
|
test("regular user can book via API", async ({ page, request }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
// Set up availability as admin
|
||||||
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
const dateStr = getTomorrowDateStr();
|
||||||
|
await setAvailability(page, dateStr);
|
||||||
|
await clearAuth(page);
|
||||||
|
|
||||||
|
// Login as regular user
|
||||||
|
await loginUser(page, REGULAR_USER.email, REGULAR_USER.password);
|
||||||
|
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||||
|
|
||||||
|
if (authCookie) {
|
||||||
|
const response = await request.post(`${API_URL}/api/booking`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
slot_start: `${dateStr}T10:00:00Z`,
|
||||||
|
note: "API test booking",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(data.note).toBe("API test booking");
|
||||||
|
expect(data.status).toBe("booked");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin cannot book via API", async ({ page, request }) => {
|
||||||
|
await clearAuth(page);
|
||||||
|
await loginUser(page, ADMIN_USER.email, ADMIN_USER.password);
|
||||||
|
const dateStr = getTomorrowDateStr();
|
||||||
|
await setAvailability(page, dateStr);
|
||||||
|
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const authCookie = cookies.find(c => c.name === "auth_token");
|
||||||
|
|
||||||
|
if (authCookie) {
|
||||||
|
const response = await request.post(`${API_URL}/api/booking`, {
|
||||||
|
headers: {
|
||||||
|
Cookie: `auth_token=${authCookie.value}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
slot_start: `${dateStr}T10:15:00Z`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue