// ABOUTME: E2E tests for period logging functionality. // ABOUTME: Tests period start logging, date selection, and period history. import { expect, test } from "@playwright/test"; test.describe("period logging", () => { test.describe("unauthenticated", () => { test("period history page redirects to login when not authenticated", async ({ page, }) => { await page.goto("/period-history"); // 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 }); }); test("dashboard shows period date prompt for new users", async ({ page, }) => { // Check if onboarding banner for period date is visible // This depends on whether the test user has period data set const onboardingBanner = page.getByText( /period|log your period|set.*date/i, ); const hasOnboarding = await onboardingBanner .first() .isVisible() .catch(() => false); // Either has onboarding prompt or has cycle data - both are valid states if (hasOnboarding) { await expect(onboardingBanner.first()).toBeVisible(); } }); test("period history page is accessible", async ({ page }) => { await page.goto("/period-history"); // Should show period history content await expect(page.getByRole("heading")).toBeVisible(); }); test("period history shows table or empty state", async ({ page }) => { await page.goto("/period-history"); // Wait for loading to complete await page.waitForLoadState("networkidle"); // Look for either table or empty state message const table = page.getByRole("table"); const emptyState = page.getByText("No period history found"); const totalText = page.getByText(/\d+ periods/); const hasTable = await table.isVisible().catch(() => false); const hasEmpty = await emptyState.isVisible().catch(() => false); const hasTotal = await totalText.isVisible().catch(() => false); // Either table, empty state, or total count should be present expect(hasTable || hasEmpty || hasTotal).toBe(true); }); test("period history shows average cycle length if data exists", async ({ page, }) => { await page.goto("/period-history"); // Average cycle length is shown when there's enough data const avgText = page.getByText(/average.*cycle|cycle.*average|avg/i); const hasAvg = await avgText .first() .isVisible() .catch(() => false); // This is optional - depends on having data if (hasAvg) { await expect(avgText.first()).toBeVisible(); } }); test("period history shows back navigation", async ({ page }) => { await page.goto("/period-history"); // Look for back link const backLink = page.getByRole("link", { name: /back|dashboard|home/i }); await expect(backLink).toBeVisible(); }); test("can navigate to period history from dashboard", async ({ page }) => { // Look for navigation to period history const periodHistoryLink = page.getByRole("link", { name: /period.*history|history/i, }); const hasLink = await periodHistoryLink.isVisible().catch(() => false); if (hasLink) { await periodHistoryLink.click(); await expect(page).toHaveURL(/\/period-history/); } }); }); test.describe("API endpoints", () => { test("period history API requires authentication", async ({ page }) => { const response = await page.request.get("/api/period-history"); // Should return 401 Unauthorized expect(response.status()).toBe(401); }); test("period log API requires authentication", async ({ page }) => { const response = await page.request.post("/api/cycle/period", { data: { startDate: "2024-01-15" }, }); // Should return 401 Unauthorized expect(response.status()).toBe(401); }); }); test.describe("period logging flow", () => { // 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 }); }); test("period date cannot be in the future", async ({ page }) => { // Navigate to period history await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for an "Add Period" or "Log Period" button const addButton = page.getByRole("button", { name: /add.*period|log.*period|new.*period/i, }); const hasAddButton = await addButton.isVisible().catch(() => false); if (!hasAddButton) { // Try dashboard - look for period logging modal trigger await page.goto("/"); await page.waitForLoadState("networkidle"); const periodButton = page.getByRole("button", { name: /log.*period|add.*period/i, }); const hasPeriodButton = await periodButton .isVisible() .catch(() => false); if (!hasPeriodButton) { test.skip(); return; } } }); test("period history displays cycle length between periods", async ({ page, }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for cycle length column or text const cycleLengthText = page.getByText(/cycle.*length|\d+\s*days/i); const hasCycleLength = await cycleLengthText .first() .isVisible() .catch(() => false); // If there's period data, cycle length should be visible const table = page.getByRole("table"); const hasTable = await table.isVisible().catch(() => false); if (hasTable) { // Table has header for cycle length const header = page.getByRole("columnheader", { name: /cycle.*length|days/i, }); const hasHeader = await header.isVisible().catch(() => false); expect(hasHeader || hasCycleLength).toBe(true); } }); test("period history shows prediction accuracy when available", async ({ page, }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for prediction-related text (early/late, accuracy) const predictionText = page.getByText(/early|late|accuracy|predicted/i); const hasPrediction = await predictionText .first() .isVisible() .catch(() => false); // Prediction info may not be visible if not enough data if (hasPrediction) { await expect(predictionText.first()).toBeVisible(); } }); test("can delete period log from history", async ({ page }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for delete button const deleteButton = page.getByRole("button", { name: /delete/i }); const hasDelete = await deleteButton .first() .isVisible() .catch(() => false); if (hasDelete) { // Delete button exists for period entries await expect(deleteButton.first()).toBeVisible(); } }); test("can edit period log from history", async ({ page }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for edit button const editButton = page.getByRole("button", { name: /edit/i }); const hasEdit = await editButton .first() .isVisible() .catch(() => false); if (hasEdit) { // Edit button exists for period entries await expect(editButton.first()).toBeVisible(); } }); test("period date modal opens from dashboard onboarding banner", async ({ page, }) => { // Look for the "Set date" button in onboarding banner const setDateButton = page.getByRole("button", { name: /set date/i }); const hasSetDate = await setDateButton.isVisible().catch(() => false); if (!hasSetDate) { // User may already have period date set - skip if no onboarding banner test.skip(); return; } // Click the set date button await setDateButton.click(); // Modal should open with "Set Period Date" title const modalTitle = page.getByRole("heading", { name: /set period date/i, }); await expect(modalTitle).toBeVisible(); // Should have a date input const dateInput = page.locator('input[type="date"]'); await expect(dateInput).toBeVisible(); // Should have Cancel and Save buttons await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible(); await expect(page.getByRole("button", { name: /save/i })).toBeVisible(); // Cancel should close the modal await page.getByRole("button", { name: /cancel/i }).click(); await expect(modalTitle).not.toBeVisible(); }); test("period date input restricts future dates", async ({ page }) => { // Look for the "Set date" button in onboarding banner const setDateButton = page.getByRole("button", { name: /set date/i }); const hasSetDate = await setDateButton.isVisible().catch(() => false); if (!hasSetDate) { test.skip(); return; } // Open the modal await setDateButton.click(); // Get the date input and check its max attribute const dateInput = page.locator('input[type="date"]'); await expect(dateInput).toBeVisible(); // The max attribute should be set to today's date (YYYY-MM-DD format) const maxValue = await dateInput.getAttribute("max"); expect(maxValue).toBeTruthy(); // Parse max date and verify it's today or earlier const maxDate = new Date(maxValue as string); const today = new Date(); today.setHours(0, 0, 0, 0); maxDate.setHours(0, 0, 0, 0); expect(maxDate.getTime()).toBeLessThanOrEqual(today.getTime()); // Close modal await page.getByRole("button", { name: /cancel/i }).click(); }); test("logging period from modal updates dashboard cycle info", async ({ page, }) => { // Look for the "Set date" button in onboarding banner const setDateButton = page.getByRole("button", { name: /set date/i }); const hasSetDate = await setDateButton.isVisible().catch(() => false); if (!hasSetDate) { // User may already have period date set - skip if no onboarding banner test.skip(); return; } // Click the set date button await setDateButton.click(); // Wait for modal to be visible const modalTitle = page.getByRole("heading", { name: /set period date/i, }); await expect(modalTitle).toBeVisible(); // Calculate a valid date (7 days ago) const testDate = new Date(); testDate.setDate(testDate.getDate() - 7); const dateStr = testDate.toISOString().split("T")[0]; // Fill in the date const dateInput = page.locator('input[type="date"]'); await dateInput.fill(dateStr); // Click Save await page.getByRole("button", { name: /save/i }).click(); // Modal should close await expect(modalTitle).not.toBeVisible(); // Dashboard should now show cycle information (Day X · Phase) await page.waitForLoadState("networkidle"); // Look for cycle day display (e.g., "Day 8 · Follicular" or similar) const cycleInfo = page.getByText(/day\s+\d+\s+·/i); await expect(cycleInfo).toBeVisible({ timeout: 10000 }); }); test("edit period modal flow changes date successfully", async ({ page, }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for edit button and table to ensure we have data const editButton = page.getByRole("button", { name: /edit/i }).first(); const hasEdit = await editButton.isVisible().catch(() => false); if (!hasEdit) { test.skip(); return; } // Get the original date from the first row const firstRow = page.locator("tbody tr").first(); const originalDateCell = firstRow.locator("td").first(); const originalDateText = await originalDateCell.textContent(); // Click edit button await editButton.click(); // Edit modal should appear const editModalTitle = page.getByRole("heading", { name: /edit period date/i, }); await expect(editModalTitle).toBeVisible(); // Get the date input in the edit modal const editDateInput = page.locator("#editDate"); await expect(editDateInput).toBeVisible(); // Calculate a new date (14 days ago) const newDate = new Date(); newDate.setDate(newDate.getDate() - 14); const newDateStr = newDate.toISOString().split("T")[0]; // Clear and fill new date await editDateInput.fill(newDateStr); // Click Save in the edit modal await page.getByRole("button", { name: /save/i }).click(); // Modal should close await expect(editModalTitle).not.toBeVisible(); // Wait for table to refresh await page.waitForLoadState("networkidle"); // Verify the date changed (the row should have new date text) const updatedDateCell = page .locator("tbody tr") .first() .locator("td") .first(); const updatedDateText = await updatedDateCell.textContent(); // If we had original data, verify it changed if (originalDateText) { // Format the new date to match display format (e.g., "Jan 1, 2024") const formattedNewDate = newDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); expect(updatedDateText).toContain( formattedNewDate.split(",")[0].split(" ")[0], ); } }); test("delete period confirmation flow removes entry", async ({ page }) => { await page.goto("/period-history"); await page.waitForLoadState("networkidle"); // Look for delete button const deleteButton = page .getByRole("button", { name: /delete/i }) .first(); const hasDelete = await deleteButton.isVisible().catch(() => false); if (!hasDelete) { test.skip(); return; } // Get the total count text before deletion const totalText = page.getByText(/\d+ periods/); const hasTotal = await totalText.isVisible().catch(() => false); let originalCount = 0; if (hasTotal) { const countMatch = (await totalText.textContent())?.match( /(\d+) periods/, ); if (countMatch) { originalCount = parseInt(countMatch[1], 10); } } // Click delete button await deleteButton.click(); // Confirmation modal should appear const confirmModalTitle = page.getByRole("heading", { name: /delete period/i, }); await expect(confirmModalTitle).toBeVisible(); // Should show warning message const warningText = page.getByText(/are you sure.*delete/i); await expect(warningText).toBeVisible(); // Should have Cancel and Confirm buttons await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible(); await expect( page.getByRole("button", { name: /confirm/i }), ).toBeVisible(); // Click Confirm to delete await page.getByRole("button", { name: /confirm/i }).click(); // Modal should close await expect(confirmModalTitle).not.toBeVisible(); // Wait for page to refresh await page.waitForLoadState("networkidle"); // If we had a count, verify it decreased if (originalCount > 1) { const newTotalText = page.getByText(/\d+ periods/); const newTotalVisible = await newTotalText .isVisible() .catch(() => false); if (newTotalVisible) { const newCountMatch = (await newTotalText.textContent())?.match( /(\d+) periods/, ); if (newCountMatch) { const newCount = parseInt(newCountMatch[1], 10); expect(newCount).toBe(originalCount - 1); } } } }); }); });