Compare commits
5 Commits
7dd08ab5ce
...
4a874476c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a874476c3 | |||
| ff3d8fad2c | |||
| b221acee40 | |||
| 6df145d916 | |||
| 00b84d0b22 |
@@ -147,6 +147,11 @@ These are optional enhancements to improve E2E coverage. Not required for featur
|
||||
|
||||
## 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: 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)
|
||||
|
||||
@@ -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).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
|
||||
// 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();
|
||||
}
|
||||
await expect(oidcButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -129,12 +129,13 @@ test.describe("dashboard", () => {
|
||||
// Click the toggle
|
||||
await toggleCheckbox.click();
|
||||
|
||||
// Wait a moment for the API call
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Toggle should change state
|
||||
const afterChecked = await toggleCheckbox.isChecked();
|
||||
expect(afterChecked).not.toBe(initialChecked);
|
||||
// Wait for the checkbox state to change using retry-based assertion
|
||||
// The API call completes and React re-renders asynchronously
|
||||
if (initialChecked) {
|
||||
await expect(toggleCheckbox).not.toBeChecked({ timeout: 5000 });
|
||||
} else {
|
||||
await expect(toggleCheckbox).toBeChecked({ timeout: 5000 });
|
||||
}
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
@@ -244,59 +245,39 @@ test.describe("dashboard", () => {
|
||||
});
|
||||
|
||||
test("displays cycle day in 'Day X' format", async ({ page }) => {
|
||||
// Wait for dashboard to finish loading
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for "Day" followed by a number
|
||||
const cycleDayText = page.getByText(/Day \d+/i);
|
||||
const hasCycleDay = await cycleDayText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// Wait for either cycle day or onboarding - both are valid states
|
||||
// Use Playwright's expect with retry for reliable detection
|
||||
const cycleDayText = page.getByText(/Day \d+/i).first();
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||
|
||||
// Either has cycle day or onboarding (both valid states)
|
||||
if (!hasCycleDay) {
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasCycleDay || hasOnboarding).toBe(true);
|
||||
try {
|
||||
// First try waiting for cycle day with a short timeout
|
||||
await expect(cycleDayText).toBeVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
// If no cycle day, expect onboarding banner
|
||||
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("displays current phase name", async ({ page }) => {
|
||||
// Wait for dashboard to finish loading
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for phase names
|
||||
const phaseNames = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATION",
|
||||
"EARLY_LUTEAL",
|
||||
"LATE_LUTEAL",
|
||||
];
|
||||
let foundPhase = false;
|
||||
// Wait for either a phase name or onboarding - both are valid states
|
||||
const phaseRegex =
|
||||
/MENSTRUAL|FOLLICULAR|OVULATION|EARLY_LUTEAL|LATE_LUTEAL/i;
|
||||
const phaseText = page.getByText(phaseRegex).first();
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||
|
||||
for (const phase of phaseNames) {
|
||||
const phaseText = page.getByText(new RegExp(phase, "i"));
|
||||
const isVisible = await phaseText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
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);
|
||||
try {
|
||||
// First try waiting for a phase name with a short timeout
|
||||
await expect(phaseText).toBeVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
// If no phase, expect onboarding banner
|
||||
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -381,81 +362,62 @@ test.describe("dashboard", () => {
|
||||
});
|
||||
|
||||
test("displays seed cycling recommendation", async ({ page }) => {
|
||||
// Wait for dashboard to finish loading
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for seed names (flax, pumpkin, sesame, sunflower)
|
||||
const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i);
|
||||
const hasSeeds = await seedText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// Wait for either seed info or onboarding - both are valid states
|
||||
const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i).first();
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||
|
||||
// Either has seeds info or onboarding
|
||||
if (!hasSeeds) {
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasSeeds || hasOnboarding).toBe(true);
|
||||
try {
|
||||
await expect(seedText).toBeVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("displays carbohydrate range", async ({ page }) => {
|
||||
// Wait for dashboard to finish loading
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for carb-related text
|
||||
const carbText = page.getByText(/carb|carbohydrate/i);
|
||||
const hasCarbs = await carbText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// Wait for either carb info or onboarding - both are valid states
|
||||
const carbText = page.getByText(/carb|carbohydrate/i).first();
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||
|
||||
if (!hasCarbs) {
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasCarbs || hasOnboarding).toBe(true);
|
||||
try {
|
||||
await expect(carbText).toBeVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("displays keto guidance", async ({ page }) => {
|
||||
// Wait for dashboard to finish loading
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for keto-related text
|
||||
const ketoText = page.getByText(/keto/i);
|
||||
const hasKeto = await ketoText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
// Wait for either keto info or onboarding - both are valid states
|
||||
const ketoText = page.getByText(/keto/i).first();
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i).first();
|
||||
|
||||
if (!hasKeto) {
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasKeto || hasOnboarding).toBe(true);
|
||||
try {
|
||||
await expect(ketoText).toBeVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
await expect(onboarding).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("displays nutrition section header", async ({ page }) => {
|
||||
// Wait for dashboard to finish loading
|
||||
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 hasHeader = await nutritionHeader.isVisible().catch(() => false);
|
||||
const nutritionText = page.getByText(/nutrition/i).first();
|
||||
|
||||
if (!hasHeader) {
|
||||
// May be text label instead of heading
|
||||
const nutritionText = page.getByText(/nutrition/i);
|
||||
const hasText = await nutritionText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasHeader || hasText).toBe(true);
|
||||
try {
|
||||
await expect(nutritionHeader).toBeVisible({ timeout: 5000 });
|
||||
} catch {
|
||||
await expect(nutritionText).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
86
e2e/fixtures.ts
Normal file
86
e2e/fixtures.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// ABOUTME: Playwright test fixtures for different user states.
|
||||
// ABOUTME: Provides pre-authenticated pages for onboarding, established, calendar, and garmin users.
|
||||
import { test as base, type Page } from "@playwright/test";
|
||||
import { TEST_USERS, type TestUserPreset } from "./pocketbase-harness";
|
||||
|
||||
/**
|
||||
* Logs in a user via the email/password form.
|
||||
* Throws if the email form is not visible (OIDC-only mode).
|
||||
*/
|
||||
async function loginUser(
|
||||
page: Page,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEmailForm) {
|
||||
throw new Error(
|
||||
"Email/password form not visible - app may be in OIDC-only mode",
|
||||
);
|
||||
}
|
||||
|
||||
await emailInput.fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
// Wait for successful redirect to dashboard
|
||||
await page.waitForURL("/", { timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fixture for a specific user preset.
|
||||
*/
|
||||
function createUserFixture(preset: TestUserPreset) {
|
||||
return async (
|
||||
{ page }: { page: Page },
|
||||
use: (page: Page) => Promise<void>,
|
||||
) => {
|
||||
const user = TEST_USERS[preset];
|
||||
await loginUser(page, user.email, user.password);
|
||||
await use(page);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixtures providing pre-authenticated pages for each user type.
|
||||
*
|
||||
* Usage:
|
||||
* import { test, expect } from './fixtures';
|
||||
*
|
||||
* test('onboarding user sees set date button', async ({ onboardingPage }) => {
|
||||
* await onboardingPage.goto('/');
|
||||
* // User has no period data, will see onboarding UI
|
||||
* });
|
||||
*
|
||||
* test('established user sees dashboard', async ({ establishedPage }) => {
|
||||
* await establishedPage.goto('/');
|
||||
* // User has period data from 14 days ago
|
||||
* });
|
||||
*/
|
||||
type TestFixtures = {
|
||||
/** User with no period data - sees onboarding UI */
|
||||
onboardingPage: Page;
|
||||
/** User with period data (14 days ago) - sees normal dashboard */
|
||||
establishedPage: Page;
|
||||
/** User with period data and calendar token - can copy/regenerate URL */
|
||||
calendarPage: Page;
|
||||
/** User with valid Garmin tokens (90 days until expiry) */
|
||||
garminPage: Page;
|
||||
/** User with expired Garmin tokens */
|
||||
garminExpiredPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<TestFixtures>({
|
||||
onboardingPage: createUserFixture("onboarding"),
|
||||
establishedPage: createUserFixture("established"),
|
||||
calendarPage: createUserFixture("calendar"),
|
||||
garminPage: createUserFixture("garmin"),
|
||||
garminExpiredPage: createUserFixture("garminExpired"),
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
@@ -40,6 +40,19 @@ test.describe("garmin connection", () => {
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
await page.goto("/settings/garmin");
|
||||
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 }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ABOUTME: Runs before all e2e tests to provide a fresh database with test data.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { DEFAULT_CONFIG, start } from "./pocketbase-harness";
|
||||
import { DEFAULT_CONFIG, start, TEST_USERS } from "./pocketbase-harness";
|
||||
|
||||
const STATE_FILE = path.join(__dirname, ".harness-state.json");
|
||||
|
||||
@@ -24,9 +24,27 @@ export default async function globalSetup(): Promise<void> {
|
||||
// Set environment variables for the test process
|
||||
process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url;
|
||||
process.env.POCKETBASE_URL = state.url;
|
||||
process.env.TEST_USER_EMAIL = DEFAULT_CONFIG.testUserEmail;
|
||||
process.env.TEST_USER_PASSWORD = DEFAULT_CONFIG.testUserPassword;
|
||||
|
||||
// Export credentials for each test user type
|
||||
process.env.TEST_USER_ONBOARDING_EMAIL = TEST_USERS.onboarding.email;
|
||||
process.env.TEST_USER_ONBOARDING_PASSWORD = TEST_USERS.onboarding.password;
|
||||
process.env.TEST_USER_ESTABLISHED_EMAIL = TEST_USERS.established.email;
|
||||
process.env.TEST_USER_ESTABLISHED_PASSWORD = TEST_USERS.established.password;
|
||||
process.env.TEST_USER_CALENDAR_EMAIL = TEST_USERS.calendar.email;
|
||||
process.env.TEST_USER_CALENDAR_PASSWORD = TEST_USERS.calendar.password;
|
||||
process.env.TEST_USER_GARMIN_EMAIL = TEST_USERS.garmin.email;
|
||||
process.env.TEST_USER_GARMIN_PASSWORD = TEST_USERS.garmin.password;
|
||||
process.env.TEST_USER_GARMIN_EXPIRED_EMAIL = TEST_USERS.garminExpired.email;
|
||||
process.env.TEST_USER_GARMIN_EXPIRED_PASSWORD =
|
||||
TEST_USERS.garminExpired.password;
|
||||
|
||||
// Keep backward compatibility - default to established user
|
||||
process.env.TEST_USER_EMAIL = TEST_USERS.established.email;
|
||||
process.env.TEST_USER_PASSWORD = TEST_USERS.established.password;
|
||||
|
||||
console.log(`PocketBase running at ${state.url}`);
|
||||
console.log(`Test user: ${DEFAULT_CONFIG.testUserEmail}`);
|
||||
console.log("Test users created:");
|
||||
for (const [preset, user] of Object.entries(TEST_USERS)) {
|
||||
console.log(` ${preset}: ${user.email}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -140,15 +138,18 @@ test.describe("mobile viewport", () => {
|
||||
const viewportSize = page.viewportSize();
|
||||
expect(viewportSize?.width).toBe(375);
|
||||
|
||||
// Calendar heading should be visible
|
||||
const heading = page.getByRole("heading", { name: /calendar/i });
|
||||
await expect(heading).toBeVisible();
|
||||
// Calendar page title heading should be visible (exact match to avoid "Calendar Subscription")
|
||||
const heading = page.getByRole("heading", {
|
||||
name: "Calendar",
|
||||
exact: true,
|
||||
});
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Calendar grid should be visible
|
||||
const calendarGrid = page
|
||||
.getByRole("grid")
|
||||
.or(page.locator('[data-testid="month-view"]'));
|
||||
await expect(calendarGrid).toBeVisible();
|
||||
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Month navigation should be visible
|
||||
const monthYear = page.getByText(
|
||||
|
||||
@@ -1,85 +1,73 @@
|
||||
// ABOUTME: E2E tests for period logging functionality.
|
||||
// ABOUTME: Tests period start logging, date selection, and period history.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("period logging", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("period history page redirects to login when not authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
import { test as baseTest } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
baseTest.describe("period logging", () => {
|
||||
baseTest.describe("unauthenticated", () => {
|
||||
baseTest(
|
||||
"period history page redirects to login when not authenticated",
|
||||
async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
|
||||
// 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;
|
||||
baseTest.describe("API endpoints", () => {
|
||||
baseTest("period history API requires authentication", async ({ page }) => {
|
||||
const response = await page.request.get("/api/period-history");
|
||||
|
||||
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();
|
||||
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
// Should return 401 Unauthorized
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
baseTest("period log API requires authentication", async ({ page }) => {
|
||||
const response = await page.request.post("/api/cycle/period", {
|
||||
data: { startDate: "2024-01-15" },
|
||||
});
|
||||
|
||||
// Should return 401 Unauthorized
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging authenticated", () => {
|
||||
test("dashboard shows period date prompt for new users", async ({
|
||||
page,
|
||||
onboardingPage,
|
||||
}) => {
|
||||
// Check if onboarding banner for period date is visible
|
||||
// This depends on whether the test user has period data set
|
||||
const onboardingBanner = page.getByText(
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Onboarding user has no period data, should see onboarding banner
|
||||
const onboardingBanner = onboardingPage.getByText(
|
||||
/period|log your period|set.*date/i,
|
||||
);
|
||||
const hasOnboarding = await onboardingBanner
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Either has onboarding prompt or has cycle data - both are valid states
|
||||
if (hasOnboarding) {
|
||||
await expect(onboardingBanner.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("period history page is accessible", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
test("period history page is accessible", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Should show period history content
|
||||
await expect(page.getByRole("heading")).toBeVisible();
|
||||
await expect(establishedPage.getByRole("heading")).toBeVisible();
|
||||
});
|
||||
|
||||
test("period history shows table or empty state", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
test("period history shows table or empty state", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Wait for loading to complete
|
||||
await page.waitForLoadState("networkidle");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for either table or empty state message
|
||||
const table = page.getByRole("table");
|
||||
const emptyState = page.getByText("No period history found");
|
||||
const totalText = page.getByText(/\d+ periods/);
|
||||
const table = establishedPage.getByRole("table");
|
||||
const emptyState = establishedPage.getByText("No period history found");
|
||||
const totalText = establishedPage.getByText(/\d+ periods/);
|
||||
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
const hasEmpty = await emptyState.isVisible().catch(() => false);
|
||||
@@ -90,12 +78,14 @@ test.describe("period logging", () => {
|
||||
});
|
||||
|
||||
test("period history shows average cycle length if data exists", async ({
|
||||
page,
|
||||
establishedPage,
|
||||
}) => {
|
||||
await page.goto("/period-history");
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Average cycle length is shown when there's enough data
|
||||
const avgText = page.getByText(/average.*cycle|cycle.*average|avg/i);
|
||||
const avgText = establishedPage.getByText(
|
||||
/average.*cycle|cycle.*average|avg/i,
|
||||
);
|
||||
const hasAvg = await avgText
|
||||
.first()
|
||||
.isVisible()
|
||||
@@ -107,235 +97,83 @@ test.describe("period logging", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("period history shows back navigation", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
test("period history shows back navigation", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Look for back link
|
||||
const backLink = page.getByRole("link", { name: /back|dashboard|home/i });
|
||||
const backLink = establishedPage.getByRole("link", {
|
||||
name: /back|dashboard|home/i,
|
||||
});
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to period history from dashboard", async ({ page }) => {
|
||||
test("can navigate to period history from dashboard", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
// Look for navigation to period history
|
||||
const periodHistoryLink = page.getByRole("link", {
|
||||
const periodHistoryLink = establishedPage.getByRole("link", {
|
||||
name: /period.*history|history/i,
|
||||
});
|
||||
const hasLink = await periodHistoryLink.isVisible().catch(() => false);
|
||||
|
||||
if (hasLink) {
|
||||
await periodHistoryLink.click();
|
||||
await expect(page).toHaveURL(/\/period-history/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("API endpoints", () => {
|
||||
test("period history API requires authentication", async ({ page }) => {
|
||||
const response = await page.request.get("/api/period-history");
|
||||
|
||||
// Should return 401 Unauthorized
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
|
||||
test("period log API requires authentication", async ({ page }) => {
|
||||
const response = await page.request.post("/api/cycle/period", {
|
||||
data: { startDate: "2024-01-15" },
|
||||
});
|
||||
|
||||
// Should return 401 Unauthorized
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging flow", () => {
|
||||
// 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 });
|
||||
});
|
||||
|
||||
test("period date cannot be in the future", async ({ page }) => {
|
||||
// Navigate to period history
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for an "Add Period" or "Log Period" button
|
||||
const addButton = page.getByRole("button", {
|
||||
name: /add.*period|log.*period|new.*period/i,
|
||||
});
|
||||
const hasAddButton = await addButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasAddButton) {
|
||||
// Try dashboard - look for period logging modal trigger
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const periodButton = page.getByRole("button", {
|
||||
name: /log.*period|add.*period/i,
|
||||
});
|
||||
const hasPeriodButton = await periodButton
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasPeriodButton) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("period history displays cycle length between periods", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for cycle length column or text
|
||||
const cycleLengthText = page.getByText(/cycle.*length|\d+\s*days/i);
|
||||
const hasCycleLength = await cycleLengthText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// If there's period data, cycle length should be visible
|
||||
const table = page.getByRole("table");
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
|
||||
if (hasTable) {
|
||||
// Table has header for cycle length
|
||||
const header = page.getByRole("columnheader", {
|
||||
name: /cycle.*length|days/i,
|
||||
});
|
||||
const hasHeader = await header.isVisible().catch(() => false);
|
||||
expect(hasHeader || hasCycleLength).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("period history shows prediction accuracy when available", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for prediction-related text (early/late, accuracy)
|
||||
const predictionText = page.getByText(/early|late|accuracy|predicted/i);
|
||||
const hasPrediction = await predictionText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Prediction info may not be visible if not enough data
|
||||
if (hasPrediction) {
|
||||
await expect(predictionText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can delete period log from history", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for delete button
|
||||
const deleteButton = page.getByRole("button", { name: /delete/i });
|
||||
const hasDelete = await deleteButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasDelete) {
|
||||
// Delete button exists for period entries
|
||||
await expect(deleteButton.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can edit period log from history", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for edit button
|
||||
const editButton = page.getByRole("button", { name: /edit/i });
|
||||
const hasEdit = await editButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasEdit) {
|
||||
// Edit button exists for period entries
|
||||
await expect(editButton.first()).toBeVisible();
|
||||
await expect(establishedPage).toHaveURL(/\/period-history/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging flow - onboarding user", () => {
|
||||
test("period date modal opens from dashboard onboarding banner", async ({
|
||||
page,
|
||||
onboardingPage,
|
||||
}) => {
|
||||
// Look for the "Set date" button in onboarding banner
|
||||
const setDateButton = page.getByRole("button", { name: /set date/i });
|
||||
const hasSetDate = await setDateButton.isVisible().catch(() => false);
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
if (!hasSetDate) {
|
||||
// User may already have period date set - skip if no onboarding banner
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
// Onboarding user should see "Set date" button
|
||||
const setDateButton = onboardingPage.getByRole("button", {
|
||||
name: /set date/i,
|
||||
});
|
||||
await expect(setDateButton).toBeVisible();
|
||||
|
||||
// Click the set date button
|
||||
await setDateButton.click();
|
||||
|
||||
// Modal should open with "Set Period Date" title
|
||||
const modalTitle = page.getByRole("heading", {
|
||||
const modalTitle = onboardingPage.getByRole("heading", {
|
||||
name: /set period date/i,
|
||||
});
|
||||
await expect(modalTitle).toBeVisible();
|
||||
|
||||
// Should have a date input
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||
await expect(dateInput).toBeVisible();
|
||||
|
||||
// Should have Cancel and Save buttons
|
||||
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /save/i })).toBeVisible();
|
||||
await expect(
|
||||
onboardingPage.getByRole("button", { name: /cancel/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
onboardingPage.getByRole("button", { name: /save/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Cancel should close the modal
|
||||
await page.getByRole("button", { name: /cancel/i }).click();
|
||||
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(modalTitle).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("period date input restricts future dates", async ({ page }) => {
|
||||
// Look for the "Set date" button in onboarding banner
|
||||
const setDateButton = page.getByRole("button", { name: /set date/i });
|
||||
const hasSetDate = await setDateButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasSetDate) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
test("period date input restricts future dates", async ({
|
||||
onboardingPage,
|
||||
}) => {
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Open the modal
|
||||
const setDateButton = onboardingPage.getByRole("button", {
|
||||
name: /set date/i,
|
||||
});
|
||||
await setDateButton.click();
|
||||
|
||||
// Get the date input and check its max attribute
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||
await expect(dateInput).toBeVisible();
|
||||
|
||||
// The max attribute should be set to today's date (YYYY-MM-DD format)
|
||||
@@ -351,27 +189,22 @@ test.describe("period logging", () => {
|
||||
expect(maxDate.getTime()).toBeLessThanOrEqual(today.getTime());
|
||||
|
||||
// Close modal
|
||||
await page.getByRole("button", { name: /cancel/i }).click();
|
||||
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
|
||||
});
|
||||
|
||||
test("logging period from modal updates dashboard cycle info", async ({
|
||||
page,
|
||||
onboardingPage,
|
||||
}) => {
|
||||
// Look for the "Set date" button in onboarding banner
|
||||
const setDateButton = page.getByRole("button", { name: /set date/i });
|
||||
const hasSetDate = await setDateButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasSetDate) {
|
||||
// User may already have period date set - skip if no onboarding banner
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Click the set date button
|
||||
const setDateButton = onboardingPage.getByRole("button", {
|
||||
name: /set date/i,
|
||||
});
|
||||
await setDateButton.click();
|
||||
|
||||
// Wait for modal to be visible
|
||||
const modalTitle = page.getByRole("heading", {
|
||||
const modalTitle = onboardingPage.getByRole("heading", {
|
||||
name: /set period date/i,
|
||||
});
|
||||
await expect(modalTitle).toBeVisible();
|
||||
@@ -382,40 +215,150 @@ test.describe("period logging", () => {
|
||||
const dateStr = testDate.toISOString().split("T")[0];
|
||||
|
||||
// Fill in the date
|
||||
const dateInput = page.locator('input[type="date"]');
|
||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||
await dateInput.fill(dateStr);
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: /save/i }).click();
|
||||
// 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 });
|
||||
|
||||
// Dashboard should now show cycle information (Day X · Phase)
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Wait for network activity to settle
|
||||
await onboardingPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for cycle day display (e.g., "Day 8 · Follicular" or similar)
|
||||
const cycleInfo = page.getByText(/day\s+\d+\s+·/i);
|
||||
await expect(cycleInfo).toBeVisible({ timeout: 10000 });
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging flow - established user", () => {
|
||||
test("period date cannot be in the future", async ({ establishedPage }) => {
|
||||
// Navigate to period history
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for an "Add Period" or "Log Period" button
|
||||
const addButton = establishedPage.getByRole("button", {
|
||||
name: /add.*period|log.*period|new.*period/i,
|
||||
});
|
||||
const hasAddButton = await addButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasAddButton) {
|
||||
// Established user may have an edit button instead - also valid
|
||||
const editButton = establishedPage.getByRole("button", { name: /edit/i });
|
||||
const hasEdit = await editButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasEdit).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("period history displays cycle length between periods", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for cycle length column or text
|
||||
const cycleLengthText = establishedPage.getByText(
|
||||
/cycle.*length|\d+\s*days/i,
|
||||
);
|
||||
const hasCycleLength = await cycleLengthText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// If there's period data, cycle length should be visible
|
||||
const table = establishedPage.getByRole("table");
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
|
||||
if (hasTable) {
|
||||
// Table has header for cycle length
|
||||
const header = establishedPage.getByRole("columnheader", {
|
||||
name: /cycle.*length|days/i,
|
||||
});
|
||||
const hasHeader = await header.isVisible().catch(() => false);
|
||||
expect(hasHeader || hasCycleLength).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("period history shows prediction accuracy when available", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for prediction-related text (early/late, accuracy)
|
||||
const predictionText = establishedPage.getByText(
|
||||
/early|late|accuracy|predicted/i,
|
||||
);
|
||||
const hasPrediction = await predictionText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Prediction info may not be visible if not enough data
|
||||
if (hasPrediction) {
|
||||
await expect(predictionText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can delete period log from history", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for delete button
|
||||
const deleteButton = establishedPage.getByRole("button", {
|
||||
name: /delete/i,
|
||||
});
|
||||
const hasDelete = await deleteButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasDelete) {
|
||||
// Delete button exists for period entries
|
||||
await expect(deleteButton.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can edit period log from history", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for edit button
|
||||
const editButton = establishedPage.getByRole("button", { name: /edit/i });
|
||||
const hasEdit = await editButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasEdit) {
|
||||
// Edit button exists for period entries
|
||||
await expect(editButton.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("edit period modal flow changes date successfully", async ({
|
||||
page,
|
||||
establishedPage,
|
||||
}) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for edit button and table to ensure we have data
|
||||
const editButton = page.getByRole("button", { name: /edit/i }).first();
|
||||
const hasEdit = await editButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEdit) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
const editButton = establishedPage
|
||||
.getByRole("button", { name: /edit/i })
|
||||
.first();
|
||||
await expect(editButton).toBeVisible();
|
||||
|
||||
// Get the original date from the first row
|
||||
const firstRow = page.locator("tbody tr").first();
|
||||
const firstRow = establishedPage.locator("tbody tr").first();
|
||||
const originalDateCell = firstRow.locator("td").first();
|
||||
const originalDateText = await originalDateCell.textContent();
|
||||
|
||||
@@ -423,34 +366,34 @@ test.describe("period logging", () => {
|
||||
await editButton.click();
|
||||
|
||||
// Edit modal should appear
|
||||
const editModalTitle = page.getByRole("heading", {
|
||||
const editModalTitle = establishedPage.getByRole("heading", {
|
||||
name: /edit period date/i,
|
||||
});
|
||||
await expect(editModalTitle).toBeVisible();
|
||||
|
||||
// Get the date input in the edit modal
|
||||
const editDateInput = page.locator("#editDate");
|
||||
const editDateInput = establishedPage.locator("#editDate");
|
||||
await expect(editDateInput).toBeVisible();
|
||||
|
||||
// Calculate a new date (14 days ago)
|
||||
// Calculate a new date (21 days ago to avoid conflicts)
|
||||
const newDate = new Date();
|
||||
newDate.setDate(newDate.getDate() - 14);
|
||||
newDate.setDate(newDate.getDate() - 21);
|
||||
const newDateStr = newDate.toISOString().split("T")[0];
|
||||
|
||||
// Clear and fill new date
|
||||
await editDateInput.fill(newDateStr);
|
||||
|
||||
// Click Save in the edit modal
|
||||
await page.getByRole("button", { name: /save/i }).click();
|
||||
await establishedPage.getByRole("button", { name: /save/i }).click();
|
||||
|
||||
// Modal should close
|
||||
await expect(editModalTitle).not.toBeVisible();
|
||||
|
||||
// Wait for table to refresh
|
||||
await page.waitForLoadState("networkidle");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Verify the date changed (the row should have new date text)
|
||||
const updatedDateCell = page
|
||||
const updatedDateCell = establishedPage
|
||||
.locator("tbody tr")
|
||||
.first()
|
||||
.locator("td")
|
||||
@@ -471,23 +414,20 @@ test.describe("period logging", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("delete period confirmation flow removes entry", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
test("delete period confirmation flow removes entry", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for delete button
|
||||
const deleteButton = page
|
||||
const deleteButton = establishedPage
|
||||
.getByRole("button", { name: /delete/i })
|
||||
.first();
|
||||
const hasDelete = await deleteButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasDelete) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
await expect(deleteButton).toBeVisible();
|
||||
|
||||
// Get the total count text before deletion
|
||||
const totalText = page.getByText(/\d+ periods/);
|
||||
const totalText = establishedPage.getByText(/\d+ periods/);
|
||||
const hasTotal = await totalText.isVisible().catch(() => false);
|
||||
let originalCount = 0;
|
||||
if (hasTotal) {
|
||||
@@ -503,36 +443,36 @@ test.describe("period logging", () => {
|
||||
await deleteButton.click();
|
||||
|
||||
// Confirmation modal should appear
|
||||
const confirmModalTitle = page.getByRole("heading", {
|
||||
const confirmModalTitle = establishedPage.getByRole("heading", {
|
||||
name: /delete period/i,
|
||||
});
|
||||
await expect(confirmModalTitle).toBeVisible();
|
||||
|
||||
// Should show warning message
|
||||
const warningText = page.getByText(/are you sure.*delete/i);
|
||||
const warningText = establishedPage.getByText(/are you sure.*delete/i);
|
||||
await expect(warningText).toBeVisible();
|
||||
|
||||
// Should have Cancel and Confirm buttons
|
||||
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /confirm/i }),
|
||||
establishedPage.getByRole("button", { name: /cancel/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
establishedPage.getByRole("button", { name: /confirm/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Click Confirm to delete
|
||||
await page.getByRole("button", { name: /confirm/i }).click();
|
||||
await establishedPage.getByRole("button", { name: /confirm/i }).click();
|
||||
|
||||
// Modal should close
|
||||
await expect(confirmModalTitle).not.toBeVisible();
|
||||
|
||||
// Wait for page to refresh
|
||||
await page.waitForLoadState("networkidle");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// If we had a count, verify it decreased
|
||||
if (originalCount > 1) {
|
||||
const newTotalText = page.getByText(/\d+ periods/);
|
||||
const newTotalVisible = await newTotalText
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const newTotalText = establishedPage.getByText(/\d+ periods/);
|
||||
const newTotalVisible = await newTotalText.isVisible().catch(() => false);
|
||||
if (newTotalVisible) {
|
||||
const newCountMatch = (await newTotalText.textContent())?.match(
|
||||
/(\d+) periods/,
|
||||
@@ -544,5 +484,4 @@ test.describe("period logging", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,31 +53,32 @@ test.describe("plan page", () => {
|
||||
test("shows current cycle status section", async ({ page }) => {
|
||||
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", {
|
||||
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) {
|
||||
await expect(statusSection).toBeVisible();
|
||||
try {
|
||||
// Wait for Current Status section to be visible (data loaded successfully)
|
||||
await expect(statusSection).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 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
|
||||
await expect(page.getByText(/training type:/i)).toBeVisible();
|
||||
await expect(page.getByText(/training type:/i)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Should show weekly limit
|
||||
await expect(page.getByText(/weekly limit:/i)).toBeVisible();
|
||||
} else {
|
||||
// If no status, should see loading or error state
|
||||
const loading = page.getByText(/loading/i);
|
||||
const error = page.getByRole("alert");
|
||||
const hasLoading = await loading.isVisible().catch(() => false);
|
||||
const hasError = await error.isVisible().catch(() => false);
|
||||
|
||||
expect(hasLoading || hasError).toBe(true);
|
||||
await expect(page.getByText(/weekly limit:/i)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// If status section not visible, check for error alert
|
||||
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase.
|
||||
// ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests.
|
||||
|
||||
import { type ChildProcess, execSync, spawn } from "node:child_process";
|
||||
import { createCipheriv, randomBytes } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -11,6 +13,45 @@ import {
|
||||
getMissingCollections,
|
||||
} from "../scripts/setup-db";
|
||||
|
||||
/**
|
||||
* Test user presets for different e2e test scenarios.
|
||||
*/
|
||||
export type TestUserPreset =
|
||||
| "onboarding"
|
||||
| "established"
|
||||
| "calendar"
|
||||
| "garmin"
|
||||
| "garminExpired";
|
||||
|
||||
/**
|
||||
* Configuration for each test user type.
|
||||
*/
|
||||
export const TEST_USERS: Record<
|
||||
TestUserPreset,
|
||||
{ email: string; password: string }
|
||||
> = {
|
||||
onboarding: {
|
||||
email: "e2e-onboarding@phaseflow.local",
|
||||
password: "e2e-onboarding-123",
|
||||
},
|
||||
established: {
|
||||
email: "e2e-test@phaseflow.local",
|
||||
password: "e2e-test-password-123",
|
||||
},
|
||||
calendar: {
|
||||
email: "e2e-calendar@phaseflow.local",
|
||||
password: "e2e-calendar-123",
|
||||
},
|
||||
garmin: {
|
||||
email: "e2e-garmin@phaseflow.local",
|
||||
password: "e2e-garmin-123",
|
||||
},
|
||||
garminExpired: {
|
||||
email: "e2e-garmin-expired@phaseflow.local",
|
||||
password: "e2e-garmin-expired-123",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for the test harness.
|
||||
*/
|
||||
@@ -83,19 +124,52 @@ async function waitForReady(url: string, timeoutMs = 30000): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the admin superuser using the PocketBase CLI.
|
||||
* Sleeps for the specified number of milliseconds.
|
||||
*/
|
||||
function createAdminUser(
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the admin superuser using the PocketBase CLI.
|
||||
* Retries on database lock errors since PocketBase may still be running migrations.
|
||||
*/
|
||||
async function createAdminUser(
|
||||
dataDir: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): void {
|
||||
maxRetries = 5,
|
||||
): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
execSync(
|
||||
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
const errorMsg = String(lastError.message || lastError);
|
||||
|
||||
// Retry on database lock errors
|
||||
if (
|
||||
errorMsg.includes("database is locked") ||
|
||||
errorMsg.includes("SQLITE_BUSY")
|
||||
) {
|
||||
await sleep(100 * (attempt + 1)); // Exponential backoff: 100ms, 200ms, 300ms...
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, throw immediately
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +214,14 @@ async function addUserFields(pb: PocketBase): Promise<void> {
|
||||
* Sets up API rules for collections to allow user access.
|
||||
*/
|
||||
async function setupApiRules(pb: PocketBase): Promise<void> {
|
||||
// Allow users to update their own user record
|
||||
// viewRule allows reading user records by ID (needed for ICS calendar feed)
|
||||
const usersCollection = await pb.collections.getOne("users");
|
||||
await pb.collections.update(usersCollection.id, {
|
||||
viewRule: "", // Empty string = allow all authenticated & unauthenticated reads
|
||||
updateRule: "id = @request.auth.id",
|
||||
});
|
||||
|
||||
// Allow users to read/write their own period_logs
|
||||
const periodLogs = await pb.collections.getOne("period_logs");
|
||||
await pb.collections.update(periodLogs.id, {
|
||||
@@ -181,20 +263,91 @@ async function setupCollections(pb: PocketBase): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the test user with period data.
|
||||
* Retries an async operation with exponential backoff.
|
||||
*/
|
||||
async function createTestUser(
|
||||
pb: PocketBase,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
// Calculate date 14 days ago for mid-cycle test data
|
||||
async function retryAsync<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 5,
|
||||
baseDelayMs = 100,
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
const errorMsg = String(lastError.message || lastError);
|
||||
|
||||
// Retry on transient errors (database busy, connection issues)
|
||||
if (
|
||||
errorMsg.includes("database is locked") ||
|
||||
errorMsg.includes("SQLITE_BUSY") ||
|
||||
errorMsg.includes("Failed to create record")
|
||||
) {
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, throw immediately
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string using AES-256-GCM (matches src/lib/encryption.ts format).
|
||||
* Uses the test encryption key from playwright.config.ts.
|
||||
*/
|
||||
function encryptForTest(plaintext: string): string {
|
||||
const key = Buffer.from(
|
||||
"e2e-test-encryption-key-32chars".padEnd(32, "0").slice(0, 32),
|
||||
);
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the onboarding test user (no period data).
|
||||
*/
|
||||
async function createOnboardingUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.onboarding;
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the established test user with period data (default user).
|
||||
*/
|
||||
async function createEstablishedUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.established;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Create the test user
|
||||
const user = await pb.collection("users").create({
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
@@ -202,18 +355,180 @@ async function createTestUser(
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a period log entry
|
||||
await pb.collection("period_logs").create({
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the calendar test user with period data and calendar token.
|
||||
*/
|
||||
async function createCalendarUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.calendar;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
calendarToken: "e2e-test-calendar-token-12345678",
|
||||
}),
|
||||
);
|
||||
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Garmin test user with period data and valid Garmin tokens.
|
||||
*/
|
||||
async function createGarminUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.garmin;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Token expires 90 days in the future
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const oauth1Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
oauth_token: "test-oauth1-token",
|
||||
oauth_token_secret: "test-oauth1-secret",
|
||||
}),
|
||||
);
|
||||
|
||||
const oauth2Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
access_token: "test-access-token",
|
||||
refresh_token: "test-refresh-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 7776000,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
garminConnected: true,
|
||||
garminOauth1Token: oauth1Token,
|
||||
garminOauth2Token: oauth2Token,
|
||||
garminTokenExpiresAt: expiresAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Garmin expired test user with period data and expired Garmin tokens.
|
||||
*/
|
||||
async function createGarminExpiredUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.garminExpired;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Token expired 1 day ago
|
||||
const expiredAt = new Date();
|
||||
expiredAt.setDate(expiredAt.getDate() - 1);
|
||||
|
||||
const oauth1Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
oauth_token: "test-expired-oauth1-token",
|
||||
oauth_token_secret: "test-expired-oauth1-secret",
|
||||
}),
|
||||
);
|
||||
|
||||
const oauth2Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
access_token: "test-expired-access-token",
|
||||
refresh_token: "test-expired-refresh-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 7776000,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
garminConnected: true,
|
||||
garminOauth1Token: oauth1Token,
|
||||
garminOauth2Token: oauth2Token,
|
||||
garminTokenExpiresAt: expiredAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all test users for e2e tests.
|
||||
*/
|
||||
async function createAllTestUsers(pb: PocketBase): Promise<void> {
|
||||
await createOnboardingUser(pb);
|
||||
await createEstablishedUser(pb);
|
||||
await createCalendarUser(pb);
|
||||
await createGarminUser(pb);
|
||||
await createGarminExpiredUser(pb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a fresh PocketBase instance for e2e testing.
|
||||
*/
|
||||
@@ -247,8 +562,8 @@ export async function start(
|
||||
// Wait for PocketBase to be ready
|
||||
await waitForReady(url);
|
||||
|
||||
// Create admin user via CLI
|
||||
createAdminUser(dataDir, config.adminEmail, config.adminPassword);
|
||||
// Create admin user via CLI (with retry for database lock during migrations)
|
||||
await createAdminUser(dataDir, config.adminEmail, config.adminPassword);
|
||||
|
||||
// Connect to PocketBase as admin
|
||||
const pb = new PocketBase(url);
|
||||
@@ -260,8 +575,8 @@ export async function start(
|
||||
// Set up collections
|
||||
await setupCollections(pb);
|
||||
|
||||
// Create test user with period data
|
||||
await createTestUser(pb, config.testUserEmail, config.testUserPassword);
|
||||
// Create all test users for different e2e scenarios
|
||||
await createAllTestUsers(pb);
|
||||
|
||||
currentState = {
|
||||
process: pbProcess,
|
||||
|
||||
@@ -435,10 +435,12 @@ test.describe("settings", () => {
|
||||
const newValue = originalValue === "08:00" ? "09:00" : "08:00";
|
||||
await notificationTimeInput.fill(newValue);
|
||||
|
||||
// Save
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
@@ -453,7 +455,9 @@ test.describe("settings", () => {
|
||||
// Restore original value
|
||||
await notificationTimeAfter.fill(originalValue);
|
||||
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 }) => {
|
||||
@@ -475,10 +479,12 @@ test.describe("settings", () => {
|
||||
: "America/New_York";
|
||||
await timezoneInput.fill(newValue);
|
||||
|
||||
// Save
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
@@ -493,7 +499,9 @@ test.describe("settings", () => {
|
||||
// Restore original value
|
||||
await timezoneAfter.fill(originalValue);
|
||||
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 ({
|
||||
@@ -536,10 +544,12 @@ test.describe("settings", () => {
|
||||
await notificationTimeInput.fill(newNotificationTime);
|
||||
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 });
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
@@ -561,7 +571,9 @@ test.describe("settings", () => {
|
||||
await notificationTimeAfter.fill(originalNotificationTime);
|
||||
await timezoneAfter.fill(originalTimezone);
|
||||
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 ({
|
||||
@@ -582,10 +594,12 @@ test.describe("settings", () => {
|
||||
const newValue = originalValue === "28" ? "31" : "28";
|
||||
await cycleLengthInput.fill(newValue);
|
||||
|
||||
// Save
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
@@ -600,7 +614,9 @@ test.describe("settings", () => {
|
||||
// Restore original value
|
||||
await cycleLengthAfter.fill(originalValue);
|
||||
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 ({
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"pino": "^10.1.1",
|
||||
"pocketbase": "^0.26.5",
|
||||
"prom-client": "^15.1.3",
|
||||
|
||||
@@ -22,8 +22,9 @@ export default defineConfig({
|
||||
// Retry failed tests on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Limit parallel workers on CI to avoid resource issues
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
// Run tests sequentially since all tests share the same test user
|
||||
// Parallel execution causes race conditions when tests modify user state
|
||||
workers: 1,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
@@ -44,11 +45,12 @@ export default defineConfig({
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
|
||||
// Run dev server before starting tests
|
||||
// Note: POCKETBASE_URL is set by global-setup.ts for the test PocketBase instance
|
||||
// Note: POCKETBASE_URL is set for the test PocketBase instance on port 8091
|
||||
// We never reuse existing servers to ensure the correct PocketBase URL is used
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120 * 1000, // 2 minutes for Next.js to start
|
||||
env: {
|
||||
// Use the test PocketBase instance (port 8091)
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
oauth-1.0a:
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6
|
||||
pino:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1
|
||||
@@ -1773,6 +1776,9 @@ packages:
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
oauth-1.0a@2.2.6:
|
||||
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
@@ -3445,6 +3451,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
oauth-1.0a@2.2.6: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
@@ -45,13 +45,16 @@ oauth1_adapter = TypeAdapter(OAuth1Token)
|
||||
oauth2_adapter = TypeAdapter(OAuth2Token)
|
||||
|
||||
expires_at_ts = garth.client.oauth2_token.expires_at
|
||||
refresh_expires_at_ts = garth.client.oauth2_token.refresh_token_expires_at
|
||||
tokens = {
|
||||
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
|
||||
"oauth2": oauth2_adapter.dump_python(garth.client.oauth2_token, mode='json'),
|
||||
"expires_at": datetime.fromtimestamp(expires_at_ts).isoformat()
|
||||
"expires_at": datetime.fromtimestamp(expires_at_ts).isoformat(),
|
||||
"refresh_token_expires_at": datetime.fromtimestamp(refresh_expires_at_ts).isoformat()
|
||||
}
|
||||
|
||||
print("\n--- Copy everything below this line ---")
|
||||
print(json.dumps(tokens, indent=2))
|
||||
print("--- Copy everything above this line ---")
|
||||
print(f"\nTokens expire: {tokens['expires_at']}")
|
||||
print(f"\nAccess token expires: {tokens['expires_at']}")
|
||||
print(f"Refresh token expires: {tokens['refresh_token_expires_at']} (re-run script before this date)")
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getExistingCollectionNames,
|
||||
getMissingCollections,
|
||||
PERIOD_LOGS_COLLECTION,
|
||||
USER_CUSTOM_FIELDS,
|
||||
} from "./setup-db";
|
||||
|
||||
describe("PERIOD_LOGS_COLLECTION", () => {
|
||||
@@ -162,3 +163,21 @@ describe("getMissingCollections", () => {
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("USER_CUSTOM_FIELDS garmin token max lengths", () => {
|
||||
it("should have sufficient max length for garminOauth2Token field", () => {
|
||||
const oauth2Field = USER_CUSTOM_FIELDS.find(
|
||||
(f) => f.name === "garminOauth2Token",
|
||||
);
|
||||
expect(oauth2Field).toBeDefined();
|
||||
expect(oauth2Field?.max).toBeGreaterThanOrEqual(10000);
|
||||
});
|
||||
|
||||
it("should have sufficient max length for garminOauth1Token field", () => {
|
||||
const oauth1Field = USER_CUSTOM_FIELDS.find(
|
||||
(f) => f.name === "garminOauth1Token",
|
||||
);
|
||||
expect(oauth1Field).toBeDefined();
|
||||
expect(oauth1Field?.max).toBeGreaterThanOrEqual(10000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import PocketBase from "pocketbase";
|
||||
* Collection field definition for PocketBase.
|
||||
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
|
||||
*/
|
||||
interface CollectionField {
|
||||
export interface CollectionField {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
// Text field max length (PocketBase defaults to 5000 if not specified)
|
||||
max?: number;
|
||||
// Relation field properties (top-level, not in options)
|
||||
collectionId?: string;
|
||||
maxSelect?: number;
|
||||
@@ -142,11 +144,12 @@ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
|
||||
* Custom fields to add to the users collection.
|
||||
* These are required for Garmin integration and app functionality.
|
||||
*/
|
||||
const USER_CUSTOM_FIELDS = [
|
||||
export const USER_CUSTOM_FIELDS: CollectionField[] = [
|
||||
{ name: "garminConnected", type: "bool" },
|
||||
{ name: "garminOauth1Token", type: "text" },
|
||||
{ name: "garminOauth2Token", type: "text" },
|
||||
{ name: "garminOauth1Token", type: "text", max: 20000 },
|
||||
{ name: "garminOauth2Token", type: "text", max: 20000 },
|
||||
{ name: "garminTokenExpiresAt", type: "date" },
|
||||
{ name: "garminRefreshTokenExpiresAt", type: "date" },
|
||||
{ name: "calendarToken", type: "text" },
|
||||
{ name: "lastPeriodDate", type: "date" },
|
||||
{ name: "cycleLength", type: "number" },
|
||||
@@ -156,36 +159,62 @@ const USER_CUSTOM_FIELDS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Adds custom fields to the users collection if they don't already exist.
|
||||
* Adds or updates custom fields on the users collection.
|
||||
* For new fields: adds them. For existing fields: updates max constraint if different.
|
||||
* This is idempotent - safe to run multiple times.
|
||||
*/
|
||||
export async function addUserFields(pb: PocketBase): Promise<void> {
|
||||
const usersCollection = await pb.collections.getOne("users");
|
||||
|
||||
// Get existing field names
|
||||
const existingFieldNames = new Set(
|
||||
(usersCollection.fields || []).map((f: { name: string }) => f.name),
|
||||
// Build a map of existing fields by name
|
||||
const existingFieldsMap = new Map<string, Record<string, unknown>>(
|
||||
(usersCollection.fields || []).map((f: Record<string, unknown>) => [
|
||||
f.name as string,
|
||||
f,
|
||||
]),
|
||||
);
|
||||
|
||||
// Filter to only new fields
|
||||
const newFields = USER_CUSTOM_FIELDS.filter(
|
||||
(f) => !existingFieldNames.has(f.name),
|
||||
);
|
||||
// Separate new fields from fields that need updating
|
||||
const newFields: CollectionField[] = [];
|
||||
const fieldsToUpdate: string[] = [];
|
||||
|
||||
if (newFields.length > 0) {
|
||||
// Combine existing fields with new ones
|
||||
for (const definedField of USER_CUSTOM_FIELDS) {
|
||||
const existingField = existingFieldsMap.get(definedField.name);
|
||||
if (!existingField) {
|
||||
newFields.push(definedField);
|
||||
} else if (
|
||||
definedField.max !== undefined &&
|
||||
existingField.max !== definedField.max
|
||||
) {
|
||||
fieldsToUpdate.push(definedField.name);
|
||||
existingField.max = definedField.max;
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = newFields.length > 0 || fieldsToUpdate.length > 0;
|
||||
|
||||
if (hasChanges) {
|
||||
// Combine existing fields (with updates) and new fields
|
||||
const allFields = [...(usersCollection.fields || []), ...newFields];
|
||||
|
||||
await pb.collections.update(usersCollection.id, {
|
||||
fields: allFields,
|
||||
});
|
||||
|
||||
if (newFields.length > 0) {
|
||||
console.log(
|
||||
` Added ${newFields.length} field(s) to users:`,
|
||||
newFields.map((f) => f.name),
|
||||
);
|
||||
}
|
||||
if (fieldsToUpdate.length > 0) {
|
||||
console.log(
|
||||
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
|
||||
fieldsToUpdate,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(" All user fields already exist.");
|
||||
console.log(" All user fields already exist with correct settings.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "valid-calendar-token-abc123def",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -14,11 +14,13 @@ interface RouteParams {
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { userId, token } = await params;
|
||||
const { userId, token: rawToken } = await params;
|
||||
// Strip .ics suffix if present (Next.js may include it in the param)
|
||||
const token = rawToken.endsWith(".ics") ? rawToken.slice(0, -4) : rawToken;
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
try {
|
||||
// Fetch user from database
|
||||
const pb = createPocketBaseClient();
|
||||
const user = await pb.collection("users").getOne(userId);
|
||||
|
||||
// Check if user has a calendar token set
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("POST /api/calendar/regenerate-token", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "old-calendar-token-abc123",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
||||
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { User } from "@/types";
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { User } from "@/types";
|
||||
let mockUsers: User[] = [];
|
||||
// Track DailyLog creations
|
||||
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||
// Track user updates
|
||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||
|
||||
// Mock PocketBase
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
@@ -20,6 +22,7 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
return [];
|
||||
}),
|
||||
create: mockPbCreate,
|
||||
update: mockPbUpdate,
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
@@ -28,7 +31,14 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
const mockDecrypt = vi.fn((ciphertext: string) => {
|
||||
// Return mock OAuth2 token JSON
|
||||
if (ciphertext.includes("oauth2")) {
|
||||
return JSON.stringify({ accessToken: "mock-token-123" });
|
||||
return JSON.stringify({ access_token: "mock-token-123" });
|
||||
}
|
||||
// Return mock OAuth1 token JSON (needed for refresh flow)
|
||||
if (ciphertext.includes("oauth1")) {
|
||||
return JSON.stringify({
|
||||
oauth_token: "mock-oauth1-token",
|
||||
oauth_token_secret: "mock-oauth1-secret",
|
||||
});
|
||||
}
|
||||
return ciphertext.replace("encrypted:", "");
|
||||
});
|
||||
@@ -57,10 +67,15 @@ vi.mock("@/lib/garmin", () => ({
|
||||
|
||||
// Mock email sending
|
||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@/lib/email", () => ({
|
||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||
mockSendTokenExpirationWarning(...args),
|
||||
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
|
||||
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||
mockSendPeriodConfirmationEmail(...args),
|
||||
}));
|
||||
|
||||
// Mock logger (required for route to run without side effects)
|
||||
@@ -87,6 +102,7 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
@@ -112,8 +128,10 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mockUsers = [];
|
||||
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
||||
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
|
||||
process.env.CRON_SECRET = validSecret;
|
||||
});
|
||||
|
||||
@@ -188,9 +206,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
||||
});
|
||||
|
||||
it("skips users with expired tokens", async () => {
|
||||
mockIsTokenExpired.mockReturnValue(true);
|
||||
mockUsers = [createMockUser()];
|
||||
it("skips users with expired refresh tokens", async () => {
|
||||
// Set refresh token to expired (in the past)
|
||||
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -415,9 +436,28 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
|
||||
describe("Token expiration warnings", () => {
|
||||
it("sends warning email when token expires in exactly 14 days", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(14);
|
||||
// Use fake timers to ensure consistent date calculations
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Helper to create a date N days from now
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
it("sends warning email when refresh token expires in exactly 14 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -430,9 +470,13 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.warningsSent).toBe(1);
|
||||
});
|
||||
|
||||
it("sends warning email when token expires in exactly 7 days", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(7);
|
||||
it("sends warning email when refresh token expires in exactly 7 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||
}),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -445,36 +489,40 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.warningsSent).toBe(1);
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 30 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(30);
|
||||
it("does not send warning when refresh token expires in 30 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 15 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(15);
|
||||
it("does not send warning when refresh token expires in 15 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 8 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(8);
|
||||
it("does not send warning when refresh token expires in 8 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 6 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(6);
|
||||
it("does not send warning when refresh token expires in 6 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -483,11 +531,17 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
it("sends warnings for multiple users on different thresholds", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ id: "user1", email: "user1@example.com" }),
|
||||
createMockUser({ id: "user2", email: "user2@example.com" }),
|
||||
createMockUser({
|
||||
id: "user1",
|
||||
email: "user1@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
createMockUser({
|
||||
id: "user2",
|
||||
email: "user2@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||
}),
|
||||
];
|
||||
// First user at 14 days, second user at 7 days
|
||||
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -507,8 +561,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
|
||||
it("continues processing sync even if warning email fails", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(14);
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
];
|
||||
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
||||
new Error("Email failed"),
|
||||
);
|
||||
@@ -520,10 +578,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.usersProcessed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not send warning for expired tokens", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockIsTokenExpired.mockReturnValue(true);
|
||||
mockDaysUntilExpiry.mockReturnValue(-1);
|
||||
it("does not send warning for expired refresh tokens", async () => {
|
||||
// Expired refresh tokens are skipped entirely (not synced), so no warning
|
||||
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
|
||||
@@ -5,14 +5,17 @@ import { NextResponse } from "next/server";
|
||||
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||
import { decrypt } from "@/lib/encryption";
|
||||
import { decrypt, encrypt } from "@/lib/encryption";
|
||||
import {
|
||||
daysUntilExpiry,
|
||||
fetchBodyBattery,
|
||||
fetchHrvStatus,
|
||||
fetchIntensityMinutes,
|
||||
isTokenExpired,
|
||||
} from "@/lib/garmin";
|
||||
import {
|
||||
exchangeOAuth1ForOAuth2,
|
||||
isAccessTokenExpired,
|
||||
type OAuth1TokenData,
|
||||
} from "@/lib/garmin-auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
activeUsersGauge,
|
||||
@@ -20,13 +23,14 @@ import {
|
||||
garminSyncTotal,
|
||||
} from "@/lib/metrics";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
import type { GarminTokens, User } from "@/types";
|
||||
import type { User } from "@/types";
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
usersProcessed: number;
|
||||
errors: number;
|
||||
skippedExpired: number;
|
||||
tokensRefreshed: number;
|
||||
warningsSent: number;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -47,6 +51,7 @@ export async function POST(request: Request) {
|
||||
usersProcessed: 0,
|
||||
errors: 0,
|
||||
skippedExpired: 0,
|
||||
tokensRefreshed: 0,
|
||||
warningsSent: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@@ -66,38 +71,81 @@ export async function POST(request: Request) {
|
||||
const userSyncStartTime = Date.now();
|
||||
|
||||
try {
|
||||
// Check if tokens are expired
|
||||
// Check if refresh token is expired (user needs to re-auth via Python script)
|
||||
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: user.garminOauth1Token,
|
||||
oauth2: user.garminOauth2Token,
|
||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||
expires_at: user.garminTokenExpiresAt!.toISOString(),
|
||||
};
|
||||
|
||||
if (isTokenExpired(tokens)) {
|
||||
if (user.garminRefreshTokenExpiresAt) {
|
||||
const refreshTokenExpired =
|
||||
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
|
||||
if (refreshTokenExpired) {
|
||||
logger.info(
|
||||
{ userId: user.id },
|
||||
"Refresh token expired, skipping user",
|
||||
);
|
||||
result.skippedExpired++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync start
|
||||
logger.info({ userId: user.id }, "Garmin sync start");
|
||||
|
||||
// Check for token expiration warnings (exactly 14 or 7 days)
|
||||
const daysRemaining = daysUntilExpiry(tokens);
|
||||
// Check for refresh token expiration warnings (exactly 14 or 7 days)
|
||||
if (user.garminRefreshTokenExpiresAt) {
|
||||
const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = refreshExpiry.getTime() - now.getTime();
|
||||
const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||
try {
|
||||
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
|
||||
await sendTokenExpirationWarning(
|
||||
user.email,
|
||||
daysRemaining,
|
||||
user.id,
|
||||
);
|
||||
result.warningsSent++;
|
||||
} catch {
|
||||
// Continue processing even if warning email fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt OAuth2 token
|
||||
// Decrypt tokens
|
||||
const oauth1Json = decrypt(user.garminOauth1Token);
|
||||
const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData;
|
||||
const oauth2Json = decrypt(user.garminOauth2Token);
|
||||
const oauth2Data = JSON.parse(oauth2Json);
|
||||
const accessToken = oauth2Data.accessToken;
|
||||
let oauth2Data = JSON.parse(oauth2Json);
|
||||
|
||||
// Check if access token needs refresh
|
||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||
const accessTokenExpiresAt = user.garminTokenExpiresAt!;
|
||||
if (isAccessTokenExpired(accessTokenExpiresAt)) {
|
||||
logger.info({ userId: user.id }, "Access token expired, refreshing");
|
||||
try {
|
||||
const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data);
|
||||
oauth2Data = refreshResult.oauth2;
|
||||
|
||||
// Update stored tokens
|
||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data));
|
||||
await pb.collection("users").update(user.id, {
|
||||
garminOauth2Token: encryptedOauth2,
|
||||
garminTokenExpiresAt: refreshResult.expires_at,
|
||||
garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at,
|
||||
});
|
||||
|
||||
result.tokensRefreshed++;
|
||||
logger.info({ userId: user.id }, "Access token refreshed");
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
{ userId: user.id, err: refreshError },
|
||||
"Failed to refresh access token",
|
||||
);
|
||||
result.errors++;
|
||||
garminSyncTotal.inc({ status: "failure" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = oauth2Data.access_token;
|
||||
|
||||
// Fetch Garmin data
|
||||
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
||||
|
||||
@@ -29,9 +29,15 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
|
||||
// Mock email sending
|
||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@/lib/email", () => ({
|
||||
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||
mockSendTokenExpirationWarning(...args),
|
||||
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||
mockSendPeriodConfirmationEmail(...args),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
@@ -48,6 +54,7 @@ describe("POST /api/cron/notifications", () => {
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -9,9 +9,29 @@ import type { User } from "@/types";
|
||||
// Module-level variable to control mock user in tests
|
||||
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
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({})),
|
||||
createPocketBaseClient: vi.fn(() => mockPb),
|
||||
loadAuthFromCookies: vi.fn(),
|
||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
||||
getCurrentUser: vi.fn(() => currentMockUser),
|
||||
@@ -24,7 +44,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
||||
if (!currentMockUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(request, currentMockUser);
|
||||
return handler(request, currentMockUser, mockPb);
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -39,6 +59,7 @@ describe("GET /api/cycle/current", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
|
||||
@@ -40,9 +40,18 @@ function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
|
||||
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
|
||||
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(
|
||||
{
|
||||
error:
|
||||
@@ -53,20 +62,16 @@ export const GET = withAuth(async (_request, user) => {
|
||||
}
|
||||
|
||||
// Calculate current cycle position
|
||||
const cycleDay = getCycleDay(
|
||||
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 daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength);
|
||||
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, cycleLength);
|
||||
|
||||
return NextResponse.json({
|
||||
cycleDay,
|
||||
phase,
|
||||
phaseConfig,
|
||||
daysUntilNextPhase,
|
||||
cycleLength: user.cycleLength,
|
||||
cycleLength,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ describe("POST /api/cycle/period", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2024-12-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -71,6 +71,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -100,6 +101,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -129,6 +131,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -156,6 +159,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -185,6 +189,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: pastDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -216,6 +221,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -245,6 +251,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -274,6 +281,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -303,6 +311,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -329,6 +338,7 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -12,26 +12,41 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
const connected = freshUser.garminConnected === true;
|
||||
|
||||
if (!connected) {
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
connected: false,
|
||||
daysUntilExpiry: null,
|
||||
expired: false,
|
||||
warningLevel: null,
|
||||
});
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = freshUser.garminTokenExpiresAt
|
||||
// Use refresh token expiry for user-facing warnings (when they need to re-auth)
|
||||
// Fall back to access token expiry if refresh expiry not set
|
||||
const refreshTokenExpiresAt = freshUser.garminRefreshTokenExpiresAt
|
||||
? String(freshUser.garminRefreshTokenExpiresAt)
|
||||
: "";
|
||||
const accessTokenExpiresAt = freshUser.garminTokenExpiresAt
|
||||
? String(freshUser.garminTokenExpiresAt)
|
||||
: "";
|
||||
|
||||
const tokens = {
|
||||
oauth1: "",
|
||||
oauth2: "",
|
||||
expires_at: expiresAt,
|
||||
expires_at: accessTokenExpiresAt,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
|
||||
};
|
||||
|
||||
const days = daysUntilExpiry(tokens);
|
||||
const expired = isTokenExpired(tokens);
|
||||
|
||||
// Check if refresh token is expired (user needs to re-authenticate)
|
||||
const expired = refreshTokenExpiresAt
|
||||
? new Date(refreshTokenExpiresAt) <= new Date()
|
||||
: isTokenExpired(tokens);
|
||||
|
||||
let warningLevel: "warning" | "critical" | null = null;
|
||||
if (days <= 7) {
|
||||
@@ -40,10 +55,15 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
warningLevel = "warning";
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
return NextResponse.json(
|
||||
{
|
||||
connected: true,
|
||||
daysUntilExpiry: days,
|
||||
expired,
|
||||
warningLevel,
|
||||
});
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("POST /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -141,6 +142,7 @@ describe("POST /api/garmin/tokens", () => {
|
||||
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
||||
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
||||
garminTokenExpiresAt: expiresAt,
|
||||
garminRefreshTokenExpiresAt: expect.any(String),
|
||||
garminConnected: true,
|
||||
});
|
||||
});
|
||||
@@ -267,6 +269,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -304,6 +307,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: null,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
garminConnected: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { logger } from "@/lib/logger";
|
||||
|
||||
export const POST = withAuth(async (request, user, pb) => {
|
||||
const body = await request.json();
|
||||
const { oauth1, oauth2, expires_at } = body;
|
||||
const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!oauth1) {
|
||||
@@ -52,6 +52,23 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate refresh_token_expires_at if provided
|
||||
let refreshTokenExpiresAt = refresh_token_expires_at;
|
||||
if (refreshTokenExpiresAt) {
|
||||
const refreshExpiryDate = new Date(refreshTokenExpiresAt);
|
||||
if (Number.isNaN(refreshExpiryDate.getTime())) {
|
||||
return NextResponse.json(
|
||||
{ error: "refresh_token_expires_at must be a valid date" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If not provided, estimate refresh token expiry as ~30 days from now
|
||||
refreshTokenExpiresAt = new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
// Encrypt tokens before storing
|
||||
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
||||
@@ -61,6 +78,7 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
garminOauth1Token: encryptedOauth1,
|
||||
garminOauth2Token: encryptedOauth2,
|
||||
garminTokenExpiresAt: expires_at,
|
||||
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
garminConnected: true,
|
||||
});
|
||||
|
||||
@@ -84,11 +102,12 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate days until expiry
|
||||
// Calculate days until refresh token expiry (what users care about)
|
||||
const expiryDays = daysUntilExpiry({
|
||||
oauth1: "",
|
||||
oauth2: "",
|
||||
expires_at,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -103,6 +122,7 @@ export const DELETE = withAuth(async (_request, user, pb) => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: null,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
garminConnected: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("GET /api/history", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("POST /api/overrides", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -187,6 +188,7 @@ describe("DELETE /api/overrides", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("GET /api/period-history", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("PATCH /api/period-logs/[id]", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -276,6 +277,7 @@ describe("DELETE /api/period-logs/[id]", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -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");
|
||||
@@ -48,6 +63,7 @@ describe("GET /api/today", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -12,10 +12,29 @@ let currentMockUser: User | null = null;
|
||||
// Track PocketBase update calls
|
||||
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,
|
||||
calendarToken: currentMockUser.calendarToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Create mock PocketBase client
|
||||
const mockPb = {
|
||||
collection: vi.fn(() => ({
|
||||
update: mockPbUpdate,
|
||||
getOne: mockPbGetOne,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -41,6 +60,7 @@ describe("GET /api/user", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -55,6 +75,7 @@ describe("GET /api/user", () => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
mockPbUpdate.mockClear();
|
||||
mockPbGetOne.mockClear();
|
||||
});
|
||||
|
||||
it("returns user profile when authenticated", async () => {
|
||||
@@ -76,17 +97,27 @@ describe("GET /api/user", () => {
|
||||
expect(body.timezone).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("does not expose sensitive token fields", async () => {
|
||||
it("does not expose sensitive Garmin token fields", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await GET(mockRequest);
|
||||
const body = await response.json();
|
||||
|
||||
// Should NOT include encrypted tokens
|
||||
// Should NOT include encrypted Garmin tokens
|
||||
expect(body.garminOauth1Token).toBeUndefined();
|
||||
expect(body.garminOauth2Token).toBeUndefined();
|
||||
expect(body.calendarToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes calendarToken for calendar subscription URL", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await GET(mockRequest);
|
||||
const body = await response.json();
|
||||
|
||||
// calendarToken is needed by the calendar page to display the subscription URL
|
||||
expect(body.calendarToken).toBe("cal-secret-token");
|
||||
});
|
||||
|
||||
it("includes activeOverrides array", async () => {
|
||||
@@ -119,6 +150,7 @@ describe("PATCH /api/user", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
@@ -133,6 +165,7 @@ describe("PATCH /api/user", () => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
mockPbUpdate.mockClear();
|
||||
mockPbGetOne.mockClear();
|
||||
});
|
||||
|
||||
// Helper to create mock request with JSON body
|
||||
@@ -370,9 +403,8 @@ describe("PATCH /api/user", () => {
|
||||
expect(body.cycleLength).toBe(32);
|
||||
expect(body.notificationTime).toBe("07:30");
|
||||
expect(body.timezone).toBe("America/New_York");
|
||||
// Should not expose sensitive fields
|
||||
// Should not expose sensitive Garmin token fields
|
||||
expect(body.garminOauth1Token).toBeUndefined();
|
||||
expect(body.garminOauth2Token).toBeUndefined();
|
||||
expect(body.calendarToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,24 +13,35 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
/**
|
||||
* GET /api/user
|
||||
* Returns the authenticated user's profile.
|
||||
* Fetches fresh data from database to ensure updates are reflected.
|
||||
* 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
|
||||
const lastPeriodDate = user.lastPeriodDate
|
||||
? user.lastPeriodDate.toISOString().split("T")[0]
|
||||
const lastPeriodDate = freshUser.lastPeriodDate
|
||||
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
garminConnected: user.garminConnected,
|
||||
cycleLength: user.cycleLength,
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: freshUser.id,
|
||||
email: freshUser.email,
|
||||
garminConnected: freshUser.garminConnected ?? false,
|
||||
cycleLength: freshUser.cycleLength,
|
||||
lastPeriodDate,
|
||||
notificationTime: user.notificationTime,
|
||||
timezone: user.timezone,
|
||||
activeOverrides: user.activeOverrides,
|
||||
});
|
||||
notificationTime: freshUser.notificationTime,
|
||||
timezone: freshUser.timezone,
|
||||
activeOverrides: freshUser.activeOverrides ?? [],
|
||||
calendarToken: (freshUser.calendarToken as string) || null,
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,7 +139,12 @@ export default function GarminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const showTokenInput = !status?.connected || status?.expired;
|
||||
// Show token input when:
|
||||
// - Not connected
|
||||
// - Token expired
|
||||
// - Warning level active (so user can proactively paste new tokens)
|
||||
const showTokenInput =
|
||||
!status?.connected || status?.expired || status?.warningLevel;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -242,7 +247,11 @@ export default function GarminSettingsPage() {
|
||||
{/* Token Input Section */}
|
||||
{showTokenInput && (
|
||||
<div className="border border-input rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{status?.connected && status?.warningLevel
|
||||
? "Refresh Tokens"
|
||||
: "Connect Garmin"}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">
|
||||
|
||||
@@ -40,7 +40,10 @@ export function DecisionCard({ decision }: DecisionCardProps) {
|
||||
const colors = getStatusColors(decision.status);
|
||||
|
||||
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>
|
||||
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
||||
<p className={colors.text}>{decision.reason}</p>
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("withAuth", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date(),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
|
||||
146
src/lib/garmin-auth.test.ts
Normal file
146
src/lib/garmin-auth.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// ABOUTME: Unit tests for Garmin OAuth1 to OAuth2 token exchange functionality.
|
||||
// ABOUTME: Tests access token expiry checks and token exchange logic.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { exchangeOAuth1ForOAuth2, isAccessTokenExpired } from "./garmin-auth";
|
||||
|
||||
describe("isAccessTokenExpired", () => {
|
||||
it("returns false when token expires in the future", () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setHours(futureDate.getHours() + 2);
|
||||
expect(isAccessTokenExpired(futureDate)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when token has expired", () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setHours(pastDate.getHours() - 1);
|
||||
expect(isAccessTokenExpired(pastDate)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when token expires within 5 minute buffer", () => {
|
||||
const nearFutureDate = new Date();
|
||||
nearFutureDate.setMinutes(nearFutureDate.getMinutes() + 3);
|
||||
expect(isAccessTokenExpired(nearFutureDate)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when token expires beyond 5 minute buffer", () => {
|
||||
const safeDate = new Date();
|
||||
safeDate.setMinutes(safeDate.getMinutes() + 10);
|
||||
expect(isAccessTokenExpired(safeDate)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles ISO string dates", () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setHours(futureDate.getHours() + 2);
|
||||
expect(isAccessTokenExpired(futureDate.toISOString())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exchangeOAuth1ForOAuth2", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("calls Garmin exchange endpoint with OAuth1 authorization", async () => {
|
||||
const mockOAuth2Response = {
|
||||
scope: "test-scope",
|
||||
jti: "test-jti",
|
||||
access_token: "new-access-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 3600,
|
||||
refresh_token_expires_in: 2592000,
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockOAuth2Response),
|
||||
});
|
||||
|
||||
const oauth1Token = {
|
||||
oauth_token: "test-oauth1-token",
|
||||
oauth_token_secret: "test-oauth1-secret",
|
||||
};
|
||||
|
||||
const result = await exchangeOAuth1ForOAuth2(oauth1Token);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: expect.stringContaining("OAuth"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.oauth2).toEqual(mockOAuth2Response);
|
||||
expect(result.expires_at).toBeDefined();
|
||||
expect(result.refresh_token_expires_at).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws error when exchange fails", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve("Unauthorized"),
|
||||
});
|
||||
|
||||
const oauth1Token = {
|
||||
oauth_token: "invalid-token",
|
||||
oauth_token_secret: "invalid-secret",
|
||||
};
|
||||
|
||||
await expect(exchangeOAuth1ForOAuth2(oauth1Token)).rejects.toThrow(
|
||||
"OAuth exchange failed: 401",
|
||||
);
|
||||
});
|
||||
|
||||
it("calculates correct expiry timestamps", async () => {
|
||||
const expiresIn = 3600; // 1 hour
|
||||
const refreshExpiresIn = 2592000; // 30 days
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
scope: "test-scope",
|
||||
jti: "test-jti",
|
||||
access_token: "token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh",
|
||||
expires_in: expiresIn,
|
||||
refresh_token_expires_in: refreshExpiresIn,
|
||||
}),
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const result = await exchangeOAuth1ForOAuth2({
|
||||
oauth_token: "token",
|
||||
oauth_token_secret: "secret",
|
||||
});
|
||||
|
||||
const expiresAt = new Date(result.expires_at).getTime();
|
||||
const refreshExpiresAt = new Date(
|
||||
result.refresh_token_expires_at,
|
||||
).getTime();
|
||||
|
||||
// Allow 5 second tolerance for test execution time
|
||||
expect(expiresAt).toBeGreaterThanOrEqual(now + expiresIn * 1000 - 5000);
|
||||
expect(expiresAt).toBeLessThanOrEqual(now + expiresIn * 1000 + 5000);
|
||||
|
||||
expect(refreshExpiresAt).toBeGreaterThanOrEqual(
|
||||
now + refreshExpiresIn * 1000 - 5000,
|
||||
);
|
||||
expect(refreshExpiresAt).toBeLessThanOrEqual(
|
||||
now + refreshExpiresIn * 1000 + 5000,
|
||||
);
|
||||
});
|
||||
});
|
||||
114
src/lib/garmin-auth.ts
Normal file
114
src/lib/garmin-auth.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// ABOUTME: Garmin OAuth1 to OAuth2 token exchange functionality.
|
||||
// ABOUTME: Uses OAuth1 tokens to refresh expired OAuth2 access tokens.
|
||||
import { createHmac } from "node:crypto";
|
||||
import OAuth from "oauth-1.0a";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const GARMIN_EXCHANGE_URL =
|
||||
"https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0";
|
||||
|
||||
const OAUTH_CONSUMER = {
|
||||
key: "fc3e99d2-118c-44b8-8ae3-03370dde24c0",
|
||||
secret: "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF",
|
||||
};
|
||||
|
||||
export interface OAuth1TokenData {
|
||||
oauth_token: string;
|
||||
oauth_token_secret: string;
|
||||
}
|
||||
|
||||
export interface OAuth2TokenData {
|
||||
scope: string;
|
||||
jti: string;
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
refresh_token_expires_in: number;
|
||||
}
|
||||
|
||||
export interface RefreshResult {
|
||||
oauth2: OAuth2TokenData;
|
||||
expires_at: string;
|
||||
refresh_token_expires_at: string;
|
||||
}
|
||||
|
||||
function hashFunctionSha1(baseString: string, key: string): string {
|
||||
return createHmac("sha1", key).update(baseString).digest("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange OAuth1 token for a fresh OAuth2 token.
|
||||
* This is how Garmin "refreshes" tokens - by re-exchanging OAuth1 for OAuth2.
|
||||
* The OAuth1 token lasts ~1 year, OAuth2 access token ~21 hours.
|
||||
*/
|
||||
export async function exchangeOAuth1ForOAuth2(
|
||||
oauth1Token: OAuth1TokenData,
|
||||
): Promise<RefreshResult> {
|
||||
const oauth = new OAuth({
|
||||
consumer: OAUTH_CONSUMER,
|
||||
signature_method: "HMAC-SHA1",
|
||||
hash_function: hashFunctionSha1,
|
||||
});
|
||||
|
||||
const requestData = {
|
||||
url: GARMIN_EXCHANGE_URL,
|
||||
method: "POST",
|
||||
};
|
||||
|
||||
const token = {
|
||||
key: oauth1Token.oauth_token,
|
||||
secret: oauth1Token.oauth_token_secret,
|
||||
};
|
||||
|
||||
const authHeader = oauth.toHeader(oauth.authorize(requestData, token));
|
||||
|
||||
logger.info("Exchanging OAuth1 token for fresh OAuth2 token");
|
||||
|
||||
const response = await fetch(GARMIN_EXCHANGE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...authHeader,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.error(
|
||||
{ status: response.status, body: text },
|
||||
"OAuth1 to OAuth2 exchange failed",
|
||||
);
|
||||
throw new Error(`OAuth exchange failed: ${response.status} - ${text}`);
|
||||
}
|
||||
|
||||
const oauth2Data = (await response.json()) as OAuth2TokenData;
|
||||
|
||||
const now = Date.now();
|
||||
const expiresAt = new Date(now + oauth2Data.expires_in * 1000).toISOString();
|
||||
const refreshTokenExpiresAt = new Date(
|
||||
now + oauth2Data.refresh_token_expires_in * 1000,
|
||||
).toISOString();
|
||||
|
||||
logger.info(
|
||||
{ expiresAt, refreshTokenExpiresAt },
|
||||
"OAuth2 token refreshed successfully",
|
||||
);
|
||||
|
||||
return {
|
||||
oauth2: oauth2Data,
|
||||
expires_at: expiresAt,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if access token is expired or expiring within the buffer period.
|
||||
* Buffer is 5 minutes to ensure we refresh before actual expiry.
|
||||
*/
|
||||
export function isAccessTokenExpired(expiresAt: Date | string): boolean {
|
||||
const expiryTime = new Date(expiresAt).getTime();
|
||||
const bufferMs = 5 * 60 * 1000; // 5 minutes buffer
|
||||
return Date.now() >= expiryTime - bufferMs;
|
||||
}
|
||||
@@ -110,6 +110,38 @@ describe("daysUntilExpiry", () => {
|
||||
expect(days).toBeGreaterThanOrEqual(6);
|
||||
expect(days).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it("uses refresh_token_expires_at when available", () => {
|
||||
const accessExpiry = new Date();
|
||||
accessExpiry.setDate(accessExpiry.getDate() + 1); // Access token expires in 1 day
|
||||
const refreshExpiry = new Date();
|
||||
refreshExpiry.setDate(refreshExpiry.getDate() + 30); // Refresh token expires in 30 days
|
||||
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: accessExpiry.toISOString(),
|
||||
refresh_token_expires_at: refreshExpiry.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
// Should use refresh token expiry (30 days), not access token expiry (1 day)
|
||||
expect(days).toBeGreaterThanOrEqual(29);
|
||||
expect(days).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("falls back to expires_at when refresh_token_expires_at not available", () => {
|
||||
const accessExpiry = new Date();
|
||||
accessExpiry.setDate(accessExpiry.getDate() + 5);
|
||||
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: accessExpiry.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeGreaterThanOrEqual(4);
|
||||
expect(days).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGarminData", () => {
|
||||
|
||||
@@ -36,8 +36,15 @@ export function isTokenExpired(tokens: GarminTokens): boolean {
|
||||
return expiresAt <= new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate days until refresh token expiry.
|
||||
* This is what users care about - when they need to re-authenticate.
|
||||
* Falls back to access token expiry if refresh token expiry not available.
|
||||
*/
|
||||
export function daysUntilExpiry(tokens: GarminTokens): number {
|
||||
const expiresAt = new Date(tokens.expires_at);
|
||||
const expiresAt = tokens.refresh_token_expires_at
|
||||
? new Date(tokens.refresh_token_expires_at)
|
||||
: new Date(tokens.expires_at);
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
@@ -68,6 +68,7 @@ describe("getCurrentUser", () => {
|
||||
garminOauth1Token: "encrypted1",
|
||||
garminOauth2Token: "encrypted2",
|
||||
garminTokenExpiresAt: "2025-06-01T00:00:00Z",
|
||||
garminRefreshTokenExpiresAt: "2025-07-01T00:00:00Z",
|
||||
calendarToken: "cal-token-123",
|
||||
lastPeriodDate: "2025-01-01",
|
||||
cycleLength: 28,
|
||||
@@ -105,6 +106,7 @@ describe("getCurrentUser", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "",
|
||||
garminRefreshTokenExpiresAt: "",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "2025-01-15",
|
||||
cycleLength: 31,
|
||||
@@ -139,6 +141,7 @@ describe("getCurrentUser", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "not-a-date",
|
||||
garminRefreshTokenExpiresAt: "also-not-a-date",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "",
|
||||
cycleLength: 28,
|
||||
|
||||
@@ -96,6 +96,7 @@ function mapRecordToUser(record: RecordModel): User {
|
||||
garminOauth1Token: record.garminOauth1Token as string,
|
||||
garminOauth2Token: record.garminOauth2Token as string,
|
||||
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
|
||||
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
|
||||
calendarToken: record.calendarToken as string,
|
||||
lastPeriodDate: parseDate(record.lastPeriodDate),
|
||||
cycleLength: record.cycleLength as number,
|
||||
|
||||
@@ -22,7 +22,8 @@ export interface User {
|
||||
garminConnected: boolean;
|
||||
garminOauth1Token: string; // encrypted JSON
|
||||
garminOauth2Token: string; // encrypted JSON
|
||||
garminTokenExpiresAt: Date | null;
|
||||
garminTokenExpiresAt: Date | null; // access token expiry (~21 hours)
|
||||
garminRefreshTokenExpiresAt: Date | null; // refresh token expiry (~30 days)
|
||||
|
||||
// Calendar
|
||||
calendarToken: string; // random secret for ICS URL
|
||||
@@ -87,6 +88,7 @@ export interface GarminTokens {
|
||||
oauth1: string;
|
||||
oauth2: string;
|
||||
expires_at: string;
|
||||
refresh_token_expires_at?: string;
|
||||
}
|
||||
|
||||
export interface PhaseConfig {
|
||||
|
||||
Reference in New Issue
Block a user