Fix E2E test reliability issues and stale data bugs
- Fix race conditions: Set workers: 1 since all tests share test user state - Fix stale data: GET /api/user and /api/cycle/current now fetch fresh data from database instead of returning stale PocketBase auth store cache - Fix timing: Replace waitForTimeout with retry-based Playwright assertions - Fix mobile test: Use exact heading match to avoid strict mode violation - Add test user setup: Include notificationTime and update rule for users All 1014 unit tests and 190 E2E tests pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -147,6 +147,11 @@ These are optional enhancements to improve E2E coverage. Not required for featur
|
|||||||
|
|
||||||
## Revision History
|
## Revision History
|
||||||
|
|
||||||
|
- 2026-01-13: Fixed E2E test reliability issues:
|
||||||
|
- Race conditions: Changed to single worker execution (tests share test user state)
|
||||||
|
- Stale data: GET /api/user and GET /api/cycle/current now fetch fresh data from database instead of stale auth cache
|
||||||
|
- Timing: Replaced fixed waitForTimeout calls with retry-based Playwright assertions
|
||||||
|
- Mobile test: Fixed strict mode violation by using exact heading match
|
||||||
- 2026-01-13: Marked notifications.spec.ts as redundant (notification preferences already covered in settings.spec.ts)
|
- 2026-01-13: Marked notifications.spec.ts as redundant (notification preferences already covered in settings.spec.ts)
|
||||||
- 2026-01-13: Added dark-mode.spec.ts with 2 E2E tests (system preference detection for light/dark mode)
|
- 2026-01-13: Added dark-mode.spec.ts with 2 E2E tests (system preference detection for light/dark mode)
|
||||||
- 2026-01-13: Added 4 Garmin E2E tests (network error recovery for save, disconnect, status fetch, retry)
|
- 2026-01-13: Added 4 Garmin E2E tests (network error recovery for save, disconnect, status fetch, retry)
|
||||||
|
|||||||
@@ -129,12 +129,13 @@ test.describe("dashboard", () => {
|
|||||||
// Click the toggle
|
// Click the toggle
|
||||||
await toggleCheckbox.click();
|
await toggleCheckbox.click();
|
||||||
|
|
||||||
// Wait a moment for the API call
|
// Wait for the checkbox state to change using retry-based assertion
|
||||||
await page.waitForTimeout(500);
|
// The API call completes and React re-renders asynchronously
|
||||||
|
if (initialChecked) {
|
||||||
// Toggle should change state
|
await expect(toggleCheckbox).not.toBeChecked({ timeout: 5000 });
|
||||||
const afterChecked = await toggleCheckbox.isChecked();
|
} else {
|
||||||
expect(afterChecked).not.toBe(initialChecked);
|
await expect(toggleCheckbox).toBeChecked({ timeout: 5000 });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
test.skip();
|
test.skip();
|
||||||
}
|
}
|
||||||
@@ -244,59 +245,39 @@ test.describe("dashboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("displays cycle day in 'Day X' format", async ({ page }) => {
|
test("displays cycle day in 'Day X' format", async ({ page }) => {
|
||||||
|
// Wait for dashboard to finish loading
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for "Day" followed by a number
|
// Wait for either cycle day or onboarding - both are valid states
|
||||||
const cycleDayText = page.getByText(/Day \d+/i);
|
// Use Playwright's expect with retry for reliable detection
|
||||||
const hasCycleDay = await cycleDayText
|
const cycleDayText = page.getByText(/Day \d+/i).first();
|
||||||
.first()
|
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
// Either has cycle day or onboarding (both valid states)
|
try {
|
||||||
if (!hasCycleDay) {
|
// First try waiting for cycle day with a short timeout
|
||||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
await expect(cycleDayText).toBeVisible({ timeout: 5000 });
|
||||||
const hasOnboarding = await onboarding
|
} catch {
|
||||||
.first()
|
// If no cycle day, expect onboarding banner
|
||||||
.isVisible()
|
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||||
.catch(() => false);
|
|
||||||
expect(hasCycleDay || hasOnboarding).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("displays current phase name", async ({ page }) => {
|
test("displays current phase name", async ({ page }) => {
|
||||||
|
// Wait for dashboard to finish loading
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for phase names
|
// Wait for either a phase name or onboarding - both are valid states
|
||||||
const phaseNames = [
|
const phaseRegex =
|
||||||
"MENSTRUAL",
|
/MENSTRUAL|FOLLICULAR|OVULATION|EARLY_LUTEAL|LATE_LUTEAL/i;
|
||||||
"FOLLICULAR",
|
const phaseText = page.getByText(phaseRegex).first();
|
||||||
"OVULATION",
|
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||||
"EARLY_LUTEAL",
|
|
||||||
"LATE_LUTEAL",
|
|
||||||
];
|
|
||||||
let foundPhase = false;
|
|
||||||
|
|
||||||
for (const phase of phaseNames) {
|
try {
|
||||||
const phaseText = page.getByText(new RegExp(phase, "i"));
|
// First try waiting for a phase name with a short timeout
|
||||||
const isVisible = await phaseText
|
await expect(phaseText).toBeVisible({ timeout: 5000 });
|
||||||
.first()
|
} catch {
|
||||||
.isVisible()
|
// If no phase, expect onboarding banner
|
||||||
.catch(() => false);
|
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||||
if (isVisible) {
|
|
||||||
foundPhase = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Either has phase or shows onboarding
|
|
||||||
if (!foundPhase) {
|
|
||||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
|
||||||
const hasOnboarding = await onboarding
|
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(foundPhase || hasOnboarding).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,81 +362,62 @@ test.describe("dashboard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("displays seed cycling recommendation", async ({ page }) => {
|
test("displays seed cycling recommendation", async ({ page }) => {
|
||||||
|
// Wait for dashboard to finish loading
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for seed names (flax, pumpkin, sesame, sunflower)
|
// Wait for either seed info or onboarding - both are valid states
|
||||||
const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i);
|
const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i).first();
|
||||||
const hasSeeds = await seedText
|
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
// Either has seeds info or onboarding
|
try {
|
||||||
if (!hasSeeds) {
|
await expect(seedText).toBeVisible({ timeout: 5000 });
|
||||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
} catch {
|
||||||
const hasOnboarding = await onboarding
|
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasSeeds || hasOnboarding).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("displays carbohydrate range", async ({ page }) => {
|
test("displays carbohydrate range", async ({ page }) => {
|
||||||
|
// Wait for dashboard to finish loading
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for carb-related text
|
// Wait for either carb info or onboarding - both are valid states
|
||||||
const carbText = page.getByText(/carb|carbohydrate/i);
|
const carbText = page.getByText(/carb|carbohydrate/i).first();
|
||||||
const hasCarbs = await carbText
|
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (!hasCarbs) {
|
try {
|
||||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
await expect(carbText).toBeVisible({ timeout: 5000 });
|
||||||
const hasOnboarding = await onboarding
|
} catch {
|
||||||
.first()
|
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasCarbs || hasOnboarding).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("displays keto guidance", async ({ page }) => {
|
test("displays keto guidance", async ({ page }) => {
|
||||||
|
// Wait for dashboard to finish loading
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for keto-related text
|
// Wait for either keto info or onboarding - both are valid states
|
||||||
const ketoText = page.getByText(/keto/i);
|
const ketoText = page.getByText(/keto/i).first();
|
||||||
const hasKeto = await ketoText
|
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
|
|
||||||
if (!hasKeto) {
|
try {
|
||||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
await expect(ketoText).toBeVisible({ timeout: 5000 });
|
||||||
const hasOnboarding = await onboarding
|
} catch {
|
||||||
.first()
|
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasKeto || hasOnboarding).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("displays nutrition section header", async ({ page }) => {
|
test("displays nutrition section header", async ({ page }) => {
|
||||||
|
// Wait for dashboard to finish loading
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Nutrition panel should have a header
|
// Wait for nutrition header or text
|
||||||
const nutritionHeader = page.getByRole("heading", { name: /nutrition/i });
|
const nutritionHeader = page.getByRole("heading", { name: /nutrition/i });
|
||||||
const hasHeader = await nutritionHeader.isVisible().catch(() => false);
|
const nutritionText = page.getByText(/nutrition/i).first();
|
||||||
|
|
||||||
if (!hasHeader) {
|
try {
|
||||||
// May be text label instead of heading
|
await expect(nutritionHeader).toBeVisible({ timeout: 5000 });
|
||||||
const nutritionText = page.getByText(/nutrition/i);
|
} catch {
|
||||||
const hasText = await nutritionText
|
await expect(nutritionText).toBeVisible({ timeout: 5000 });
|
||||||
.first()
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
expect(hasHeader || hasText).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ test.describe("garmin connection", () => {
|
|||||||
await page.waitForURL("/", { timeout: 10000 });
|
await page.waitForURL("/", { timeout: 10000 });
|
||||||
await page.goto("/settings/garmin");
|
await page.goto("/settings/garmin");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Clean up: Disconnect if already connected to ensure clean state
|
||||||
|
const disconnectButton = page.getByRole("button", {
|
||||||
|
name: /disconnect/i,
|
||||||
|
});
|
||||||
|
const isConnected = await disconnectButton.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
await disconnectButton.click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
// Wait for disconnect to complete
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows not connected initially for new user", async ({ page }) => {
|
test("shows not connected initially for new user", async ({ page }) => {
|
||||||
|
|||||||
@@ -140,15 +140,18 @@ test.describe("mobile viewport", () => {
|
|||||||
const viewportSize = page.viewportSize();
|
const viewportSize = page.viewportSize();
|
||||||
expect(viewportSize?.width).toBe(375);
|
expect(viewportSize?.width).toBe(375);
|
||||||
|
|
||||||
// Calendar heading should be visible
|
// Calendar page title heading should be visible (exact match to avoid "Calendar Subscription")
|
||||||
const heading = page.getByRole("heading", { name: /calendar/i });
|
const heading = page.getByRole("heading", {
|
||||||
await expect(heading).toBeVisible();
|
name: "Calendar",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Calendar grid should be visible
|
// Calendar grid should be visible
|
||||||
const calendarGrid = page
|
const calendarGrid = page
|
||||||
.getByRole("grid")
|
.getByRole("grid")
|
||||||
.or(page.locator('[data-testid="month-view"]'));
|
.or(page.locator('[data-testid="month-view"]'));
|
||||||
await expect(calendarGrid).toBeVisible();
|
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Month navigation should be visible
|
// Month navigation should be visible
|
||||||
const monthYear = page.getByText(
|
const monthYear = page.getByText(
|
||||||
|
|||||||
@@ -53,31 +53,32 @@ test.describe("plan page", () => {
|
|||||||
test("shows current cycle status section", async ({ page }) => {
|
test("shows current cycle status section", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for Current Status section
|
// Wait for page to finish loading - look for Current Status or error state
|
||||||
const statusSection = page.getByRole("heading", {
|
const statusSection = page.getByRole("heading", {
|
||||||
name: "Current Status",
|
name: "Current Status",
|
||||||
});
|
});
|
||||||
const hasStatus = await statusSection.isVisible().catch(() => false);
|
// Use text content to find error alert (avoid Next.js route announcer)
|
||||||
|
const errorAlert = page.getByText(/error:/i);
|
||||||
|
|
||||||
if (hasStatus) {
|
try {
|
||||||
await expect(statusSection).toBeVisible();
|
// Wait for Current Status section to be visible (data loaded successfully)
|
||||||
|
await expect(statusSection).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Should show day number
|
// Should show day number
|
||||||
await expect(page.getByText(/day \d+/i)).toBeVisible();
|
await expect(page.getByText(/day \d+/i)).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
// Should show training type
|
// Should show training type
|
||||||
await expect(page.getByText(/training type:/i)).toBeVisible();
|
await expect(page.getByText(/training type:/i)).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
// Should show weekly limit
|
// Should show weekly limit
|
||||||
await expect(page.getByText(/weekly limit:/i)).toBeVisible();
|
await expect(page.getByText(/weekly limit:/i)).toBeVisible({
|
||||||
} else {
|
timeout: 5000,
|
||||||
// If no status, should see loading or error state
|
});
|
||||||
const loading = page.getByText(/loading/i);
|
} catch {
|
||||||
const error = page.getByRole("alert");
|
// If status section not visible, check for error alert
|
||||||
const hasLoading = await loading.isVisible().catch(() => false);
|
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||||
const hasError = await error.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
expect(hasLoading || hasError).toBe(true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ async function addUserFields(pb: PocketBase): Promise<void> {
|
|||||||
* Sets up API rules for collections to allow user access.
|
* Sets up API rules for collections to allow user access.
|
||||||
*/
|
*/
|
||||||
async function setupApiRules(pb: PocketBase): Promise<void> {
|
async function setupApiRules(pb: PocketBase): Promise<void> {
|
||||||
|
// Allow users to update their own user record
|
||||||
|
const usersCollection = await pb.collections.getOne("users");
|
||||||
|
await pb.collections.update(usersCollection.id, {
|
||||||
|
updateRule: "id = @request.auth.id",
|
||||||
|
});
|
||||||
|
|
||||||
// Allow users to read/write their own period_logs
|
// Allow users to read/write their own period_logs
|
||||||
const periodLogs = await pb.collections.getOne("period_logs");
|
const periodLogs = await pb.collections.getOne("period_logs");
|
||||||
await pb.collections.update(periodLogs.id, {
|
await pb.collections.update(periodLogs.id, {
|
||||||
@@ -202,6 +208,7 @@ async function createTestUser(
|
|||||||
verified: true,
|
verified: true,
|
||||||
lastPeriodDate,
|
lastPeriodDate,
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
|
notificationTime: "07:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -435,10 +435,12 @@ test.describe("settings", () => {
|
|||||||
const newValue = originalValue === "08:00" ? "09:00" : "08:00";
|
const newValue = originalValue === "08:00" ? "09:00" : "08:00";
|
||||||
await notificationTimeInput.fill(newValue);
|
await notificationTimeInput.fill(newValue);
|
||||||
|
|
||||||
// Save
|
// Save and wait for success toast
|
||||||
const saveButton = page.getByRole("button", { name: /save/i });
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(1500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Reload the page
|
// Reload the page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -453,7 +455,9 @@ test.describe("settings", () => {
|
|||||||
// Restore original value
|
// Restore original value
|
||||||
await notificationTimeAfter.fill(originalValue);
|
await notificationTimeAfter.fill(originalValue);
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("timezone changes persist after page reload", async ({ page }) => {
|
test("timezone changes persist after page reload", async ({ page }) => {
|
||||||
@@ -475,10 +479,12 @@ test.describe("settings", () => {
|
|||||||
: "America/New_York";
|
: "America/New_York";
|
||||||
await timezoneInput.fill(newValue);
|
await timezoneInput.fill(newValue);
|
||||||
|
|
||||||
// Save
|
// Save and wait for success toast
|
||||||
const saveButton = page.getByRole("button", { name: /save/i });
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(1500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Reload the page
|
// Reload the page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -493,7 +499,9 @@ test.describe("settings", () => {
|
|||||||
// Restore original value
|
// Restore original value
|
||||||
await timezoneAfter.fill(originalValue);
|
await timezoneAfter.fill(originalValue);
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("multiple settings changes persist after page reload", async ({
|
test("multiple settings changes persist after page reload", async ({
|
||||||
@@ -536,10 +544,12 @@ test.describe("settings", () => {
|
|||||||
await notificationTimeInput.fill(newNotificationTime);
|
await notificationTimeInput.fill(newNotificationTime);
|
||||||
await timezoneInput.fill(newTimezone);
|
await timezoneInput.fill(newTimezone);
|
||||||
|
|
||||||
// Save all changes at once
|
// Save all changes at once and wait for success toast
|
||||||
const saveButton = page.getByRole("button", { name: /save/i });
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(1500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Reload the page
|
// Reload the page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -561,7 +571,9 @@ test.describe("settings", () => {
|
|||||||
await notificationTimeAfter.fill(originalNotificationTime);
|
await notificationTimeAfter.fill(originalNotificationTime);
|
||||||
await timezoneAfter.fill(originalTimezone);
|
await timezoneAfter.fill(originalTimezone);
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cycle length persistence verifies exact saved value", async ({
|
test("cycle length persistence verifies exact saved value", async ({
|
||||||
@@ -582,10 +594,12 @@ test.describe("settings", () => {
|
|||||||
const newValue = originalValue === "28" ? "31" : "28";
|
const newValue = originalValue === "28" ? "31" : "28";
|
||||||
await cycleLengthInput.fill(newValue);
|
await cycleLengthInput.fill(newValue);
|
||||||
|
|
||||||
// Save
|
// Save and wait for success toast
|
||||||
const saveButton = page.getByRole("button", { name: /save/i });
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(1500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
// Reload the page
|
// Reload the page
|
||||||
await page.reload();
|
await page.reload();
|
||||||
@@ -600,7 +614,9 @@ test.describe("settings", () => {
|
|||||||
// Restore original value
|
// Restore original value
|
||||||
await cycleLengthAfter.fill(originalValue);
|
await cycleLengthAfter.fill(originalValue);
|
||||||
await saveButton.click();
|
await saveButton.click();
|
||||||
await page.waitForTimeout(500);
|
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("settings form shows correct values after save without reload", async ({
|
test("settings form shows correct values after save without reload", async ({
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export default defineConfig({
|
|||||||
// Retry failed tests on CI only
|
// Retry failed tests on CI only
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
// Limit parallel workers on CI to avoid resource issues
|
// Run tests sequentially since all tests share the same test user
|
||||||
workers: process.env.CI ? 1 : undefined,
|
// Parallel execution causes race conditions when tests modify user state
|
||||||
|
workers: 1,
|
||||||
|
|
||||||
// Reporter configuration
|
// Reporter configuration
|
||||||
reporter: [["html", { open: "never" }], ["list"]],
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
|||||||
@@ -9,9 +9,29 @@ import type { User } from "@/types";
|
|||||||
// Module-level variable to control mock user in tests
|
// Module-level variable to control mock user in tests
|
||||||
let currentMockUser: User | null = null;
|
let currentMockUser: User | null = null;
|
||||||
|
|
||||||
|
// Create mock PocketBase getOne function that returns fresh user data
|
||||||
|
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
||||||
|
if (!currentMockUser) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
id: currentMockUser.id,
|
||||||
|
email: currentMockUser.email,
|
||||||
|
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
||||||
|
cycleLength: currentMockUser.cycleLength,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mock PocketBase client
|
||||||
|
const mockPb = {
|
||||||
|
collection: vi.fn(() => ({
|
||||||
|
getOne: mockPbGetOne,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
// Mock PocketBase client for database operations
|
// Mock PocketBase client for database operations
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
createPocketBaseClient: vi.fn(() => ({})),
|
createPocketBaseClient: vi.fn(() => mockPb),
|
||||||
loadAuthFromCookies: vi.fn(),
|
loadAuthFromCookies: vi.fn(),
|
||||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
||||||
getCurrentUser: vi.fn(() => currentMockUser),
|
getCurrentUser: vi.fn(() => currentMockUser),
|
||||||
@@ -24,7 +44,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -40,9 +40,18 @@ function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
|
|||||||
return nextPhaseStart - cycleDay;
|
return nextPhaseStart - cycleDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = withAuth(async (_request, user) => {
|
export const GET = withAuth(async (_request, user, pb) => {
|
||||||
|
// Fetch fresh user data from database to get latest values
|
||||||
|
// The user param from withAuth is from auth store cache which may be stale
|
||||||
|
const freshUser = await pb.collection("users").getOne(user.id);
|
||||||
|
|
||||||
// Validate user has required cycle data
|
// Validate user has required cycle data
|
||||||
if (!user.lastPeriodDate) {
|
const lastPeriodDate = freshUser.lastPeriodDate
|
||||||
|
? new Date(freshUser.lastPeriodDate as string)
|
||||||
|
: null;
|
||||||
|
const cycleLength = (freshUser.cycleLength as number) || 28;
|
||||||
|
|
||||||
|
if (!lastPeriodDate) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
@@ -53,20 +62,16 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate current cycle position
|
// Calculate current cycle position
|
||||||
const cycleDay = getCycleDay(
|
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||||
user.lastPeriodDate,
|
const phase = getPhase(cycleDay, cycleLength);
|
||||||
user.cycleLength,
|
|
||||||
new Date(),
|
|
||||||
);
|
|
||||||
const phase = getPhase(cycleDay, user.cycleLength);
|
|
||||||
const phaseConfig = getPhaseConfig(phase);
|
const phaseConfig = getPhaseConfig(phase);
|
||||||
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength);
|
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, cycleLength);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
cycleDay,
|
cycleDay,
|
||||||
phase,
|
phase,
|
||||||
phaseConfig,
|
phaseConfig,
|
||||||
daysUntilNextPhase,
|
daysUntilNextPhase,
|
||||||
cycleLength: user.cycleLength,
|
cycleLength,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,28 @@ let currentMockUser: User | null = null;
|
|||||||
// Track PocketBase update calls
|
// Track PocketBase update calls
|
||||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
// Track PocketBase getOne calls - returns the current mock user data
|
||||||
|
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
||||||
|
if (!currentMockUser) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
id: currentMockUser.id,
|
||||||
|
email: currentMockUser.email,
|
||||||
|
garminConnected: currentMockUser.garminConnected,
|
||||||
|
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
||||||
|
cycleLength: currentMockUser.cycleLength,
|
||||||
|
notificationTime: currentMockUser.notificationTime,
|
||||||
|
timezone: currentMockUser.timezone,
|
||||||
|
activeOverrides: currentMockUser.activeOverrides,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Create mock PocketBase client
|
// Create mock PocketBase client
|
||||||
const mockPb = {
|
const mockPb = {
|
||||||
collection: vi.fn(() => ({
|
collection: vi.fn(() => ({
|
||||||
update: mockPbUpdate,
|
update: mockPbUpdate,
|
||||||
|
getOne: mockPbGetOne,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +73,7 @@ describe("GET /api/user", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentMockUser = null;
|
currentMockUser = null;
|
||||||
mockPbUpdate.mockClear();
|
mockPbUpdate.mockClear();
|
||||||
|
mockPbGetOne.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns user profile when authenticated", async () => {
|
it("returns user profile when authenticated", async () => {
|
||||||
@@ -133,6 +152,7 @@ describe("PATCH /api/user", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentMockUser = null;
|
currentMockUser = null;
|
||||||
mockPbUpdate.mockClear();
|
mockPbUpdate.mockClear();
|
||||||
|
mockPbGetOne.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to create mock request with JSON body
|
// Helper to create mock request with JSON body
|
||||||
|
|||||||
@@ -13,23 +13,28 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|||||||
/**
|
/**
|
||||||
* GET /api/user
|
* GET /api/user
|
||||||
* Returns the authenticated user's profile.
|
* Returns the authenticated user's profile.
|
||||||
|
* Fetches fresh data from database to ensure updates are reflected.
|
||||||
* Excludes sensitive fields like encrypted tokens.
|
* Excludes sensitive fields like encrypted tokens.
|
||||||
*/
|
*/
|
||||||
export const GET = withAuth(async (_request, user, _pb) => {
|
export const GET = withAuth(async (_request, user, pb) => {
|
||||||
|
// Fetch fresh user data from database to get latest values
|
||||||
|
// The user param from withAuth is from auth store cache which may be stale
|
||||||
|
const freshUser = await pb.collection("users").getOne(user.id);
|
||||||
|
|
||||||
// Format date for consistent API response
|
// Format date for consistent API response
|
||||||
const lastPeriodDate = user.lastPeriodDate
|
const lastPeriodDate = freshUser.lastPeriodDate
|
||||||
? user.lastPeriodDate.toISOString().split("T")[0]
|
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: user.id,
|
id: freshUser.id,
|
||||||
email: user.email,
|
email: freshUser.email,
|
||||||
garminConnected: user.garminConnected,
|
garminConnected: freshUser.garminConnected ?? false,
|
||||||
cycleLength: user.cycleLength,
|
cycleLength: freshUser.cycleLength,
|
||||||
lastPeriodDate,
|
lastPeriodDate,
|
||||||
notificationTime: user.notificationTime,
|
notificationTime: freshUser.notificationTime,
|
||||||
timezone: user.timezone,
|
timezone: freshUser.timezone,
|
||||||
activeOverrides: user.activeOverrides,
|
activeOverrides: freshUser.activeOverrides ?? [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user