// ABOUTME: E2E tests for calendar functionality including ICS feed and calendar view. // ABOUTME: Tests calendar display, navigation, and ICS subscription features. import { test as baseTest } from "@playwright/test"; import { expect, test } from "./fixtures"; baseTest.describe("calendar", () => { baseTest.describe("unauthenticated", () => { baseTest( "calendar page redirects to login when not authenticated", async ({ page }) => { await page.goto("/calendar"); // Should redirect to /login await expect(page).toHaveURL(/\/login/); }, ); }); baseTest.describe("ICS endpoint", () => { baseTest( "ICS endpoint returns error for invalid user", async ({ page }) => { const response = await page.request.get( "/api/calendar/invalid-user-id/invalid-token.ics", ); // Should return 404 (user not found) or 500 (PocketBase not connected in test env) expect([404, 500]).toContain(response.status()); }, ); baseTest( "ICS endpoint returns error for invalid token", async ({ page }) => { // Need a valid user ID but invalid token - this would require setup // For now, just verify the endpoint exists and returns appropriate error const response = await page.request.get( "/api/calendar/test/invalid.ics", ); // Should return 404 (user not found), 401 (invalid token), or 500 (PocketBase not connected) expect([401, 404, 500]).toContain(response.status()); }, ); }); baseTest.describe("calendar regenerate token API", () => { baseTest("regenerate token requires authentication", async ({ page }) => { const response = await page.request.post( "/api/calendar/regenerate-token", ); // Should return 401 Unauthorized expect(response.status()).toBe(401); }); }); }); test.describe("calendar authenticated", () => { test("displays calendar page with heading", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Check for the main calendar heading (h1) const heading = establishedPage.getByRole("heading", { name: "Calendar", exact: true, }); await expect(heading).toBeVisible(); }); test("shows month view calendar", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Look for calendar grid structure const calendarGrid = establishedPage .getByRole("grid") .or(establishedPage.locator('[data-testid="month-view"]')); await expect(calendarGrid).toBeVisible(); }); test("shows month and year display", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Calendar should show current month/year const monthYear = establishedPage.getByText( /january|february|march|april|may|june|july|august|september|october|november|december/i, ); await expect(monthYear.first()).toBeVisible(); }); test("has navigation controls for months", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Look for previous/next month buttons const prevButton = establishedPage.getByRole("button", { name: /prev|previous|←|back/i, }); const nextButton = establishedPage.getByRole("button", { name: /next|→|forward/i, }); // At least one navigation control should be visible const hasPrev = await prevButton.isVisible().catch(() => false); const hasNext = await nextButton.isVisible().catch(() => false); expect(hasPrev || hasNext).toBe(true); }); test("can navigate to previous month", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); const prevButton = establishedPage.getByRole("button", { name: /prev|previous|←/i, }); const hasPrev = await prevButton.isVisible().catch(() => false); if (hasPrev) { // Click previous month button await prevButton.click(); // Wait for update - verify page doesn't error await establishedPage.waitForTimeout(500); // Verify calendar is still rendered const monthYear = establishedPage.getByText( /january|february|march|april|may|june|july|august|september|october|november|december/i, ); await expect(monthYear.first()).toBeVisible(); } }); test("can navigate to next month", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); const nextButton = establishedPage.getByRole("button", { name: /next|→/i }); const hasNext = await nextButton.isVisible().catch(() => false); if (hasNext) { // Click next await nextButton.click(); // Wait for update await establishedPage.waitForTimeout(500); } }); test("shows ICS subscription section", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Look for calendar subscription / ICS section const subscriptionText = establishedPage.getByText( /subscribe|subscription|calendar.*url|ics/i, ); const hasSubscription = await subscriptionText .first() .isVisible() .catch(() => false); // This may not be visible if user hasn't generated a token if (hasSubscription) { await expect(subscriptionText.first()).toBeVisible(); } }); test("shows back navigation", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); const backLink = establishedPage.getByRole("link", { name: /back|home|dashboard/i, }); await expect(backLink).toBeVisible(); }); test("can navigate back to dashboard", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); const backLink = establishedPage.getByRole("link", { name: /back|home|dashboard/i, }); await backLink.click(); await expect(establishedPage).toHaveURL("/"); }); }); test.describe("calendar display features", () => { test("today is highlighted in calendar view", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Today's date should be highlighted with distinct styling const today = new Date(); const dayNumber = today.getDate().toString(); // Look for today button/cell with special styling const todayCell = establishedPage .locator('[data-today="true"]') .or(establishedPage.locator('[aria-current="date"]')) .or( establishedPage.getByRole("button", { name: new RegExp(`${dayNumber}`), }), ); const hasTodayHighlight = await todayCell .first() .isVisible() .catch(() => false); if (hasTodayHighlight) { await expect(todayCell.first()).toBeVisible(); } }); test("phase colors are visible in calendar days", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Calendar days should have phase coloring (background color classes) const dayButtons = establishedPage.getByRole("button").filter({ has: establishedPage.locator('[class*="bg-"]'), }); const hasColoredDays = await dayButtons .first() .isVisible() .catch(() => false); // If there's cycle data, some days should have color if (hasColoredDays) { await expect(dayButtons.first()).toBeVisible(); } }); test("calendar shows phase legend", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Look for phase legend with phase names const legendText = establishedPage.getByText( /menstrual|follicular|ovulation|luteal/i, ); const hasLegend = await legendText .first() .isVisible() .catch(() => false); if (hasLegend) { await expect(legendText.first()).toBeVisible(); } }); test("calendar has Today button for quick navigation", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); const todayButton = establishedPage.getByRole("button", { name: /today/i }); const hasTodayButton = await todayButton.isVisible().catch(() => false); if (hasTodayButton) { await expect(todayButton).toBeVisible(); } }); test("can navigate multiple months and return to today", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Navigate forward a few months const nextButton = establishedPage.getByRole("button", { name: /next|→/i }); const hasNext = await nextButton.isVisible().catch(() => false); if (hasNext) { await nextButton.click(); await establishedPage.waitForTimeout(300); await nextButton.click(); await establishedPage.waitForTimeout(300); // Look for Today button to return const todayButton = establishedPage.getByRole("button", { name: /today/i, }); const hasTodayButton = await todayButton.isVisible().catch(() => false); if (hasTodayButton) { await todayButton.click(); await establishedPage.waitForTimeout(300); // Should be back to current month const currentMonth = new Date().toLocaleString("default", { month: "long", }); const monthText = establishedPage.getByText( new RegExp(currentMonth, "i"), ); const isCurrentMonth = await monthText .first() .isVisible() .catch(() => false); expect(isCurrentMonth).toBe(true); } } }); }); test.describe("ICS feed - generate token flow", () => { test("can generate calendar URL", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Established user has no token - should see generate button const generateButton = establishedPage.getByRole("button", { name: /generate/i, }); await expect(generateButton).toBeVisible(); await generateButton.click(); await establishedPage.waitForTimeout(1000); // After generating, URL should be displayed const urlDisplay = establishedPage.getByText(/\.ics|calendar.*url/i); await expect(urlDisplay.first()).toBeVisible(); }); test("shows generate or regenerate token button", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Look for generate/regenerate button const tokenButton = establishedPage.getByRole("button", { name: /generate|regenerate/i, }); await expect(tokenButton).toBeVisible(); }); }); test.describe("ICS feed - with token", () => { // Helper to ensure URL is generated async function ensureCalendarUrlGenerated( page: import("@playwright/test").Page, ): Promise { const urlInput = page.getByRole("textbox"); const hasUrl = await urlInput.isVisible().catch(() => false); if (!hasUrl) { // Generate the URL if not present const generateButton = page.getByRole("button", { name: /generate/i }); if (await generateButton.isVisible().catch(() => false)) { await generateButton.click(); await page.waitForTimeout(1000); } } } test("calendar URL is displayed after generation", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(establishedPage); // URL should now be visible const urlInput = establishedPage.getByRole("textbox"); await expect(urlInput).toBeVisible(); }); test("calendar URL contains user ID and token", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(establishedPage); const urlInput = establishedPage.getByRole("textbox"); await expect(urlInput).toBeVisible(); const url = await urlInput.inputValue(); // URL should contain /api/calendar/ and end with .ics expect(url).toContain("/api/calendar/"); expect(url).toContain(".ics"); }); test("shows copy button when URL exists", async ({ establishedPage }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(establishedPage); // Copy button should be visible after generating token const copyButton = establishedPage.getByRole("button", { name: /copy/i }); await expect(copyButton).toBeVisible(); }); test("copy button copies URL to clipboard", async ({ establishedPage, context, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(establishedPage); // Grant clipboard permissions await context.grantPermissions(["clipboard-read", "clipboard-write"]); const copyButton = establishedPage.getByRole("button", { name: /copy/i }); await expect(copyButton).toBeVisible(); await copyButton.click(); // Verify clipboard has content (clipboard read may not work in all env) const clipboardContent = await establishedPage .evaluate(() => navigator.clipboard.readText()) .catch(() => null); if (clipboardContent) { expect(clipboardContent).toContain(".ics"); } }); test("shows regenerate button after generating token", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(establishedPage); // User with token should see regenerate option const regenerateButton = establishedPage.getByRole("button", { name: /regenerate/i, }); await expect(regenerateButton).toBeVisible(); }); }); test.describe("ICS feed content validation", () => { // Helper to ensure URL is generated async function ensureCalendarUrlGenerated( page: import("@playwright/test").Page, ): Promise { const urlInput = page.getByRole("textbox"); const hasUrl = await urlInput.isVisible().catch(() => false); if (!hasUrl) { const generateButton = page.getByRole("button", { name: /generate/i }); if (await generateButton.isVisible().catch(() => false)) { await generateButton.click(); await page.waitForTimeout(1000); } } } async function getIcsContent( page: import("@playwright/test").Page, ): Promise { const urlInput = page.getByRole("textbox"); const hasUrlInput = await urlInput.isVisible().catch(() => false); if (!hasUrlInput) { return null; } const url = await urlInput.inputValue(); const response = await page.request.get(url); if (response.ok()) { return await response.text(); } return null; } test("ICS feed contains valid VCALENDAR structure", async ({ calendarPage, }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const icsContent = await getIcsContent(calendarPage); expect(icsContent).not.toBeNull(); // Verify basic ICS structure expect(icsContent).toContain("BEGIN:VCALENDAR"); expect(icsContent).toContain("END:VCALENDAR"); expect(icsContent).toContain("VERSION:2.0"); expect(icsContent).toContain("PRODID:"); }); test("ICS feed contains VEVENT entries", async ({ calendarPage }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const icsContent = await getIcsContent(calendarPage); expect(icsContent).not.toBeNull(); // Should have at least some events expect(icsContent).toContain("BEGIN:VEVENT"); expect(icsContent).toContain("END:VEVENT"); }); test("ICS feed contains phase events with emojis", async ({ calendarPage, }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const icsContent = await getIcsContent(calendarPage); expect(icsContent).not.toBeNull(); // Per calendar.md spec, events should have emojis: // 🩸 Menstrual, 🌱 Follicular, 🌸 Ovulation, 🌙 Early Luteal, 🌑 Late Luteal const phaseEmojis = ["🩸", "🌱", "🌸", "🌙", "🌑"]; const hasEmojis = phaseEmojis.some((emoji) => icsContent?.includes(emoji)); expect(hasEmojis).toBe(true); }); test("ICS feed has CATEGORIES for calendar color coding", async ({ calendarPage, }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const icsContent = await getIcsContent(calendarPage); expect(icsContent).not.toBeNull(); // Per calendar.md spec, phases have color categories: // Red, Green, Pink, Yellow, Orange const colorCategories = ["Red", "Green", "Pink", "Yellow", "Orange"]; const hasCategories = colorCategories.some((color) => icsContent?.includes(`CATEGORIES:${color}`), ); // If user has cycle data, categories should be present if ( icsContent?.includes("MENSTRUAL") || icsContent?.includes("FOLLICULAR") ) { expect(hasCategories).toBe(true); } }); test("ICS feed spans approximately 90 days", async ({ calendarPage }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const icsContent = await getIcsContent(calendarPage); expect(icsContent).not.toBeNull(); // Count DTSTART entries to estimate event span const dtStartMatches = icsContent?.match(/DTSTART/g); // Should have multiple events (phases + warnings) // 3 months of phases (~15 phase events) + warning events if (dtStartMatches) { expect(dtStartMatches.length).toBeGreaterThan(5); } }); test("ICS feed includes warning events", async ({ calendarPage }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const icsContent = await getIcsContent(calendarPage); expect(icsContent).not.toBeNull(); // Per ics.ts, warning events include these phrases const warningIndicators = [ "Late Luteal Phase", "CRITICAL PHASE", "⚠️", "🔴", ]; const hasWarnings = warningIndicators.some((indicator) => icsContent?.includes(indicator), ); // Warnings should be present if feed has events if (icsContent?.includes("BEGIN:VEVENT")) { expect(hasWarnings).toBe(true); } }); test("ICS content type is text/calendar", async ({ calendarPage }) => { await calendarPage.goto("/calendar"); await calendarPage.waitForLoadState("networkidle"); await ensureCalendarUrlGenerated(calendarPage); const urlInput = calendarPage.getByRole("textbox"); await expect(urlInput).toBeVisible(); const url = await urlInput.inputValue(); const response = await calendarPage.request.get(url); expect(response.ok()).toBe(true); const contentType = response.headers()["content-type"]; expect(contentType).toContain("text/calendar"); }); }); test.describe("calendar accessibility", () => { test("calendar grid has proper ARIA role and label", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Calendar should have role="grid" per WAI-ARIA calendar pattern const calendarGrid = establishedPage.getByRole("grid", { name: /calendar/i, }); await expect(calendarGrid).toBeVisible(); }); test("day cells have descriptive aria-labels", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Day buttons should have descriptive aria-labels including date and phase info const dayButtons = establishedPage.locator("button[data-day]"); const hasDayButtons = await dayButtons .first() .isVisible() .catch(() => false); if (!hasDayButtons) { // No day buttons with data-day attribute - test different selector return; } // Get the first visible day button's aria-label const firstDayButton = dayButtons.first(); const ariaLabel = await firstDayButton.getAttribute("aria-label"); // Aria-label should contain date information (month and year) expect(ariaLabel).toMatch( /january|february|march|april|may|june|july|august|september|october|november|december/i, ); expect(ariaLabel).toMatch(/\d{4}/); // Should contain year }); test("keyboard navigation with arrow keys works", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Focus on a day button in the calendar grid const dayButtons = establishedPage.locator("button[data-day]"); const hasDayButtons = await dayButtons .first() .isVisible() .catch(() => false); if (!hasDayButtons) { return; } // Click a day button to focus it const calendarGrid = establishedPage.getByRole("grid", { name: /calendar/i, }); const hasGrid = await calendarGrid.isVisible().catch(() => false); if (!hasGrid) { return; } // Focus the grid and press Tab to focus first day await calendarGrid.focus(); await establishedPage.keyboard.press("Tab"); // Get currently focused element const focusedBefore = await establishedPage.evaluate(() => { const el = document.activeElement; return el ? el.getAttribute("data-day") : null; }); // Press ArrowRight to move to next day await establishedPage.keyboard.press("ArrowRight"); // Get new focused element const focusedAfter = await establishedPage.evaluate(() => { const el = document.activeElement; return el ? el.getAttribute("data-day") : null; }); // If both values exist, verify navigation occurred if (focusedBefore && focusedAfter) { expect(focusedAfter).not.toBe(focusedBefore); } }); test("navigation buttons have accessible labels", async ({ establishedPage, }) => { await establishedPage.goto("/calendar"); await establishedPage.waitForLoadState("networkidle"); // Previous and next month buttons should have aria-labels const prevButton = establishedPage.getByRole("button", { name: /previous month/i, }); const nextButton = establishedPage.getByRole("button", { name: /next month/i, }); const hasPrev = await prevButton.isVisible().catch(() => false); const hasNext = await nextButton.isVisible().catch(() => false); // At least one navigation button should be accessible expect(hasPrev || hasNext).toBe(true); if (hasPrev) { await expect(prevButton).toBeVisible(); } if (hasNext) { await expect(nextButton).toBeVisible(); } }); });