Enable 5 previously skipped e2e tests
All checks were successful
Deploy / deploy (push) Successful in 1m37s
All checks were successful
Deploy / deploy (push) Successful in 1m37s
- Fix OIDC tests with route interception for auth-methods API - Add data-testid to DecisionCard for reliable test selection - Fix /api/today to fetch fresh user data instead of stale cookie data - Fix period logging test timing with proper API wait patterns - Fix decision engine test with waitForResponse instead of timeout - Simplify mobile viewport test locator All 206 e2e tests now pass with 0 skipped. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -228,26 +228,42 @@ test.describe("authentication", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("OIDC authentication flow", () => {
|
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 ({
|
test("OIDC button shows provider name when configured", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for OIDC sign-in button with provider name
|
|
||||||
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
||||||
const hasOidc = await oidcButton.isVisible().catch(() => false);
|
await expect(oidcButton).toBeVisible();
|
||||||
|
await expect(oidcButton).toContainText("Test Provider");
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OIDC button shows loading state during authentication", async ({
|
test("OIDC button shows loading state during authentication", async ({
|
||||||
@@ -256,22 +272,18 @@ test.describe("authentication", () => {
|
|||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Find button by initial text
|
||||||
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
||||||
const hasOidc = await oidcButton.isVisible().catch(() => false);
|
await expect(oidcButton).toBeVisible();
|
||||||
|
|
||||||
if (hasOidc) {
|
// Click and immediately check for loading state
|
||||||
// Click the button
|
// The button text changes to "Signing in..." so we need a different locator
|
||||||
await oidcButton.click();
|
await oidcButton.click();
|
||||||
|
|
||||||
// Button should show "Signing in..." state
|
// Find the button that shows loading state (text changed)
|
||||||
await expect(oidcButton)
|
const loadingButton = page.getByRole("button", { name: /signing in/i });
|
||||||
.toContainText(/signing in/i, { timeout: 2000 })
|
await expect(loadingButton).toBeVisible();
|
||||||
.catch(() => {
|
await expect(loadingButton).toBeDisabled();
|
||||||
// May redirect too fast to catch loading state - that's acceptable
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("OIDC button is disabled when rate limited", async ({ page }) => {
|
test("OIDC button is disabled when rate limited", async ({ page }) => {
|
||||||
@@ -279,15 +291,9 @@ test.describe("authentication", () => {
|
|||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
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
|
||||||
// Initial state should not be disabled
|
await expect(oidcButton).not.toBeDisabled();
|
||||||
const isDisabledBefore = await oidcButton.isDisabled();
|
|
||||||
expect(isDisabledBefore).toBe(false);
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -355,16 +355,23 @@ test.describe("decision engine", () => {
|
|||||||
|
|
||||||
// Toggle flare on (if not already)
|
// Toggle flare on (if not already)
|
||||||
if (!flareWasChecked) {
|
if (!flareWasChecked) {
|
||||||
await flareCheckbox.click();
|
// Wait for both API calls when clicking the checkbox
|
||||||
await page.waitForTimeout(500);
|
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();
|
const restDecision = await decisionCard.textContent();
|
||||||
expect(restDecision).toContain("REST");
|
expect(restDecision).toContain("REST");
|
||||||
|
|
||||||
// Toggle flare off
|
// Toggle flare off and wait for API calls
|
||||||
await flareCheckbox.click();
|
await Promise.all([
|
||||||
await page.waitForTimeout(500);
|
page.waitForResponse("**/api/overrides"),
|
||||||
|
page.waitForResponse("**/api/today"),
|
||||||
|
flareCheckbox.click(),
|
||||||
|
]);
|
||||||
|
|
||||||
// Should return to original (or close to it)
|
// Should return to original (or close to it)
|
||||||
const restoredDecision = await decisionCard.textContent();
|
const restoredDecision = await decisionCard.textContent();
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ test.describe("mobile viewport", () => {
|
|||||||
await expect(settingsLink).toBeVisible();
|
await expect(settingsLink).toBeVisible();
|
||||||
|
|
||||||
// Decision card should be visible
|
// Decision card should be visible
|
||||||
const decisionCard = page
|
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||||
.locator('[data-testid="decision-card"]')
|
|
||||||
.or(page.getByText(/rest|gentle|light|reduced|train/i).first());
|
|
||||||
await expect(decisionCard).toBeVisible();
|
await expect(decisionCard).toBeVisible();
|
||||||
|
|
||||||
// Data panel should be visible
|
// Data panel should be visible
|
||||||
|
|||||||
@@ -192,9 +192,7 @@ test.describe("period logging flow - onboarding user", () => {
|
|||||||
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
|
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: This test is flaky - the save succeeds but the dashboard doesn't
|
test("logging period from modal updates dashboard cycle info", async ({
|
||||||
// always refresh in time. Needs investigation into React state updates.
|
|
||||||
test.skip("logging period from modal updates dashboard cycle info", async ({
|
|
||||||
onboardingPage,
|
onboardingPage,
|
||||||
}) => {
|
}) => {
|
||||||
await onboardingPage.goto("/");
|
await onboardingPage.goto("/");
|
||||||
@@ -220,19 +218,20 @@ test.describe("period logging flow - onboarding user", () => {
|
|||||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||||
await dateInput.fill(dateStr);
|
await dateInput.fill(dateStr);
|
||||||
|
|
||||||
// Click Save
|
// Click Save button
|
||||||
await onboardingPage.getByRole("button", { name: /save/i }).click();
|
await onboardingPage.getByRole("button", { name: /save/i }).click();
|
||||||
|
|
||||||
// Modal should close
|
// Modal should close after successful save
|
||||||
await expect(modalTitle).not.toBeVisible();
|
await expect(modalTitle).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
// Wait for data to refresh after successful save
|
// Wait for network activity to settle
|
||||||
// The dashboard refetches data and should show cycle info
|
|
||||||
await onboardingPage.waitForLoadState("networkidle");
|
await onboardingPage.waitForLoadState("networkidle");
|
||||||
|
|
||||||
// Look for cycle day display (e.g., "Day 8 · Follicular" or similar)
|
// Look for cycle day display (e.g., "Day 8 · Follicular" or similar)
|
||||||
// This appears after the dashboard refetches data post-save
|
// The page fetches /api/cycle/period, then /api/today and /api/user
|
||||||
const cycleInfo = onboardingPage.getByText(/day\s+\d+\s+·/i);
|
// 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 });
|
await expect(cycleInfo).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,22 @@ let currentMockDailyLog: DailyLog | null = null;
|
|||||||
|
|
||||||
// Create mock PocketBase client
|
// Create mock PocketBase client
|
||||||
const mockPb = {
|
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 () => {
|
getFirstListItem: vi.fn(async () => {
|
||||||
if (!currentMockDailyLog) {
|
if (!currentMockDailyLog) {
|
||||||
const error = new Error("No DailyLog found");
|
const error = new Error("No DailyLog found");
|
||||||
|
|||||||
@@ -28,8 +28,13 @@ const DEFAULT_BIOMETRICS: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
|
// (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
|
// Validate required user data
|
||||||
if (!user.lastPeriodDate) {
|
if (!freshUser.lastPeriodDate) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
@@ -38,14 +43,13 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
{ status: 400 },
|
{ 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
|
// Calculate cycle information
|
||||||
const cycleDay = getCycleDay(
|
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||||
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 phaseLimit = getPhaseLimit(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
|
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
|
||||||
let daysUntilNextPhase: number;
|
let daysUntilNextPhase: number;
|
||||||
if (phase === "LATE_LUTEAL") {
|
if (phase === "LATE_LUTEAL") {
|
||||||
daysUntilNextPhase = user.cycleLength - cycleDay + 1;
|
daysUntilNextPhase = cycleLength - cycleDay + 1;
|
||||||
} else if (phase === "MENSTRUAL") {
|
} else if (phase === "MENSTRUAL") {
|
||||||
daysUntilNextPhase = 4 - cycleDay;
|
daysUntilNextPhase = 4 - cycleDay;
|
||||||
} else if (phase === "FOLLICULAR") {
|
} else if (phase === "FOLLICULAR") {
|
||||||
daysUntilNextPhase = user.cycleLength - 15 - cycleDay;
|
daysUntilNextPhase = cycleLength - 15 - cycleDay;
|
||||||
} else if (phase === "OVULATION") {
|
} else if (phase === "OVULATION") {
|
||||||
daysUntilNextPhase = user.cycleLength - 13 - cycleDay;
|
daysUntilNextPhase = cycleLength - 13 - cycleDay;
|
||||||
} else {
|
} else {
|
||||||
// EARLY_LUTEAL
|
// EARLY_LUTEAL
|
||||||
daysUntilNextPhase = user.cycleLength - 6 - cycleDay;
|
daysUntilNextPhase = cycleLength - 6 - cycleDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to fetch today's DailyLog for biometrics
|
// 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
|
// 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
|
// Log decision calculation per observability spec
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -120,7 +127,7 @@ export const GET = withAuth(async (_request, user, pb) => {
|
|||||||
phase,
|
phase,
|
||||||
phaseConfig,
|
phaseConfig,
|
||||||
daysUntilNextPhase,
|
daysUntilNextPhase,
|
||||||
cycleLength: user.cycleLength,
|
cycleLength,
|
||||||
biometrics,
|
biometrics,
|
||||||
nutrition,
|
nutrition,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ export function DecisionCard({ decision }: DecisionCardProps) {
|
|||||||
const colors = getStatusColors(decision.status);
|
const colors = getStatusColors(decision.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg p-6 ${colors.background}`}>
|
<div
|
||||||
|
data-testid="decision-card"
|
||||||
|
className={`rounded-lg p-6 ${colors.background}`}
|
||||||
|
>
|
||||||
<div className="text-4xl mb-2">{decision.icon}</div>
|
<div className="text-4xl mb-2">{decision.icon}</div>
|
||||||
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
||||||
<p className={colors.text}>{decision.reason}</p>
|
<p className={colors.text}>{decision.reason}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user