All checks were successful
Deploy / deploy (push) Successful in 1m39s
- Fix dailyLog upsert to use range query (matches today route pattern) - Properly distinguish 404 errors from other failures in upsert logic - Add logging for dailyLog create/update operations - Add Settings UI section for weekly intensity goals per phase - Add unit tests for upsert behavior and intensity goals UI - Add E2E tests for intensity goals settings flow This fixes the issue where Garmin sync was creating new dailyLog records instead of updating existing ones (322 vs 222 intensity minutes bug, Unknown HRV bug). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
970 lines
31 KiB
TypeScript
970 lines
31 KiB
TypeScript
// 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");
|
|
});
|
|
});
|
|
});
|