Files
phaseflow/e2e/calendar.spec.ts
Petru Paler ff3d8fad2c Add Playwright fixtures with 5 test user types for e2e tests
Creates test infrastructure to enable previously skipped e2e tests:
- Onboarding user (no period data) for setup flow tests
- Established user (period 14 days ago) for normal usage tests
- Calendar user (with calendarToken) for ICS feed tests
- Garmin user (valid tokens) for connected state tests
- Garmin expired user (expired tokens) for expiry warning tests

Also fixes ICS feed route to strip .ics suffix from Next.js dynamic
route param, adds calendarToken to /api/user response, and sets
viewRule on users collection for unauthenticated ICS access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 05:54:49 +00:00

756 lines
24 KiB
TypeScript

// 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<void> {
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<void> {
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<string | null> {
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();
}
});
});