// ABOUTME: E2E tests for settings page including preferences and logout. // ABOUTME: Tests form rendering, validation, submission, and logout functionality. import { expect, test } from "@playwright/test"; test.describe("settings", () => { test.describe("unauthenticated", () => { test("redirects to login when not authenticated", async ({ page }) => { await page.goto("/settings"); // Should redirect to /login await expect(page).toHaveURL(/\/login/); }); test("garmin settings redirects to login when not authenticated", async ({ page, }) => { await page.goto("/settings/garmin"); // 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(); // Wait for redirect to dashboard, then navigate to settings await page.waitForURL("/", { timeout: 10000 }); await page.goto("/settings"); await page.waitForLoadState("networkidle"); }); test("displays settings form with required fields", async ({ page }) => { // Check for cycle length input const cycleLengthInput = page.getByLabel(/cycle length/i); await expect(cycleLengthInput).toBeVisible(); // Check for notification time input const notificationTimeInput = page.getByLabel(/notification time/i); await expect(notificationTimeInput).toBeVisible(); // Check for timezone input const timezoneInput = page.getByLabel(/timezone/i); await expect(timezoneInput).toBeVisible(); }); test("shows save button", async ({ page }) => { const saveButton = page.getByRole("button", { name: /save/i }); await expect(saveButton).toBeVisible(); }); test("shows logout button", async ({ page }) => { const logoutButton = page.getByRole("button", { name: /log ?out/i }); await expect(logoutButton).toBeVisible(); }); test("shows link to garmin settings", async ({ page }) => { const garminLink = page.getByRole("link", { name: /manage|garmin/i }); await expect(garminLink).toBeVisible(); }); test("shows back to dashboard link", async ({ page }) => { const backLink = page.getByRole("link", { name: /back|dashboard/i }); await expect(backLink).toBeVisible(); }); test("can update cycle length", async ({ page }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); // Clear and enter new value await cycleLengthInput.fill("30"); // Click save const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Should show success message or no error await page.waitForTimeout(1000); // Either success message or value persisted const errorMessage = page.locator('[role="alert"]').filter({ hasText: /error|failed/i, }); const hasError = await errorMessage.isVisible().catch(() => false); // No error means success expect(hasError).toBe(false); }); test("validates cycle length range", async ({ page }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); // Enter invalid value (too low) await cycleLengthInput.fill("10"); // Click save const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Should show validation error or HTML5 validation await page.waitForTimeout(500); }); test("can navigate to garmin settings", async ({ page }) => { const garminLink = page.getByRole("link", { name: /manage|garmin/i }); await garminLink.click(); await expect(page).toHaveURL(/\/settings\/garmin/); }); test("can navigate back to dashboard", async ({ page }) => { const backLink = page.getByRole("link", { name: /back|dashboard/i }); await backLink.click(); await expect(page).toHaveURL("/"); }); test("logout redirects to login", async ({ page }) => { const logoutButton = page.getByRole("button", { name: /log ?out/i }); await logoutButton.click(); // Should redirect to login page await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); }); }); test.describe("garmin settings", () => { 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 and navigate to garmin settings 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("/settings/garmin"); await page.waitForLoadState("networkidle"); }); test("displays garmin connection status", async ({ page }) => { // Look for connection status indicator const statusText = page.getByText(/connected|not connected|status/i); await expect(statusText.first()).toBeVisible(); }); test("shows back navigation", async ({ page }) => { const backLink = page.getByRole("link", { name: /back|settings/i }); await expect(backLink).toBeVisible(); }); }); test.describe("settings form validation", () => { // 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("/settings"); await page.waitForLoadState("networkidle"); }); test("notification time field accepts valid HH:MM format", async ({ page, }) => { const notificationTimeInput = page.getByLabel(/notification time/i); const isVisible = await notificationTimeInput .isVisible() .catch(() => false); if (!isVisible) { test.skip(); return; } // Enter a valid time await notificationTimeInput.fill("07:00"); const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Wait for save await page.waitForTimeout(1000); // No error should be shown const errorMessage = page.locator('[role="alert"]').filter({ hasText: /error|failed|invalid/i, }); const hasError = await errorMessage.isVisible().catch(() => false); expect(hasError).toBe(false); }); test("cycle length rejects value below minimum (21)", async ({ page }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const isVisible = await cycleLengthInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Enter invalid value (too low) await cycleLengthInput.fill("20"); const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Wait for validation await page.waitForTimeout(500); // Either HTML5 validation or error message should appear // Input should have min attribute or form shows error const inputMin = await cycleLengthInput.getAttribute("min"); if (inputMin) { expect(Number.parseInt(inputMin, 10)).toBeGreaterThanOrEqual(21); } }); test("cycle length rejects value above maximum (45)", async ({ page }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const isVisible = await cycleLengthInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Enter invalid value (too high) await cycleLengthInput.fill("50"); const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Wait for validation await page.waitForTimeout(500); // Input should have max attribute const inputMax = await cycleLengthInput.getAttribute("max"); if (inputMax) { expect(Number.parseInt(inputMax, 10)).toBeLessThanOrEqual(45); } }); test("timezone field is editable", async ({ page }) => { const timezoneInput = page.getByLabel(/timezone/i); const isVisible = await timezoneInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Timezone could be select or input const inputType = await timezoneInput.evaluate((el) => el.tagName.toLowerCase(), ); if (inputType === "select") { // Should have options const options = timezoneInput.locator("option"); const optionCount = await options.count(); expect(optionCount).toBeGreaterThan(0); } else { // Should be able to type in it const isEditable = await timezoneInput.isEditable(); expect(isEditable).toBe(true); } }); test("current cycle length value is displayed", async ({ page }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const isVisible = await cycleLengthInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Should have a current value const value = await cycleLengthInput.inputValue(); // Value should be a number in valid range const numValue = Number.parseInt(value, 10); if (!Number.isNaN(numValue)) { expect(numValue).toBeGreaterThanOrEqual(21); expect(numValue).toBeLessThanOrEqual(45); } }); test("settings changes persist after page reload", async ({ page }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const isVisible = await cycleLengthInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Get current value const originalValue = await cycleLengthInput.inputValue(); // Set a different valid value const newValue = originalValue === "28" ? "30" : "28"; await cycleLengthInput.fill(newValue); // Save const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await page.waitForTimeout(1500); // Reload the page await page.reload(); await page.waitForLoadState("networkidle"); // Check the value persisted const cycleLengthAfter = page.getByLabel(/cycle length/i); const afterValue = await cycleLengthAfter.inputValue(); // Either it persisted or was rejected - check it's a valid number const numValue = Number.parseInt(afterValue, 10); expect(numValue).toBeGreaterThanOrEqual(21); expect(numValue).toBeLessThanOrEqual(45); // Restore original value await cycleLengthAfter.fill(originalValue); await saveButton.click(); await page.waitForTimeout(500); }); test("save button shows loading state during submission", async ({ page, }) => { const saveButton = page.getByRole("button", { name: /save/i }); const isVisible = await saveButton.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Initial state should not be disabled const isDisabledBefore = await saveButton.isDisabled(); expect(isDisabledBefore).toBe(false); // Click save and quickly check for loading state await saveButton.click(); // Wait for submission to complete await page.waitForTimeout(1000); // After completion, button should be enabled again const isDisabledAfter = await saveButton.isDisabled(); expect(isDisabledAfter).toBe(false); }); test("notification time changes persist after page reload", async ({ page, }) => { const notificationTimeInput = page.getByLabel(/notification time/i); const isVisible = await notificationTimeInput .isVisible() .catch(() => false); if (!isVisible) { test.skip(); return; } // Get current value const originalValue = await notificationTimeInput.inputValue(); // Set a different valid time (toggle between 08:00 and 09:00) const newValue = originalValue === "08:00" ? "09:00" : "08:00"; await notificationTimeInput.fill(newValue); // Save and wait for success toast const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Reload the page await page.reload(); await page.waitForLoadState("networkidle"); // Check the value persisted const notificationTimeAfter = page.getByLabel(/notification time/i); const afterValue = await notificationTimeAfter.inputValue(); expect(afterValue).toBe(newValue); // Restore original value await notificationTimeAfter.fill(originalValue); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); }); test("timezone changes persist after page reload", async ({ page }) => { const timezoneInput = page.getByLabel(/timezone/i); const isVisible = await timezoneInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Get current value const originalValue = await timezoneInput.inputValue(); // Set a different timezone (toggle between two common timezones) const newValue = originalValue === "America/New_York" ? "America/Los_Angeles" : "America/New_York"; await timezoneInput.fill(newValue); // Save and wait for success toast const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Reload the page await page.reload(); await page.waitForLoadState("networkidle"); // Check the value persisted const timezoneAfter = page.getByLabel(/timezone/i); const afterValue = await timezoneAfter.inputValue(); expect(afterValue).toBe(newValue); // Restore original value await timezoneAfter.fill(originalValue); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); }); test("multiple settings changes persist after page reload", async ({ page, }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const notificationTimeInput = page.getByLabel(/notification time/i); const timezoneInput = page.getByLabel(/timezone/i); const cycleLengthVisible = await cycleLengthInput .isVisible() .catch(() => false); const notificationTimeVisible = await notificationTimeInput .isVisible() .catch(() => false); const timezoneVisible = await timezoneInput .isVisible() .catch(() => false); if (!cycleLengthVisible || !notificationTimeVisible || !timezoneVisible) { test.skip(); return; } // Get all original values const originalCycleLength = await cycleLengthInput.inputValue(); const originalNotificationTime = await notificationTimeInput.inputValue(); const originalTimezone = await timezoneInput.inputValue(); // Set different values for all fields const newCycleLength = originalCycleLength === "28" ? "30" : "28"; const newNotificationTime = originalNotificationTime === "08:00" ? "09:00" : "08:00"; const newTimezone = originalTimezone === "America/New_York" ? "America/Los_Angeles" : "America/New_York"; await cycleLengthInput.fill(newCycleLength); await notificationTimeInput.fill(newNotificationTime); await timezoneInput.fill(newTimezone); // Save all changes at once and wait for success toast const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Reload the page await page.reload(); await page.waitForLoadState("networkidle"); // Verify all values persisted const cycleLengthAfter = page.getByLabel(/cycle length/i); const notificationTimeAfter = page.getByLabel(/notification time/i); const timezoneAfter = page.getByLabel(/timezone/i); expect(await cycleLengthAfter.inputValue()).toBe(newCycleLength); expect(await notificationTimeAfter.inputValue()).toBe( newNotificationTime, ); expect(await timezoneAfter.inputValue()).toBe(newTimezone); // Restore all original values await cycleLengthAfter.fill(originalCycleLength); await notificationTimeAfter.fill(originalNotificationTime); await timezoneAfter.fill(originalTimezone); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); }); test("cycle length persistence verifies exact saved value", async ({ page, }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const isVisible = await cycleLengthInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Get current value const originalValue = await cycleLengthInput.inputValue(); // Set a specific different valid value const newValue = originalValue === "28" ? "31" : "28"; await cycleLengthInput.fill(newValue); // Save and wait for success toast const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Reload the page await page.reload(); await page.waitForLoadState("networkidle"); // Check the exact value persisted (not just range validation) const cycleLengthAfter = page.getByLabel(/cycle length/i); const afterValue = await cycleLengthAfter.inputValue(); expect(afterValue).toBe(newValue); // Restore original value await cycleLengthAfter.fill(originalValue); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); }); test("settings form shows correct values after save without reload", async ({ page, }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const notificationTimeInput = page.getByLabel(/notification time/i); const cycleLengthVisible = await cycleLengthInput .isVisible() .catch(() => false); const notificationTimeVisible = await notificationTimeInput .isVisible() .catch(() => false); if (!cycleLengthVisible || !notificationTimeVisible) { test.skip(); return; } // Get original values const originalCycleLength = await cycleLengthInput.inputValue(); const originalNotificationTime = await notificationTimeInput.inputValue(); // Change values const newCycleLength = originalCycleLength === "28" ? "29" : "28"; const newNotificationTime = originalNotificationTime === "08:00" ? "10:00" : "08:00"; await cycleLengthInput.fill(newCycleLength); await notificationTimeInput.fill(newNotificationTime); // Save const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await page.waitForTimeout(1500); // Verify values are still showing the new values without reload expect(await cycleLengthInput.inputValue()).toBe(newCycleLength); expect(await notificationTimeInput.inputValue()).toBe( newNotificationTime, ); // Restore original values await cycleLengthInput.fill(originalCycleLength); await notificationTimeInput.fill(originalNotificationTime); await saveButton.click(); await page.waitForTimeout(500); }); }); test.describe("error recovery", () => { 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("/settings"); await page.waitForLoadState("networkidle"); }); test("shows error message and allows retry when save fails", async ({ page, }) => { const cycleLengthInput = page.getByLabel(/cycle length/i); const isVisible = await cycleLengthInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Get original value for restoration const originalValue = await cycleLengthInput.inputValue(); // Intercept the save request and make it fail once, then succeed let failureCount = 0; await page.route("**/api/user", async (route) => { if (route.request().method() === "PATCH") { if (failureCount === 0) { failureCount++; // First request fails with server error await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Server error" }), }); } else { // Subsequent requests succeed - let them through await route.continue(); } } else { await route.continue(); } }); // Change the cycle length const newValue = originalValue === "28" ? "32" : "28"; await cycleLengthInput.fill(newValue); // Click save - should fail const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Wait for error handling to complete await page.waitForTimeout(1000); // The key test is that the form remains usable after a failed save // Error handling may show a toast or just keep the form editable // Verify form is still editable (not stuck in loading state) const isEditable = await cycleLengthInput.isEditable(); expect(isEditable).toBe(true); // Verify save button is enabled for retry const isButtonEnabled = !(await saveButton.isDisabled()); expect(isButtonEnabled).toBe(true); // Try saving again - should succeed this time await saveButton.click(); await page.waitForTimeout(1500); // Form should still be functional const isEditableAfterRetry = await cycleLengthInput.isEditable(); expect(isEditableAfterRetry).toBe(true); // Clean up route interception await page.unroute("**/api/user"); // Restore original value await cycleLengthInput.fill(originalValue); await saveButton.click(); await page.waitForTimeout(500); }); }); test.describe("intensity goals section", () => { 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("/settings"); await page.waitForLoadState("networkidle"); }); test("displays Weekly Intensity Goals section", async ({ page }) => { const sectionHeading = page.getByRole("heading", { name: /weekly intensity goals/i, }); await expect(sectionHeading).toBeVisible(); }); test("displays input for menstrual phase goal", async ({ page }) => { const menstrualInput = page.getByLabel(/menstrual/i); await expect(menstrualInput).toBeVisible(); }); test("displays input for follicular phase goal", async ({ page }) => { const follicularInput = page.getByLabel(/follicular/i); await expect(follicularInput).toBeVisible(); }); test("displays input for ovulation phase goal", async ({ page }) => { const ovulationInput = page.getByLabel(/ovulation/i); await expect(ovulationInput).toBeVisible(); }); test("displays input for early luteal phase goal", async ({ page }) => { const earlyLutealInput = page.getByLabel(/early luteal/i); await expect(earlyLutealInput).toBeVisible(); }); test("displays input for late luteal phase goal", async ({ page }) => { const lateLutealInput = page.getByLabel(/late luteal/i); await expect(lateLutealInput).toBeVisible(); }); test("can modify menstrual phase goal and save", async ({ page }) => { const menstrualInput = page.getByLabel(/menstrual/i); const isVisible = await menstrualInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Get original value const originalValue = await menstrualInput.inputValue(); // Set a different value const newValue = originalValue === "75" ? "80" : "75"; await menstrualInput.fill(newValue); // Save and wait for success toast const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Restore original value await menstrualInput.fill(originalValue); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); }); test("persists intensity goal value after page reload", async ({ page, }) => { const menstrualInput = page.getByLabel(/menstrual/i); const isVisible = await menstrualInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Get original value const originalValue = await menstrualInput.inputValue(); // Set a different value const newValue = originalValue === "75" ? "85" : "75"; await menstrualInput.fill(newValue); // Save and wait for success toast const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Reload the page await page.reload(); await page.waitForLoadState("networkidle"); // Check the value persisted const menstrualAfter = page.getByLabel(/menstrual/i); const afterValue = await menstrualAfter.inputValue(); expect(afterValue).toBe(newValue); // Restore original value await menstrualAfter.fill(originalValue); await saveButton.click(); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); }); test("intensity goal inputs have number type and min attribute", async ({ page, }) => { const menstrualInput = page.getByLabel(/menstrual/i); const isVisible = await menstrualInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Check type attribute const inputType = await menstrualInput.getAttribute("type"); expect(inputType).toBe("number"); // Check min attribute const inputMin = await menstrualInput.getAttribute("min"); expect(inputMin).toBe("0"); }); test("all intensity goal inputs are disabled while saving", async ({ page, }) => { const menstrualInput = page.getByLabel(/menstrual/i); const isVisible = await menstrualInput.isVisible().catch(() => false); if (!isVisible) { test.skip(); return; } // Start saving (slow down the response to catch disabled state) await page.route("**/api/user", async (route) => { if (route.request().method() === "PATCH") { // Delay response to allow testing disabled state await new Promise((resolve) => setTimeout(resolve, 500)); await route.continue(); } else { await route.continue(); } }); const saveButton = page.getByRole("button", { name: /save/i }); await saveButton.click(); // Check inputs are disabled during save await expect(menstrualInput).toBeDisabled(); // Wait for save to complete await expect(page.getByText(/settings saved successfully/i)).toBeVisible({ timeout: 10000, }); // Clean up route interception await page.unroute("**/api/user"); }); }); });