// ABOUTME: E2E tests for calendar functionality including ICS feed and calendar view. // ABOUTME: Tests calendar display, navigation, and ICS subscription features. import { expect, test } from "@playwright/test"; test.describe("calendar", () => { test.describe("unauthenticated", () => { test("calendar page redirects to login when not authenticated", async ({ page, }) => { await page.goto("/calendar"); // Should redirect to /login await expect(page).toHaveURL(/\/login/); }); }); test.describe("authenticated", () => { // These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } // Login via the login page await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); await page.goto("/calendar"); await page.waitForLoadState("networkidle"); }); test("displays calendar page with heading", async ({ page }) => { // Check for the main calendar heading (h1) const heading = page.getByRole("heading", { name: "Calendar", exact: true, }); await expect(heading).toBeVisible(); }); test("shows month view calendar", async ({ page }) => { // Look for calendar grid structure const calendarGrid = page .getByRole("grid") .or(page.locator('[data-testid="month-view"]')); await expect(calendarGrid).toBeVisible(); }); test("shows month and year display", async ({ page }) => { // Calendar should show current month/year const monthYear = page.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 ({ page }) => { // Look for previous/next month buttons const prevButton = page.getByRole("button", { name: /prev|previous|←|back/i, }); const nextButton = page.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 ({ page }) => { const prevButton = page.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 page.waitForTimeout(500); // Verify calendar is still rendered const monthYear = page.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 ({ page }) => { const nextButton = page.getByRole("button", { name: /next|→/i }); const hasNext = await nextButton.isVisible().catch(() => false); if (hasNext) { // Click next await nextButton.click(); // Wait for update await page.waitForTimeout(500); } }); test("shows ICS subscription section", async ({ page }) => { // Look for calendar subscription / ICS section const subscriptionText = page.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 generate or regenerate token button", async ({ page }) => { // Look for generate/regenerate button const tokenButton = page.getByRole("button", { name: /generate|regenerate/i, }); const hasButton = await tokenButton.isVisible().catch(() => false); if (hasButton) { await expect(tokenButton).toBeVisible(); } }); test("shows copy button when URL exists", async ({ page }) => { // Copy button only shows when URL is generated const copyButton = page.getByRole("button", { name: /copy/i }); const hasCopy = await copyButton.isVisible().catch(() => false); if (hasCopy) { await expect(copyButton).toBeVisible(); } }); test("shows back navigation", async ({ page }) => { const backLink = page.getByRole("link", { name: /back|home|dashboard/i }); await expect(backLink).toBeVisible(); }); test("can navigate back to dashboard", async ({ page }) => { const backLink = page.getByRole("link", { name: /back|home|dashboard/i }); await backLink.click(); await expect(page).toHaveURL("/"); }); }); test.describe("ICS endpoint", () => { test("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()); }); test("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()); }); }); test.describe("calendar regenerate token API", () => { test("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 display features", () => { // These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); await page.goto("/calendar"); await page.waitForLoadState("networkidle"); }); test("today is highlighted in calendar view", async ({ page }) => { // 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 = page .locator('[data-today="true"]') .or(page.locator('[aria-current="date"]')) .or(page.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 ({ page }) => { // Calendar days should have phase coloring (background color classes) const dayButtons = page.getByRole("button").filter({ has: page.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 ({ page }) => { // Look for phase legend with phase names const legendText = page.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 ({ page }) => { const todayButton = page.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 ({ page, }) => { // Navigate forward a few months const nextButton = page.getByRole("button", { name: /next|→/i }); const hasNext = await nextButton.isVisible().catch(() => false); if (hasNext) { await nextButton.click(); await page.waitForTimeout(300); await nextButton.click(); await page.waitForTimeout(300); // Look for Today button to return const todayButton = page.getByRole("button", { name: /today/i }); const hasTodayButton = await todayButton.isVisible().catch(() => false); if (hasTodayButton) { await todayButton.click(); await page.waitForTimeout(300); // Should be back to current month const currentMonth = new Date().toLocaleString("default", { month: "long", }); const monthText = page.getByText(new RegExp(currentMonth, "i")); const isCurrentMonth = await monthText .first() .isVisible() .catch(() => false); expect(isCurrentMonth).toBe(true); } } }); }); test.describe("ICS feed content", () => { // These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); await page.goto("/calendar"); await page.waitForLoadState("networkidle"); }); test("can generate calendar URL", async ({ page }) => { // Look for generate button const generateButton = page.getByRole("button", { name: /generate|regenerate/i, }); const hasGenerate = await generateButton.isVisible().catch(() => false); if (hasGenerate) { await generateButton.click(); await page.waitForTimeout(1000); // After generating, URL should be displayed const urlDisplay = page.getByText(/\.ics|calendar.*url/i); const hasUrl = await urlDisplay .first() .isVisible() .catch(() => false); if (hasUrl) { await expect(urlDisplay.first()).toBeVisible(); } } }); test("calendar URL contains user ID and token", async ({ page }) => { // If URL is displayed, verify it has expected format const urlInput = page.locator('input[readonly][value*=".ics"]'); const hasUrlInput = await urlInput.isVisible().catch(() => false); if (hasUrlInput) { const url = await urlInput.inputValue(); // URL should contain /api/calendar/ and end with .ics expect(url).toContain("/api/calendar/"); expect(url).toContain(".ics"); } }); test("copy button copies URL to clipboard", async ({ page, context }) => { // Grant clipboard permissions await context.grantPermissions(["clipboard-read", "clipboard-write"]); const copyButton = page.getByRole("button", { name: /copy/i }); const hasCopy = await copyButton.isVisible().catch(() => false); if (hasCopy) { await copyButton.click(); // Verify clipboard has content (clipboard read may not work in all env) const clipboardContent = await page .evaluate(() => navigator.clipboard.readText()) .catch(() => null); if (clipboardContent) { expect(clipboardContent).toContain(".ics"); } } }); }); test.describe("ICS feed content validation", () => { // These tests fetch and validate actual ICS content test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); await page.goto("/calendar"); await page.waitForLoadState("networkidle"); }); async function getIcsContent( page: import("@playwright/test").Page, ): Promise { // Find the ICS URL from the page const urlInput = page.locator('input[readonly][value*=".ics"]'); const hasUrlInput = await urlInput.isVisible().catch(() => false); if (!hasUrlInput) { // Try generating a token first const generateButton = page.getByRole("button", { name: /generate|regenerate/i, }); const hasGenerate = await generateButton.isVisible().catch(() => false); if (hasGenerate) { await generateButton.click(); await page.waitForTimeout(1500); } } const urlInputAfter = page.locator('input[readonly][value*=".ics"]'); const hasUrlAfter = await urlInputAfter.isVisible().catch(() => false); if (!hasUrlAfter) { return null; } const url = await urlInputAfter.inputValue(); // Fetch the ICS content const response = await page.request.get(url); if (response.ok()) { return await response.text(); } return null; } test("ICS feed contains valid VCALENDAR structure", async ({ page }) => { const icsContent = await getIcsContent(page); if (!icsContent) { test.skip(); return; } // 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 ({ page }) => { const icsContent = await getIcsContent(page); if (!icsContent) { test.skip(); return; } // 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 ({ page }) => { const icsContent = await getIcsContent(page); if (!icsContent) { test.skip(); return; } // 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 ({ page, }) => { const icsContent = await getIcsContent(page); if (!icsContent) { test.skip(); return; } // 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 ({ page }) => { const icsContent = await getIcsContent(page); if (!icsContent) { test.skip(); return; } // 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 ({ page }) => { const icsContent = await getIcsContent(page); if (!icsContent) { test.skip(); return; } // 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 ({ page }) => { // Find the ICS URL from the page const urlInput = page.locator('input[readonly][value*=".ics"]'); const hasUrlInput = await urlInput.isVisible().catch(() => false); if (!hasUrlInput) { const generateButton = page.getByRole("button", { name: /generate|regenerate/i, }); const hasGenerate = await generateButton.isVisible().catch(() => false); if (hasGenerate) { await generateButton.click(); await page.waitForTimeout(1500); } } const urlInputAfter = page.locator('input[readonly][value*=".ics"]'); const hasUrlAfter = await urlInputAfter.isVisible().catch(() => false); if (!hasUrlAfter) { test.skip(); return; } const url = await urlInputAfter.inputValue(); const response = await page.request.get(url); if (response.ok()) { const contentType = response.headers()["content-type"]; expect(contentType).toContain("text/calendar"); } }); }); test.describe("accessibility", () => { test.beforeEach(async ({ page }) => { const email = process.env.TEST_USER_EMAIL; const password = process.env.TEST_USER_PASSWORD; if (!email || !password) { test.skip(); return; } await page.goto("/login"); await page.waitForLoadState("networkidle"); const emailInput = page.getByLabel(/email/i); const hasEmailForm = await emailInput.isVisible().catch(() => false); if (!hasEmailForm) { test.skip(); return; } await emailInput.fill(email); await page.getByLabel(/password/i).fill(password); await page.getByRole("button", { name: /sign in/i }).click(); await page.waitForURL("/", { timeout: 10000 }); await page.goto("/calendar"); await page.waitForLoadState("networkidle"); }); test("calendar grid has proper ARIA role and label", async ({ page }) => { // Calendar should have role="grid" per WAI-ARIA calendar pattern const calendarGrid = page.getByRole("grid", { name: /calendar/i }); await expect(calendarGrid).toBeVisible(); }); test("day cells have descriptive aria-labels", async ({ page }) => { // Day buttons should have descriptive aria-labels including date and phase info const dayButtons = page.locator("button[data-day]"); const hasDayButtons = await dayButtons .first() .isVisible() .catch(() => false); if (!hasDayButtons) { test.skip(); 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 ({ page }) => { // Focus on a day button in the calendar grid const dayButtons = page.locator("button[data-day]"); const hasDayButtons = await dayButtons .first() .isVisible() .catch(() => false); if (!hasDayButtons) { test.skip(); return; } // Click a day button to focus it const calendarGrid = page.getByRole("grid", { name: /calendar/i }); const hasGrid = await calendarGrid.isVisible().catch(() => false); if (!hasGrid) { test.skip(); return; } // Focus the grid and press Tab to focus first day await calendarGrid.focus(); await page.keyboard.press("Tab"); // Get currently focused element const focusedBefore = await page.evaluate(() => { const el = document.activeElement; return el ? el.getAttribute("data-day") : null; }); // Press ArrowRight to move to next day await page.keyboard.press("ArrowRight"); // Get new focused element const focusedAfter = await page.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 ({ page }) => { // Previous and next month buttons should have aria-labels const prevButton = page.getByRole("button", { name: /previous month/i }); const nextButton = page.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(); } }); }); });