2025-12-20 23:54:34 +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-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// 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();
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
// Wait for modal
|
|
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// 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"
|
|
|
|
|
);
|
2025-12-20 23:54:34 +01:00
|
|
|
await page.getByRole("button", { name: "Save" }).click();
|
2025-12-21 01:13:10 +01:00
|
|
|
await putPromise;
|
|
|
|
|
await getPromise;
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
// Wait for modal to close
|
|
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// Should now show the slot (the card we clicked should now have this slot)
|
2025-12-20 23:54:34 +01:00
|
|
|
await expect(page.getByText("09:00 - 17:00")).toBeVisible();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("can clear availability", async ({ page }) => {
|
|
|
|
|
await page.goto("/admin/availability");
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// 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}"]`);
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
// First add availability
|
2025-12-21 01:13:10 +01:00
|
|
|
await dayCardWithNoAvailability.click();
|
2025-12-20 23:54:34 +01:00
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
2025-12-21 01:13:10 +01:00
|
|
|
|
|
|
|
|
// 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"
|
|
|
|
|
);
|
2025-12-20 23:54:34 +01:00
|
|
|
await page.getByRole("button", { name: "Save" }).click();
|
2025-12-21 01:13:10 +01:00
|
|
|
await savePutPromise;
|
|
|
|
|
await saveGetPromise;
|
2025-12-20 23:54:34 +01:00
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// Verify slot exists in the specific card we clicked
|
|
|
|
|
await expect(targetCard.getByText("09:00 - 17:00")).toBeVisible();
|
2025-12-20 23:54:34 +01:00
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// Now clear it - click on the same card using the testid
|
|
|
|
|
await targetCard.click();
|
2025-12-20 23:54:34 +01:00
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible();
|
2025-12-21 01:13:10 +01:00
|
|
|
|
|
|
|
|
// 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"
|
|
|
|
|
);
|
2025-12-20 23:54:34 +01:00
|
|
|
await page.getByRole("button", { name: "Clear All" }).click();
|
2025-12-21 01:13:10 +01:00
|
|
|
await clearPutPromise;
|
|
|
|
|
await clearGetPromise;
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
// Wait for modal to close
|
|
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// Slot should be gone from this specific card
|
|
|
|
|
await expect(targetCard.getByText("09:00 - 17:00")).not.toBeVisible();
|
2025-12-20 23:54:34 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("can add multiple slots", async ({ page }) => {
|
|
|
|
|
await page.goto("/admin/availability");
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// 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();
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// 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"
|
|
|
|
|
);
|
2025-12-20 23:54:34 +01:00
|
|
|
await page.getByRole("button", { name: "Save" }).click();
|
2025-12-21 01:13:10 +01:00
|
|
|
await putPromise;
|
|
|
|
|
await getPromise;
|
2025-12-20 23:54:34 +01:00
|
|
|
await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible();
|
|
|
|
|
|
2025-12-21 01:13:10 +01:00
|
|
|
// 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();
|
2025-12-20 23:54:34 +01:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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) {
|
2025-12-21 00:03:34 +01:00
|
|
|
const dateStr = getTomorrowDateStr();
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
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) {
|
2025-12-21 00:03:34 +01:00
|
|
|
const dateStr = getTomorrowDateStr();
|
2025-12-20 23:54:34 +01:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|