// 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"); } } }); }); });