2025-12-21 00:15:29 +01:00
|
|
|
import { test, expect, Page } from "@playwright/test";
|
2025-12-21 17:48:17 +01:00
|
|
|
import { formatDateLocal, getTomorrowDateStr } from "./helpers/date";
|
2025-12-21 00:15:29 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// Set up availability for a date using the API with retry logic
|
|
|
|
|
async function setAvailability(page: Page, dateStr: string, maxRetries = 3) {
|
2025-12-21 00:15:29 +01:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
let lastError: Error | null = null;
|
2025-12-21 00:15:29 +01:00
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-21 00:15:29 +01:00
|
|
|
const body = await response.text();
|
2025-12-21 01:13:10 +01:00
|
|
|
lastError = new Error(`Failed to set availability: ${response.status()} - ${body}`);
|
|
|
|
|
|
|
|
|
|
// Only retry on 500 errors
|
|
|
|
|
if (response.status() !== 500) {
|
|
|
|
|
throw lastError;
|
|
|
|
|
}
|
2025-12-21 00:15:29 +01:00
|
|
|
}
|
2025-12-21 01:13:10 +01:00
|
|
|
|
|
|
|
|
throw lastError;
|
2025-12-21 00:15:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-12-21 00:24:16 +01:00
|
|
|
// Use 11:45 to avoid conflicts with other tests using 10:00
|
2025-12-21 00:15:29 +01:00
|
|
|
const response = await request.post(`${API_URL}/api/booking`, {
|
|
|
|
|
headers: {
|
|
|
|
|
Cookie: `auth_token=${authCookie.value}`,
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
},
|
|
|
|
|
data: {
|
2025-12-21 00:24:16 +01:00
|
|
|
slot_start: `${dateStr}T11:45:00Z`,
|
2025-12-21 00:15:29 +01:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|