import { test, expect, Page } from "@playwright/test"; /** * Availability Page E2E Tests * * Tests for the admin availability management 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 get tomorrow's date in the format displayed on the page function getTomorrowDisplay(): string { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); return tomorrow.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); } // Helper to get a date string in YYYY-MM-DD format using 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); } test.describe("Availability Page - Admin Access", () => { test.beforeEach(async ({ page }) => { await clearAuth(page); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); }); test("admin can access availability page", async ({ page }) => { await page.goto("/admin/availability"); await expect(page).toHaveURL("/admin/availability"); await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible(); await expect(page.getByText("Configure your available time slots")).toBeVisible(); }); test("admin sees Availability link in nav", async ({ page }) => { await page.goto("/audit"); const availabilityLink = page.locator('a[href="/admin/availability"]'); await expect(availabilityLink).toBeVisible(); }); test("availability page shows calendar grid", async ({ page }) => { await page.goto("/admin/availability"); // Should show tomorrow's date in the calendar const tomorrowText = getTomorrowDisplay(); await expect(page.getByText(tomorrowText)).toBeVisible(); // Should show "No availability" for days without slots await expect(page.getByText("No availability").first()).toBeVisible(); }); test("can open edit modal by clicking a day", async ({ page }) => { await page.goto("/admin/availability"); // Click on the first day card const tomorrowText = getTomorrowDisplay(); await page.getByText(tomorrowText).click(); // Modal should appear await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible(); }); test("can add availability slot", async ({ page }) => { await page.goto("/admin/availability"); // Wait for initial data load to complete await page.waitForLoadState("networkidle"); // Find a day card with "No availability" and click on it // This ensures we're clicking on a day without existing slots const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({ has: page.getByText("No availability") }).first(); await dayCardWithNoAvailability.click(); // Wait for modal await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); // Set up listeners for both PUT and GET before clicking Save to avoid race condition const putPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" ); const getPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" ); await page.getByRole("button", { name: "Save" }).click(); await putPromise; await getPromise; // Wait for modal to close await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); // Should now show the slot (the card we clicked should now have this slot) await expect(page.getByText("09:00 - 17:00")).toBeVisible(); }); test("can clear availability", async ({ page }) => { await page.goto("/admin/availability"); // Wait for initial data load to complete await page.waitForLoadState("networkidle"); // Find a day card with "No availability" and click on it const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({ has: page.getByText("No availability") }).first(); // Get the testid so we can find the same card later const testId = await dayCardWithNoAvailability.getAttribute('data-testid'); const targetCard = page.locator(`[data-testid="${testId}"]`); // First add availability await dayCardWithNoAvailability.click(); await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); // Set up listeners for both PUT and GET before clicking Save to avoid race condition const savePutPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" ); const saveGetPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" ); await page.getByRole("button", { name: "Save" }).click(); await savePutPromise; await saveGetPromise; await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); // Verify slot exists in the specific card we clicked await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible(); // Now clear it - click on the same card using the testid await targetCard.click(); await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); // Set up listeners for both PUT and GET before clicking Clear to avoid race condition const clearPutPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" ); const clearGetPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" ); await page.getByRole("button", { name: "Clear All" }).click(); await clearPutPromise; await clearGetPromise; // Wait for modal to close await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); // Slot should be gone from this specific card await expect(targetCard.getByText("09:00 - 17:00")).not.toBeVisible(); }); test("can add multiple slots", async ({ page }) => { await page.goto("/admin/availability"); // Wait for initial data load to complete await page.waitForLoadState("networkidle"); // Find a day card with "No availability" and click on it (to avoid conflicts with booking tests) const dayCardWithNoAvailability = page.locator('[data-testid^="day-card-"]').filter({ has: page.getByText("No availability") }).first(); const testId = await dayCardWithNoAvailability.getAttribute('data-testid'); const targetCard = page.locator(`[data-testid="${testId}"]`); await dayCardWithNoAvailability.click(); await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); // First slot is 09:00-17:00 by default - change it to morning only const timeSelects = page.locator("select"); await timeSelects.nth(1).selectOption("12:00"); // Change first slot end to 12:00 // Add another slot for afternoon await page.getByText("+ Add Time Range").click(); // Change second slot times to avoid overlap await timeSelects.nth(2).selectOption("14:00"); // Second slot start await timeSelects.nth(3).selectOption("17:00"); // Second slot end // Set up listeners for both PUT and GET before clicking Save to avoid race condition const putPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "PUT" ); const getPromise = page.waitForResponse(resp => resp.url().includes("/api/admin/availability") && resp.request().method() === "GET" ); await page.getByRole("button", { name: "Save" }).click(); await putPromise; await getPromise; await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); // Should see both slots in the card we clicked await expect(targetCard.getByText("09:00 - 12:00")).toBeVisible(); await expect(targetCard.getByText("14:00 - 17:00")).toBeVisible(); }); }); test.describe("Availability Page - Access Control", () => { test("regular user cannot access availability page", async ({ page }) => { await clearAuth(page); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await page.goto("/admin/availability"); // Should be redirected (to counter/home for regular users) await expect(page).not.toHaveURL("/admin/availability"); }); test("regular user does not see Availability link", async ({ page }) => { await clearAuth(page); await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); await page.goto("/"); const availabilityLink = page.locator('a[href="/admin/availability"]'); await expect(availabilityLink).toHaveCount(0); }); test("unauthenticated user redirected to login", async ({ page }) => { await clearAuth(page); await page.goto("/admin/availability"); await expect(page).toHaveURL("/login"); }); }); test.describe("Availability API", () => { test("admin can set availability via API", async ({ page, request }) => { await clearAuth(page); await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); const cookies = await page.context().cookies(); const authCookie = cookies.find(c => c.name === "auth_token"); if (authCookie) { const dateStr = getTomorrowDateStr(); const response = await request.put(`${API_URL}/api/admin/availability`, { headers: { Cookie: `auth_token=${authCookie.value}`, "Content-Type": "application/json", }, data: { date: dateStr, slots: [{ start_time: "10:00:00", end_time: "12:00:00" }], }, }); expect(response.status()).toBe(200); const data = await response.json(); expect(data.date).toBe(dateStr); expect(data.slots).toHaveLength(1); } }); test("regular user cannot access availability API", async ({ page, request }) => { await clearAuth(page); 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 dateStr = getTomorrowDateStr(); const response = await request.get( `${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`, { headers: { Cookie: `auth_token=${authCookie.value}`, }, } ); expect(response.status()).toBe(403); } }); });