// ABOUTME: E2E tests for period logging functionality. // ABOUTME: Tests period start logging, date selection, and period history. import { test as baseTest } from "@playwright/test"; import { expect, test } from "./fixtures"; baseTest.describe("period logging", () => { baseTest.describe("unauthenticated", () => { baseTest( "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/); }, ); }); baseTest.describe("API endpoints", () => { baseTest("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); }); baseTest("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 authenticated", () => { test("dashboard shows period date prompt for new users", async ({ onboardingPage, }) => { await onboardingPage.goto("/"); // Onboarding user has no period data, should see onboarding banner const onboardingBanner = onboardingPage.getByText( /period|log your period|set.*date/i, ); await expect(onboardingBanner.first()).toBeVisible(); }); test("period history page is accessible", async ({ establishedPage }) => { await establishedPage.goto("/period-history"); // Should show period history content await expect(establishedPage.getByRole("heading")).toBeVisible(); }); test("period history shows table or empty state", async ({ establishedPage, }) => { await establishedPage.goto("/period-history"); // Wait for loading to complete await establishedPage.waitForLoadState("networkidle"); // Look for either table or empty state message const table = establishedPage.getByRole("table"); const emptyState = establishedPage.getByText("No period history found"); const totalText = establishedPage.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 ({ establishedPage, }) => { await establishedPage.goto("/period-history"); // Average cycle length is shown when there's enough data const avgText = establishedPage.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 ({ establishedPage }) => { await establishedPage.goto("/period-history"); // Look for back link const backLink = establishedPage.getByRole("link", { name: /back|dashboard|home/i, }); await expect(backLink).toBeVisible(); }); test("can navigate to period history from dashboard", async ({ establishedPage, }) => { // Look for navigation to period history const periodHistoryLink = establishedPage.getByRole("link", { name: /period.*history|history/i, }); const hasLink = await periodHistoryLink.isVisible().catch(() => false); if (hasLink) { await periodHistoryLink.click(); await expect(establishedPage).toHaveURL(/\/period-history/); } }); }); test.describe("period logging flow - onboarding user", () => { test("period date modal opens from dashboard onboarding banner", async ({ onboardingPage, }) => { await onboardingPage.goto("/"); // Onboarding user should see "Set date" button const setDateButton = onboardingPage.getByRole("button", { name: /set date/i, }); await expect(setDateButton).toBeVisible(); // Click the set date button await setDateButton.click(); // Modal should open with "Set Period Date" title const modalTitle = onboardingPage.getByRole("heading", { name: /set period date/i, }); await expect(modalTitle).toBeVisible(); // Should have a date input const dateInput = onboardingPage.locator('input[type="date"]'); await expect(dateInput).toBeVisible(); // Should have Cancel and Save buttons await expect( onboardingPage.getByRole("button", { name: /cancel/i }), ).toBeVisible(); await expect( onboardingPage.getByRole("button", { name: /save/i }), ).toBeVisible(); // Cancel should close the modal await onboardingPage.getByRole("button", { name: /cancel/i }).click(); await expect(modalTitle).not.toBeVisible(); }); test("period date input restricts future dates", async ({ onboardingPage, }) => { await onboardingPage.goto("/"); // Open the modal const setDateButton = onboardingPage.getByRole("button", { name: /set date/i, }); await setDateButton.click(); // Get the date input and check its max attribute const dateInput = onboardingPage.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 onboardingPage.getByRole("button", { name: /cancel/i }).click(); }); test("logging period from modal updates dashboard cycle info", async ({ onboardingPage, }) => { await onboardingPage.goto("/"); // Click the set date button const setDateButton = onboardingPage.getByRole("button", { name: /set date/i, }); await setDateButton.click(); // Wait for modal to be visible const modalTitle = onboardingPage.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 = onboardingPage.locator('input[type="date"]'); await dateInput.fill(dateStr); // Click Save button await onboardingPage.getByRole("button", { name: /save/i }).click(); // Modal should close after successful save await expect(modalTitle).not.toBeVisible({ timeout: 10000 }); // Wait for network activity to settle await onboardingPage.waitForLoadState("networkidle"); // Look for cycle day display (e.g., "Day 8 · Follicular" or similar) // The page fetches /api/cycle/period, then /api/today and /api/user // Content only renders when both todayData and userData are available // Use .first() as the pattern may match multiple elements on the page const cycleInfo = onboardingPage.getByText(/day\s+\d+\s+·/i).first(); await expect(cycleInfo).toBeVisible({ timeout: 15000 }); }); }); test.describe("period logging flow - established user", () => { test("period date cannot be in the future", async ({ establishedPage }) => { // Navigate to period history await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for an "Add Period" or "Log Period" button const addButton = establishedPage.getByRole("button", { name: /add.*period|log.*period|new.*period/i, }); const hasAddButton = await addButton.isVisible().catch(() => false); if (!hasAddButton) { // Established user may have an edit button instead - also valid const editButton = establishedPage.getByRole("button", { name: /edit/i }); const hasEdit = await editButton .first() .isVisible() .catch(() => false); expect(hasEdit).toBe(true); } }); test("period history displays cycle length between periods", async ({ establishedPage, }) => { await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for cycle length column or text const cycleLengthText = establishedPage.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 = establishedPage.getByRole("table"); const hasTable = await table.isVisible().catch(() => false); if (hasTable) { // Table has header for cycle length const header = establishedPage.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 ({ establishedPage, }) => { await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for prediction-related text (early/late, accuracy) const predictionText = establishedPage.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 ({ establishedPage }) => { await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for delete button const deleteButton = establishedPage.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 ({ establishedPage }) => { await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for edit button const editButton = establishedPage.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("edit period modal flow changes date successfully", async ({ establishedPage, }) => { await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for edit button and table to ensure we have data const editButton = establishedPage .getByRole("button", { name: /edit/i }) .first(); await expect(editButton).toBeVisible(); // Get the original date from the first row const firstRow = establishedPage.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 = establishedPage.getByRole("heading", { name: /edit period date/i, }); await expect(editModalTitle).toBeVisible(); // Get the date input in the edit modal const editDateInput = establishedPage.locator("#editDate"); await expect(editDateInput).toBeVisible(); // Calculate a new date (21 days ago to avoid conflicts) const newDate = new Date(); newDate.setDate(newDate.getDate() - 21); const newDateStr = newDate.toISOString().split("T")[0]; // Clear and fill new date await editDateInput.fill(newDateStr); // Click Save in the edit modal await establishedPage.getByRole("button", { name: /save/i }).click(); // Modal should close await expect(editModalTitle).not.toBeVisible(); // Wait for table to refresh await establishedPage.waitForLoadState("networkidle"); // Verify the date changed (the row should have new date text) const updatedDateCell = establishedPage .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 ({ establishedPage, }) => { await establishedPage.goto("/period-history"); await establishedPage.waitForLoadState("networkidle"); // Look for delete button const deleteButton = establishedPage .getByRole("button", { name: /delete/i }) .first(); await expect(deleteButton).toBeVisible(); // Get the total count text before deletion const totalText = establishedPage.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 = establishedPage.getByRole("heading", { name: /delete period/i, }); await expect(confirmModalTitle).toBeVisible(); // Should show warning message const warningText = establishedPage.getByText(/are you sure.*delete/i); await expect(warningText).toBeVisible(); // Should have Cancel and Confirm buttons await expect( establishedPage.getByRole("button", { name: /cancel/i }), ).toBeVisible(); await expect( establishedPage.getByRole("button", { name: /confirm/i }), ).toBeVisible(); // Click Confirm to delete await establishedPage.getByRole("button", { name: /confirm/i }).click(); // Modal should close await expect(confirmModalTitle).not.toBeVisible(); // Wait for page to refresh await establishedPage.waitForLoadState("networkidle"); // If we had a count, verify it decreased if (originalCount > 1) { const newTotalText = establishedPage.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); } } } }); });