diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 2d31d8c..3d023a2 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -228,26 +228,42 @@ test.describe("authentication", () => { }); test.describe("OIDC authentication flow", () => { + // Mock PocketBase auth-methods to return OIDC provider + test.beforeEach(async ({ page }) => { + await page.route("**/api/collections/users/auth-methods*", (route) => { + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + usernamePassword: true, + oauth2: { + enabled: true, + providers: [ + { + name: "oidc", + displayName: "Test Provider", + state: "mock-state", + codeVerifier: "mock-verifier", + codeChallenge: "mock-challenge", + codeChallengeMethod: "S256", + authURL: "https://mock.example.com/auth", + }, + ], + }, + }), + }); + }); + }); + test("OIDC button shows provider name when configured", async ({ page, }) => { await page.goto("/login"); await page.waitForLoadState("networkidle"); - // Look for OIDC sign-in button with provider name const oidcButton = page.getByRole("button", { name: /sign in with/i }); - const hasOidc = await oidcButton.isVisible().catch(() => false); - - if (hasOidc) { - // OIDC button should show provider display name - await expect(oidcButton).toBeVisible(); - // Button text should include "Sign in with" prefix - const buttonText = await oidcButton.textContent(); - expect(buttonText?.toLowerCase()).toContain("sign in with"); - } else { - // Skip test if OIDC not configured (email/password mode) - test.skip(); - } + await expect(oidcButton).toBeVisible(); + await expect(oidcButton).toContainText("Test Provider"); }); test("OIDC button shows loading state during authentication", async ({ @@ -256,22 +272,18 @@ test.describe("authentication", () => { await page.goto("/login"); await page.waitForLoadState("networkidle"); + // Find button by initial text const oidcButton = page.getByRole("button", { name: /sign in with/i }); - const hasOidc = await oidcButton.isVisible().catch(() => false); + await expect(oidcButton).toBeVisible(); - if (hasOidc) { - // Click the button - await oidcButton.click(); + // Click and immediately check for loading state + // The button text changes to "Signing in..." so we need a different locator + await oidcButton.click(); - // Button should show "Signing in..." state - await expect(oidcButton) - .toContainText(/signing in/i, { timeout: 2000 }) - .catch(() => { - // May redirect too fast to catch loading state - that's acceptable - }); - } else { - test.skip(); - } + // Find the button that shows loading state (text changed) + const loadingButton = page.getByRole("button", { name: /signing in/i }); + await expect(loadingButton).toBeVisible(); + await expect(loadingButton).toBeDisabled(); }); test("OIDC button is disabled when rate limited", async ({ page }) => { @@ -279,15 +291,9 @@ test.describe("authentication", () => { await page.waitForLoadState("networkidle"); const oidcButton = page.getByRole("button", { name: /sign in with/i }); - const hasOidc = await oidcButton.isVisible().catch(() => false); - if (hasOidc) { - // Initial state should not be disabled - const isDisabledBefore = await oidcButton.isDisabled(); - expect(isDisabledBefore).toBe(false); - } else { - test.skip(); - } + // Initial state should not be disabled + await expect(oidcButton).not.toBeDisabled(); }); }); diff --git a/e2e/decision-engine.spec.ts b/e2e/decision-engine.spec.ts index e50654a..04b63f1 100644 --- a/e2e/decision-engine.spec.ts +++ b/e2e/decision-engine.spec.ts @@ -355,16 +355,23 @@ test.describe("decision engine", () => { // Toggle flare on (if not already) if (!flareWasChecked) { - await flareCheckbox.click(); - await page.waitForTimeout(500); + // Wait for both API calls when clicking the checkbox + await Promise.all([ + page.waitForResponse("**/api/overrides"), + page.waitForResponse("**/api/today"), + flareCheckbox.click(), + ]); - // Should now be REST + // Should now be REST (flare mode forces rest) const restDecision = await decisionCard.textContent(); expect(restDecision).toContain("REST"); - // Toggle flare off - await flareCheckbox.click(); - await page.waitForTimeout(500); + // Toggle flare off and wait for API calls + await Promise.all([ + page.waitForResponse("**/api/overrides"), + page.waitForResponse("**/api/today"), + flareCheckbox.click(), + ]); // Should return to original (or close to it) const restoredDecision = await decisionCard.textContent(); diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts index b90e671..3896ce3 100644 --- a/e2e/mobile.spec.ts +++ b/e2e/mobile.spec.ts @@ -77,9 +77,7 @@ test.describe("mobile viewport", () => { await expect(settingsLink).toBeVisible(); // Decision card should be visible - const decisionCard = page - .locator('[data-testid="decision-card"]') - .or(page.getByText(/rest|gentle|light|reduced|train/i).first()); + const decisionCard = page.locator('[data-testid="decision-card"]'); await expect(decisionCard).toBeVisible(); // Data panel should be visible diff --git a/e2e/period-logging.spec.ts b/e2e/period-logging.spec.ts index b8cb9fa..adc8b4e 100644 --- a/e2e/period-logging.spec.ts +++ b/e2e/period-logging.spec.ts @@ -192,9 +192,7 @@ test.describe("period logging flow - onboarding user", () => { await onboardingPage.getByRole("button", { name: /cancel/i }).click(); }); - // TODO: This test is flaky - the save succeeds but the dashboard doesn't - // always refresh in time. Needs investigation into React state updates. - test.skip("logging period from modal updates dashboard cycle info", async ({ + test("logging period from modal updates dashboard cycle info", async ({ onboardingPage, }) => { await onboardingPage.goto("/"); @@ -220,19 +218,20 @@ test.describe("period logging flow - onboarding user", () => { const dateInput = onboardingPage.locator('input[type="date"]'); await dateInput.fill(dateStr); - // Click Save + // Click Save button await onboardingPage.getByRole("button", { name: /save/i }).click(); - // Modal should close - await expect(modalTitle).not.toBeVisible(); + // Modal should close after successful save + await expect(modalTitle).not.toBeVisible({ timeout: 10000 }); - // Wait for data to refresh after successful save - // The dashboard refetches data and should show cycle info + // Wait for network activity to settle await onboardingPage.waitForLoadState("networkidle"); // Look for cycle day display (e.g., "Day 8 · Follicular" or similar) - // This appears after the dashboard refetches data post-save - const cycleInfo = onboardingPage.getByText(/day\s+\d+\s+·/i); + // 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 }); }); }); diff --git a/src/app/api/today/route.test.ts b/src/app/api/today/route.test.ts index df88f4c..061ea54 100644 --- a/src/app/api/today/route.test.ts +++ b/src/app/api/today/route.test.ts @@ -14,7 +14,22 @@ let currentMockDailyLog: DailyLog | null = null; // Create mock PocketBase client const mockPb = { - collection: vi.fn(() => ({ + collection: vi.fn((collectionName: string) => ({ + // Mock getOne for fetching fresh user data + getOne: vi.fn(async () => { + if (collectionName === "users" && currentMockUser) { + // Return user data in PocketBase record format + return { + id: currentMockUser.id, + email: currentMockUser.email, + lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(), + cycleLength: currentMockUser.cycleLength, + activeOverrides: currentMockUser.activeOverrides, + garminConnected: currentMockUser.garminConnected, + }; + } + throw new Error("Record not found"); + }), getFirstListItem: vi.fn(async () => { if (!currentMockDailyLog) { const error = new Error("No DailyLog found"); diff --git a/src/app/api/today/route.ts b/src/app/api/today/route.ts index 933fc7f..ae2a344 100644 --- a/src/app/api/today/route.ts +++ b/src/app/api/today/route.ts @@ -28,8 +28,13 @@ const DEFAULT_BIOMETRICS: { }; 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 + // (e.g., after logging a period, the cookie still has old data) + const freshUser = await pb.collection("users").getOne(user.id); + // Validate required user data - if (!user.lastPeriodDate) { + if (!freshUser.lastPeriodDate) { return NextResponse.json( { error: @@ -38,14 +43,13 @@ export const GET = withAuth(async (_request, user, pb) => { { status: 400 }, ); } + const lastPeriodDate = new Date(freshUser.lastPeriodDate as string); + const cycleLength = freshUser.cycleLength as number; + const activeOverrides = (freshUser.activeOverrides as string[]) || []; // Calculate cycle information - const cycleDay = getCycleDay( - new Date(user.lastPeriodDate), - user.cycleLength, - new Date(), - ); - const phase = getPhase(cycleDay, user.cycleLength); + const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date()); + const phase = getPhase(cycleDay, cycleLength); const phaseConfig = getPhaseConfig(phase); const phaseLimit = getPhaseLimit(phase); @@ -54,16 +58,16 @@ export const GET = withAuth(async (_request, user, pb) => { // EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl let daysUntilNextPhase: number; if (phase === "LATE_LUTEAL") { - daysUntilNextPhase = user.cycleLength - cycleDay + 1; + daysUntilNextPhase = cycleLength - cycleDay + 1; } else if (phase === "MENSTRUAL") { daysUntilNextPhase = 4 - cycleDay; } else if (phase === "FOLLICULAR") { - daysUntilNextPhase = user.cycleLength - 15 - cycleDay; + daysUntilNextPhase = cycleLength - 15 - cycleDay; } else if (phase === "OVULATION") { - daysUntilNextPhase = user.cycleLength - 13 - cycleDay; + daysUntilNextPhase = cycleLength - 13 - cycleDay; } else { // EARLY_LUTEAL - daysUntilNextPhase = user.cycleLength - 6 - cycleDay; + daysUntilNextPhase = cycleLength - 6 - cycleDay; } // Try to fetch today's DailyLog for biometrics @@ -99,7 +103,10 @@ export const GET = withAuth(async (_request, user, pb) => { }; // Get training decision with override handling - const decision = getDecisionWithOverrides(dailyData, user.activeOverrides); + const decision = getDecisionWithOverrides( + dailyData, + activeOverrides as import("@/types").OverrideType[], + ); // Log decision calculation per observability spec logger.info( @@ -120,7 +127,7 @@ export const GET = withAuth(async (_request, user, pb) => { phase, phaseConfig, daysUntilNextPhase, - cycleLength: user.cycleLength, + cycleLength, biometrics, nutrition, }); diff --git a/src/components/dashboard/decision-card.tsx b/src/components/dashboard/decision-card.tsx index 5a26da7..4f36896 100644 --- a/src/components/dashboard/decision-card.tsx +++ b/src/components/dashboard/decision-card.tsx @@ -40,7 +40,10 @@ export function DecisionCard({ decision }: DecisionCardProps) { const colors = getStatusColors(decision.status); return ( -
{decision.reason}