import { test, expect, Page } from "@playwright/test"; import { formatDateLocal, getTomorrowDateStr } from "./helpers/date"; import { API_URL, REGULAR_USER, ADMIN_USER, clearAuth, loginUser } from "./helpers/auth"; /** * Booking Page E2E Tests * * Tests for the user booking page. */ // Set up availability for a date using the API with retry logic async function setAvailability(page: Page, dateStr: string, maxRetries = 3) { 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"); } let lastError: Error | null = null; for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { // Wait before retry await page.waitForTimeout(500); } 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()) { return; // Success } const body = await response.text(); lastError = new Error(`Failed to set availability: ${response.status()} - ${body}`); // Only retry on 500 errors if (response.status() !== 500) { throw lastError; } } throw lastError; } 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 }) => { // First set up availability for tomorrow so we have an enabled date await clearAuth(page); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); await setAvailability(page, getTomorrowDateStr()); await clearAuth(page); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await page.goto("/booking"); // Wait for availability check to complete await page.waitForTimeout(2000); // Find an enabled date button (one with availability) const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ }); let enabledButton = null; const buttonCount = await dateButtons.count(); for (let i = 0; i < buttonCount; i++) { const button = dateButtons.nth(i); const isDisabled = await button.isDisabled().catch(() => true); if (!isDisabled) { enabledButton = button; break; } } // Should have at least one enabled date (tomorrow) expect(enabledButton).not.toBeNull(); await enabledButton!.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"); // Wait for date buttons to load and availability check to complete await page.waitForTimeout(2000); // Find an enabled date button (one that has availability or is still loading) // If all dates are disabled, we can't test clicking, so verify disabled state const dateButtons = page.locator("button").filter({ hasText: /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/ }); const enabledButtons = dateButtons.filter({ hasNotText: /disabled/ }); const enabledCount = await enabledButtons.count(); if (enabledCount > 0) { // Click the first enabled date button await enabledButtons.first().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(); } } else { // All dates are disabled - verify that disabled dates are shown const disabledButtons = dateButtons.filter({ hasText: /disabled/ }); await expect(disabledButtons.first()).toBeDisabled(); } }); }); 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 booking form to disappear (indicates booking completed) await expect(page.getByRole("button", { name: "Book Appointment" })).not.toBeVisible({ timeout: 10000 }); // Wait for success message 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) { // Use 11:45 to avoid conflicts with other tests using 10:00 const response = await request.post(`${API_URL}/api/booking`, { headers: { Cookie: `auth_token=${authCookie.value}`, "Content-Type": "application/json", }, data: { slot_start: `${dateStr}T11:45: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); } }); });