Compare commits

..

5 Commits

Author SHA1 Message Date
4a874476c3 Enable 5 previously skipped e2e tests
All checks were successful
Deploy / deploy (push) Successful in 1m37s
- Fix OIDC tests with route interception for auth-methods API
- Add data-testid to DecisionCard for reliable test selection
- Fix /api/today to fetch fresh user data instead of stale cookie data
- Fix period logging test timing with proper API wait patterns
- Fix decision engine test with waitForResponse instead of timeout
- Simplify mobile viewport test locator

All 206 e2e tests now pass with 0 skipped.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 06:30:51 +00:00
ff3d8fad2c Add Playwright fixtures with 5 test user types for e2e tests
Creates test infrastructure to enable previously skipped e2e tests:
- Onboarding user (no period data) for setup flow tests
- Established user (period 14 days ago) for normal usage tests
- Calendar user (with calendarToken) for ICS feed tests
- Garmin user (valid tokens) for connected state tests
- Garmin expired user (expired tokens) for expiry warning tests

Also fixes ICS feed route to strip .ics suffix from Next.js dynamic
route param, adds calendarToken to /api/user response, and sets
viewRule on users collection for unauthenticated ICS access.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 05:54:49 +00:00
b221acee40 Implement automatic Garmin token refresh and fix expiry tracking
- Add OAuth1 to OAuth2 token exchange using Garmin's exchange endpoint
- Track refresh token expiry (~30 days) instead of access token expiry (~21 hours)
- Auto-refresh access tokens in cron sync before they expire
- Update Python script to output refresh_token_expires_at
- Add garminRefreshTokenExpiresAt field to User type and database schema
- Fix token input UX: show when warning active, not just when disconnected
- Add Cache-Control headers to /api/user and /api/garmin/status to prevent stale data
- Add oauth-1.0a package for OAuth1 signature generation

The system now automatically refreshes OAuth2 tokens using the stored OAuth1 token,
so users only need to re-run the Python auth script every ~30 days (when refresh
token expires) instead of every ~21 hours (when access token expires).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:33:10 +00:00
6df145d916 Fix Garmin token storage and flaky e2e test
1. Increase garminOauth1Token and garminOauth2Token max length from
   5000 to 20000 characters to accommodate encrypted OAuth tokens.
   Add logic to update existing field constraints in addUserFields().

2. Fix flaky pocketbase-harness e2e test by adding retry logic with
   exponential backoff to createAdminUser() and createTestUser().
   Handles SQLite database lock during PocketBase startup migrations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:52:01 +00:00
00b84d0b22 Fix E2E test reliability issues and stale data bugs
- Fix race conditions: Set workers: 1 since all tests share test user state
- Fix stale data: GET /api/user and /api/cycle/current now fetch fresh data
  from database instead of returning stale PocketBase auth store cache
- Fix timing: Replace waitForTimeout with retry-based Playwright assertions
- Fix mobile test: Use exact heading match to avoid strict mode violation
- Add test user setup: Include notificationTime and update rule for users

All 1014 unit tests and 190 E2E tests pass.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:23:32 +00:00
50 changed files with 2502 additions and 1489 deletions

View File

@@ -147,6 +147,11 @@ These are optional enhancements to improve E2E coverage. Not required for featur
## Revision History ## Revision History
- 2026-01-13: Fixed E2E test reliability issues:
- Race conditions: Changed to single worker execution (tests share test user state)
- Stale data: GET /api/user and GET /api/cycle/current now fetch fresh data from database instead of stale auth cache
- Timing: Replaced fixed waitForTimeout calls with retry-based Playwright assertions
- Mobile test: Fixed strict mode violation by using exact heading match
- 2026-01-13: Marked notifications.spec.ts as redundant (notification preferences already covered in settings.spec.ts) - 2026-01-13: Marked notifications.spec.ts as redundant (notification preferences already covered in settings.spec.ts)
- 2026-01-13: Added dark-mode.spec.ts with 2 E2E tests (system preference detection for light/dark mode) - 2026-01-13: Added dark-mode.spec.ts with 2 E2E tests (system preference detection for light/dark mode)
- 2026-01-13: Added 4 Garmin E2E tests (network error recovery for save, disconnect, status fetch, retry) - 2026-01-13: Added 4 Garmin E2E tests (network error recovery for save, disconnect, status fetch, retry)

View File

@@ -228,26 +228,42 @@ test.describe("authentication", () => {
}); });
test.describe("OIDC authentication flow", () => { test.describe("OIDC authentication flow", () => {
// Mock PocketBase auth-methods to return OIDC provider
test.beforeEach(async ({ page }) => {
await page.route("**/api/collections/users/auth-methods*", (route) => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
usernamePassword: true,
oauth2: {
enabled: true,
providers: [
{
name: "oidc",
displayName: "Test Provider",
state: "mock-state",
codeVerifier: "mock-verifier",
codeChallenge: "mock-challenge",
codeChallengeMethod: "S256",
authURL: "https://mock.example.com/auth",
},
],
},
}),
});
});
});
test("OIDC button shows provider name when configured", async ({ test("OIDC button shows provider name when configured", async ({
page, page,
}) => { }) => {
await page.goto("/login"); await page.goto("/login");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for OIDC sign-in button with provider name
const oidcButton = page.getByRole("button", { name: /sign in with/i }); const oidcButton = page.getByRole("button", { name: /sign in with/i });
const hasOidc = await oidcButton.isVisible().catch(() => false);
if (hasOidc) {
// OIDC button should show provider display name
await expect(oidcButton).toBeVisible(); await expect(oidcButton).toBeVisible();
// Button text should include "Sign in with" prefix await expect(oidcButton).toContainText("Test Provider");
const buttonText = await oidcButton.textContent();
expect(buttonText?.toLowerCase()).toContain("sign in with");
} else {
// Skip test if OIDC not configured (email/password mode)
test.skip();
}
}); });
test("OIDC button shows loading state during authentication", async ({ test("OIDC button shows loading state during authentication", async ({
@@ -256,22 +272,18 @@ test.describe("authentication", () => {
await page.goto("/login"); await page.goto("/login");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Find button by initial text
const oidcButton = page.getByRole("button", { name: /sign in with/i }); const oidcButton = page.getByRole("button", { name: /sign in with/i });
const hasOidc = await oidcButton.isVisible().catch(() => false); await expect(oidcButton).toBeVisible();
if (hasOidc) { // Click and immediately check for loading state
// Click the button // The button text changes to "Signing in..." so we need a different locator
await oidcButton.click(); await oidcButton.click();
// Button should show "Signing in..." state // Find the button that shows loading state (text changed)
await expect(oidcButton) const loadingButton = page.getByRole("button", { name: /signing in/i });
.toContainText(/signing in/i, { timeout: 2000 }) await expect(loadingButton).toBeVisible();
.catch(() => { await expect(loadingButton).toBeDisabled();
// May redirect too fast to catch loading state - that's acceptable
});
} else {
test.skip();
}
}); });
test("OIDC button is disabled when rate limited", async ({ page }) => { test("OIDC button is disabled when rate limited", async ({ page }) => {
@@ -279,15 +291,9 @@ test.describe("authentication", () => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const oidcButton = page.getByRole("button", { name: /sign in with/i }); const oidcButton = page.getByRole("button", { name: /sign in with/i });
const hasOidc = await oidcButton.isVisible().catch(() => false);
if (hasOidc) {
// Initial state should not be disabled // Initial state should not be disabled
const isDisabledBefore = await oidcButton.isDisabled(); await expect(oidcButton).not.toBeDisabled();
expect(isDisabledBefore).toBe(false);
} else {
test.skip();
}
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -129,12 +129,13 @@ test.describe("dashboard", () => {
// Click the toggle // Click the toggle
await toggleCheckbox.click(); await toggleCheckbox.click();
// Wait a moment for the API call // Wait for the checkbox state to change using retry-based assertion
await page.waitForTimeout(500); // The API call completes and React re-renders asynchronously
if (initialChecked) {
// Toggle should change state await expect(toggleCheckbox).not.toBeChecked({ timeout: 5000 });
const afterChecked = await toggleCheckbox.isChecked(); } else {
expect(afterChecked).not.toBe(initialChecked); await expect(toggleCheckbox).toBeChecked({ timeout: 5000 });
}
} else { } else {
test.skip(); test.skip();
} }
@@ -244,59 +245,39 @@ test.describe("dashboard", () => {
}); });
test("displays cycle day in 'Day X' format", async ({ page }) => { test("displays cycle day in 'Day X' format", async ({ page }) => {
// Wait for dashboard to finish loading
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for "Day" followed by a number // Wait for either cycle day or onboarding - both are valid states
const cycleDayText = page.getByText(/Day \d+/i); // Use Playwright's expect with retry for reliable detection
const hasCycleDay = await cycleDayText const cycleDayText = page.getByText(/Day \d+/i).first();
.first() const onboarding = page.getByText(/set.*period|log.*period/i).first();
.isVisible()
.catch(() => false);
// Either has cycle day or onboarding (both valid states) try {
if (!hasCycleDay) { // First try waiting for cycle day with a short timeout
const onboarding = page.getByText(/set.*period|log.*period/i); await expect(cycleDayText).toBeVisible({ timeout: 5000 });
const hasOnboarding = await onboarding } catch {
.first() // If no cycle day, expect onboarding banner
.isVisible() await expect(onboarding).toBeVisible({ timeout: 5000 });
.catch(() => false);
expect(hasCycleDay || hasOnboarding).toBe(true);
} }
}); });
test("displays current phase name", async ({ page }) => { test("displays current phase name", async ({ page }) => {
// Wait for dashboard to finish loading
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for phase names // Wait for either a phase name or onboarding - both are valid states
const phaseNames = [ const phaseRegex =
"MENSTRUAL", /MENSTRUAL|FOLLICULAR|OVULATION|EARLY_LUTEAL|LATE_LUTEAL/i;
"FOLLICULAR", const phaseText = page.getByText(phaseRegex).first();
"OVULATION", const onboarding = page.getByText(/set.*period|log.*period/i).first();
"EARLY_LUTEAL",
"LATE_LUTEAL",
];
let foundPhase = false;
for (const phase of phaseNames) { try {
const phaseText = page.getByText(new RegExp(phase, "i")); // First try waiting for a phase name with a short timeout
const isVisible = await phaseText await expect(phaseText).toBeVisible({ timeout: 5000 });
.first() } catch {
.isVisible() // If no phase, expect onboarding banner
.catch(() => false); await expect(onboarding).toBeVisible({ timeout: 5000 });
if (isVisible) {
foundPhase = true;
break;
}
}
// Either has phase or shows onboarding
if (!foundPhase) {
const onboarding = page.getByText(/set.*period|log.*period/i);
const hasOnboarding = await onboarding
.first()
.isVisible()
.catch(() => false);
expect(foundPhase || hasOnboarding).toBe(true);
} }
}); });
@@ -381,81 +362,62 @@ test.describe("dashboard", () => {
}); });
test("displays seed cycling recommendation", async ({ page }) => { test("displays seed cycling recommendation", async ({ page }) => {
// Wait for dashboard to finish loading
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for seed names (flax, pumpkin, sesame, sunflower) // Wait for either seed info or onboarding - both are valid states
const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i); const seedText = page.getByText(/flax|pumpkin|sesame|sunflower/i).first();
const hasSeeds = await seedText const onboarding = page.getByText(/set.*period|log.*period/i).first();
.first()
.isVisible()
.catch(() => false);
// Either has seeds info or onboarding try {
if (!hasSeeds) { await expect(seedText).toBeVisible({ timeout: 5000 });
const onboarding = page.getByText(/set.*period|log.*period/i); } catch {
const hasOnboarding = await onboarding await expect(onboarding).toBeVisible({ timeout: 5000 });
.first()
.isVisible()
.catch(() => false);
expect(hasSeeds || hasOnboarding).toBe(true);
} }
}); });
test("displays carbohydrate range", async ({ page }) => { test("displays carbohydrate range", async ({ page }) => {
// Wait for dashboard to finish loading
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for carb-related text // Wait for either carb info or onboarding - both are valid states
const carbText = page.getByText(/carb|carbohydrate/i); const carbText = page.getByText(/carb|carbohydrate/i).first();
const hasCarbs = await carbText const onboarding = page.getByText(/set.*period|log.*period/i).first();
.first()
.isVisible()
.catch(() => false);
if (!hasCarbs) { try {
const onboarding = page.getByText(/set.*period|log.*period/i); await expect(carbText).toBeVisible({ timeout: 5000 });
const hasOnboarding = await onboarding } catch {
.first() await expect(onboarding).toBeVisible({ timeout: 5000 });
.isVisible()
.catch(() => false);
expect(hasCarbs || hasOnboarding).toBe(true);
} }
}); });
test("displays keto guidance", async ({ page }) => { test("displays keto guidance", async ({ page }) => {
// Wait for dashboard to finish loading
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for keto-related text // Wait for either keto info or onboarding - both are valid states
const ketoText = page.getByText(/keto/i); const ketoText = page.getByText(/keto/i).first();
const hasKeto = await ketoText const onboarding = page.getByText(/set.*period|log.*period/i).first();
.first()
.isVisible()
.catch(() => false);
if (!hasKeto) { try {
const onboarding = page.getByText(/set.*period|log.*period/i); await expect(ketoText).toBeVisible({ timeout: 5000 });
const hasOnboarding = await onboarding } catch {
.first() await expect(onboarding).toBeVisible({ timeout: 5000 });
.isVisible()
.catch(() => false);
expect(hasKeto || hasOnboarding).toBe(true);
} }
}); });
test("displays nutrition section header", async ({ page }) => { test("displays nutrition section header", async ({ page }) => {
// Wait for dashboard to finish loading
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Nutrition panel should have a header // Wait for nutrition header or text
const nutritionHeader = page.getByRole("heading", { name: /nutrition/i }); const nutritionHeader = page.getByRole("heading", { name: /nutrition/i });
const hasHeader = await nutritionHeader.isVisible().catch(() => false); const nutritionText = page.getByText(/nutrition/i).first();
if (!hasHeader) { try {
// May be text label instead of heading await expect(nutritionHeader).toBeVisible({ timeout: 5000 });
const nutritionText = page.getByText(/nutrition/i); } catch {
const hasText = await nutritionText await expect(nutritionText).toBeVisible({ timeout: 5000 });
.first()
.isVisible()
.catch(() => false);
expect(hasHeader || hasText).toBe(true);
} }
}); });
}); });

View File

@@ -355,16 +355,23 @@ test.describe("decision engine", () => {
// Toggle flare on (if not already) // Toggle flare on (if not already)
if (!flareWasChecked) { if (!flareWasChecked) {
await flareCheckbox.click(); // Wait for both API calls when clicking the checkbox
await page.waitForTimeout(500); await Promise.all([
page.waitForResponse("**/api/overrides"),
page.waitForResponse("**/api/today"),
flareCheckbox.click(),
]);
// Should now be REST // Should now be REST (flare mode forces rest)
const restDecision = await decisionCard.textContent(); const restDecision = await decisionCard.textContent();
expect(restDecision).toContain("REST"); expect(restDecision).toContain("REST");
// Toggle flare off // Toggle flare off and wait for API calls
await flareCheckbox.click(); await Promise.all([
await page.waitForTimeout(500); page.waitForResponse("**/api/overrides"),
page.waitForResponse("**/api/today"),
flareCheckbox.click(),
]);
// Should return to original (or close to it) // Should return to original (or close to it)
const restoredDecision = await decisionCard.textContent(); const restoredDecision = await decisionCard.textContent();

86
e2e/fixtures.ts Normal file
View 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";

View File

@@ -40,6 +40,19 @@ test.describe("garmin connection", () => {
await page.waitForURL("/", { timeout: 10000 }); await page.waitForURL("/", { timeout: 10000 });
await page.goto("/settings/garmin"); await page.goto("/settings/garmin");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Clean up: Disconnect if already connected to ensure clean state
const disconnectButton = page.getByRole("button", {
name: /disconnect/i,
});
const isConnected = await disconnectButton.isVisible().catch(() => false);
if (isConnected) {
await disconnectButton.click();
await page.waitForTimeout(1000);
// Wait for disconnect to complete
await page.waitForLoadState("networkidle");
}
}); });
test("shows not connected initially for new user", async ({ page }) => { test("shows not connected initially for new user", async ({ page }) => {

View File

@@ -2,7 +2,7 @@
// ABOUTME: Runs before all e2e tests to provide a fresh database with test data. // ABOUTME: Runs before all e2e tests to provide a fresh database with test data.
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; 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"); 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 // Set environment variables for the test process
process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url; process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url;
process.env.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(`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}`);
}
} }

View File

@@ -77,9 +77,7 @@ test.describe("mobile viewport", () => {
await expect(settingsLink).toBeVisible(); await expect(settingsLink).toBeVisible();
// Decision card should be visible // Decision card should be visible
const decisionCard = page const decisionCard = page.locator('[data-testid="decision-card"]');
.locator('[data-testid="decision-card"]')
.or(page.getByText(/rest|gentle|light|reduced|train/i).first());
await expect(decisionCard).toBeVisible(); await expect(decisionCard).toBeVisible();
// Data panel should be visible // Data panel should be visible
@@ -140,15 +138,18 @@ test.describe("mobile viewport", () => {
const viewportSize = page.viewportSize(); const viewportSize = page.viewportSize();
expect(viewportSize?.width).toBe(375); expect(viewportSize?.width).toBe(375);
// Calendar heading should be visible // Calendar page title heading should be visible (exact match to avoid "Calendar Subscription")
const heading = page.getByRole("heading", { name: /calendar/i }); const heading = page.getByRole("heading", {
await expect(heading).toBeVisible(); name: "Calendar",
exact: true,
});
await expect(heading).toBeVisible({ timeout: 10000 });
// Calendar grid should be visible // Calendar grid should be visible
const calendarGrid = page const calendarGrid = page
.getByRole("grid") .getByRole("grid")
.or(page.locator('[data-testid="month-view"]')); .or(page.locator('[data-testid="month-view"]'));
await expect(calendarGrid).toBeVisible(); await expect(calendarGrid).toBeVisible({ timeout: 5000 });
// Month navigation should be visible // Month navigation should be visible
const monthYear = page.getByText( const monthYear = page.getByText(

View File

@@ -1,85 +1,73 @@
// ABOUTME: E2E tests for period logging functionality. // ABOUTME: E2E tests for period logging functionality.
// ABOUTME: Tests period start logging, date selection, and period history. // ABOUTME: Tests period start logging, date selection, and period history.
import { expect, test } from "@playwright/test";
test.describe("period logging", () => { import { test as baseTest } from "@playwright/test";
test.describe("unauthenticated", () => { import { expect, test } from "./fixtures";
test("period history page redirects to login when not authenticated", async ({
page, baseTest.describe("period logging", () => {
}) => { baseTest.describe("unauthenticated", () => {
baseTest(
"period history page redirects to login when not authenticated",
async ({ page }) => {
await page.goto("/period-history"); await page.goto("/period-history");
// Should redirect to /login // Should redirect to /login
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
}); },
);
}); });
test.describe("authenticated", () => { baseTest.describe("API endpoints", () => {
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars baseTest("period history API requires authentication", async ({ page }) => {
test.beforeEach(async ({ page }) => { const response = await page.request.get("/api/period-history");
const email = process.env.TEST_USER_EMAIL;
const password = process.env.TEST_USER_PASSWORD;
if (!email || !password) { // Should return 401 Unauthorized
test.skip(); expect(response.status()).toBe(401);
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 });
}); });
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 ({ test("dashboard shows period date prompt for new users", async ({
page, onboardingPage,
}) => { }) => {
// Check if onboarding banner for period date is visible await onboardingPage.goto("/");
// This depends on whether the test user has period data set
const onboardingBanner = page.getByText( // Onboarding user has no period data, should see onboarding banner
const onboardingBanner = onboardingPage.getByText(
/period|log your period|set.*date/i, /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(); await expect(onboardingBanner.first()).toBeVisible();
}
}); });
test("period history page is accessible", async ({ page }) => { test("period history page is accessible", async ({ establishedPage }) => {
await page.goto("/period-history"); await establishedPage.goto("/period-history");
// Should show period history content // 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 }) => { test("period history shows table or empty state", async ({
await page.goto("/period-history"); establishedPage,
}) => {
await establishedPage.goto("/period-history");
// Wait for loading to complete // Wait for loading to complete
await page.waitForLoadState("networkidle"); await establishedPage.waitForLoadState("networkidle");
// Look for either table or empty state message // Look for either table or empty state message
const table = page.getByRole("table"); const table = establishedPage.getByRole("table");
const emptyState = page.getByText("No period history found"); const emptyState = establishedPage.getByText("No period history found");
const totalText = page.getByText(/\d+ periods/); const totalText = establishedPage.getByText(/\d+ periods/);
const hasTable = await table.isVisible().catch(() => false); const hasTable = await table.isVisible().catch(() => false);
const hasEmpty = await emptyState.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 ({ 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 // 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 const hasAvg = await avgText
.first() .first()
.isVisible() .isVisible()
@@ -107,235 +97,83 @@ test.describe("period logging", () => {
} }
}); });
test("period history shows back navigation", async ({ page }) => { test("period history shows back navigation", async ({ establishedPage }) => {
await page.goto("/period-history"); await establishedPage.goto("/period-history");
// Look for back link // 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(); 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 // Look for navigation to period history
const periodHistoryLink = page.getByRole("link", { const periodHistoryLink = establishedPage.getByRole("link", {
name: /period.*history|history/i, name: /period.*history|history/i,
}); });
const hasLink = await periodHistoryLink.isVisible().catch(() => false); const hasLink = await periodHistoryLink.isVisible().catch(() => false);
if (hasLink) { if (hasLink) {
await periodHistoryLink.click(); await periodHistoryLink.click();
await expect(page).toHaveURL(/\/period-history/); await expect(establishedPage).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();
} }
}); });
});
test.describe("period logging flow - onboarding user", () => {
test("period date modal opens from dashboard onboarding banner", async ({ test("period date modal opens from dashboard onboarding banner", async ({
page, onboardingPage,
}) => { }) => {
// Look for the "Set date" button in onboarding banner await onboardingPage.goto("/");
const setDateButton = page.getByRole("button", { name: /set date/i });
const hasSetDate = await setDateButton.isVisible().catch(() => false);
if (!hasSetDate) { // Onboarding user should see "Set date" button
// User may already have period date set - skip if no onboarding banner const setDateButton = onboardingPage.getByRole("button", {
test.skip(); name: /set date/i,
return; });
} await expect(setDateButton).toBeVisible();
// Click the set date button // Click the set date button
await setDateButton.click(); await setDateButton.click();
// Modal should open with "Set Period Date" title // Modal should open with "Set Period Date" title
const modalTitle = page.getByRole("heading", { const modalTitle = onboardingPage.getByRole("heading", {
name: /set period date/i, name: /set period date/i,
}); });
await expect(modalTitle).toBeVisible(); await expect(modalTitle).toBeVisible();
// Should have a date input // Should have a date input
const dateInput = page.locator('input[type="date"]'); const dateInput = onboardingPage.locator('input[type="date"]');
await expect(dateInput).toBeVisible(); await expect(dateInput).toBeVisible();
// Should have Cancel and Save buttons // Should have Cancel and Save buttons
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible(); await expect(
await expect(page.getByRole("button", { name: /save/i })).toBeVisible(); onboardingPage.getByRole("button", { name: /cancel/i }),
).toBeVisible();
await expect(
onboardingPage.getByRole("button", { name: /save/i }),
).toBeVisible();
// Cancel should close the modal // 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(); await expect(modalTitle).not.toBeVisible();
}); });
test("period date input restricts future dates", async ({ page }) => { test("period date input restricts future dates", async ({
// Look for the "Set date" button in onboarding banner onboardingPage,
const setDateButton = page.getByRole("button", { name: /set date/i }); }) => {
const hasSetDate = await setDateButton.isVisible().catch(() => false); await onboardingPage.goto("/");
if (!hasSetDate) {
test.skip();
return;
}
// Open the modal // Open the modal
const setDateButton = onboardingPage.getByRole("button", {
name: /set date/i,
});
await setDateButton.click(); await setDateButton.click();
// Get the date input and check its max attribute // 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(); await expect(dateInput).toBeVisible();
// The max attribute should be set to today's date (YYYY-MM-DD format) // 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()); expect(maxDate.getTime()).toBeLessThanOrEqual(today.getTime());
// Close modal // 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 ({ test("logging period from modal updates dashboard cycle info", async ({
page, onboardingPage,
}) => { }) => {
// Look for the "Set date" button in onboarding banner await onboardingPage.goto("/");
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;
}
// Click the set date button // Click the set date button
const setDateButton = onboardingPage.getByRole("button", {
name: /set date/i,
});
await setDateButton.click(); await setDateButton.click();
// Wait for modal to be visible // Wait for modal to be visible
const modalTitle = page.getByRole("heading", { const modalTitle = onboardingPage.getByRole("heading", {
name: /set period date/i, name: /set period date/i,
}); });
await expect(modalTitle).toBeVisible(); await expect(modalTitle).toBeVisible();
@@ -382,40 +215,150 @@ test.describe("period logging", () => {
const dateStr = testDate.toISOString().split("T")[0]; const dateStr = testDate.toISOString().split("T")[0];
// Fill in the date // Fill in the date
const dateInput = page.locator('input[type="date"]'); const dateInput = onboardingPage.locator('input[type="date"]');
await dateInput.fill(dateStr); await dateInput.fill(dateStr);
// Click Save // Click Save button
await page.getByRole("button", { name: /save/i }).click(); await onboardingPage.getByRole("button", { name: /save/i }).click();
// Modal should close // Modal should close after successful save
await expect(modalTitle).not.toBeVisible(); await expect(modalTitle).not.toBeVisible({ timeout: 10000 });
// Dashboard should now show cycle information (Day X · Phase) // Wait for network activity to settle
await page.waitForLoadState("networkidle"); await onboardingPage.waitForLoadState("networkidle");
// Look for cycle day display (e.g., "Day 8 · Follicular" or similar) // Look for cycle day display (e.g., "Day 8 · Follicular" or similar)
const cycleInfo = page.getByText(/day\s+\d+\s+·/i); // The page fetches /api/cycle/period, then /api/today and /api/user
await expect(cycleInfo).toBeVisible({ timeout: 10000 }); // 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 ({ test("edit period modal flow changes date successfully", async ({
page, establishedPage,
}) => { }) => {
await page.goto("/period-history"); await establishedPage.goto("/period-history");
await page.waitForLoadState("networkidle"); await establishedPage.waitForLoadState("networkidle");
// Look for edit button and table to ensure we have data // Look for edit button and table to ensure we have data
const editButton = page.getByRole("button", { name: /edit/i }).first(); const editButton = establishedPage
const hasEdit = await editButton.isVisible().catch(() => false); .getByRole("button", { name: /edit/i })
.first();
if (!hasEdit) { await expect(editButton).toBeVisible();
test.skip();
return;
}
// Get the original date from the first row // 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 originalDateCell = firstRow.locator("td").first();
const originalDateText = await originalDateCell.textContent(); const originalDateText = await originalDateCell.textContent();
@@ -423,34 +366,34 @@ test.describe("period logging", () => {
await editButton.click(); await editButton.click();
// Edit modal should appear // Edit modal should appear
const editModalTitle = page.getByRole("heading", { const editModalTitle = establishedPage.getByRole("heading", {
name: /edit period date/i, name: /edit period date/i,
}); });
await expect(editModalTitle).toBeVisible(); await expect(editModalTitle).toBeVisible();
// Get the date input in the edit modal // Get the date input in the edit modal
const editDateInput = page.locator("#editDate"); const editDateInput = establishedPage.locator("#editDate");
await expect(editDateInput).toBeVisible(); 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(); const newDate = new Date();
newDate.setDate(newDate.getDate() - 14); newDate.setDate(newDate.getDate() - 21);
const newDateStr = newDate.toISOString().split("T")[0]; const newDateStr = newDate.toISOString().split("T")[0];
// Clear and fill new date // Clear and fill new date
await editDateInput.fill(newDateStr); await editDateInput.fill(newDateStr);
// Click Save in the edit modal // 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 // Modal should close
await expect(editModalTitle).not.toBeVisible(); await expect(editModalTitle).not.toBeVisible();
// Wait for table to refresh // Wait for table to refresh
await page.waitForLoadState("networkidle"); await establishedPage.waitForLoadState("networkidle");
// Verify the date changed (the row should have new date text) // Verify the date changed (the row should have new date text)
const updatedDateCell = page const updatedDateCell = establishedPage
.locator("tbody tr") .locator("tbody tr")
.first() .first()
.locator("td") .locator("td")
@@ -471,23 +414,20 @@ test.describe("period logging", () => {
} }
}); });
test("delete period confirmation flow removes entry", async ({ page }) => { test("delete period confirmation flow removes entry", async ({
await page.goto("/period-history"); establishedPage,
await page.waitForLoadState("networkidle"); }) => {
await establishedPage.goto("/period-history");
await establishedPage.waitForLoadState("networkidle");
// Look for delete button // Look for delete button
const deleteButton = page const deleteButton = establishedPage
.getByRole("button", { name: /delete/i }) .getByRole("button", { name: /delete/i })
.first(); .first();
const hasDelete = await deleteButton.isVisible().catch(() => false); await expect(deleteButton).toBeVisible();
if (!hasDelete) {
test.skip();
return;
}
// Get the total count text before deletion // 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); const hasTotal = await totalText.isVisible().catch(() => false);
let originalCount = 0; let originalCount = 0;
if (hasTotal) { if (hasTotal) {
@@ -503,36 +443,36 @@ test.describe("period logging", () => {
await deleteButton.click(); await deleteButton.click();
// Confirmation modal should appear // Confirmation modal should appear
const confirmModalTitle = page.getByRole("heading", { const confirmModalTitle = establishedPage.getByRole("heading", {
name: /delete period/i, name: /delete period/i,
}); });
await expect(confirmModalTitle).toBeVisible(); await expect(confirmModalTitle).toBeVisible();
// Should show warning message // 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(); await expect(warningText).toBeVisible();
// Should have Cancel and Confirm buttons // Should have Cancel and Confirm buttons
await expect(page.getByRole("button", { name: /cancel/i })).toBeVisible();
await expect( await expect(
page.getByRole("button", { name: /confirm/i }), establishedPage.getByRole("button", { name: /cancel/i }),
).toBeVisible();
await expect(
establishedPage.getByRole("button", { name: /confirm/i }),
).toBeVisible(); ).toBeVisible();
// Click Confirm to delete // Click Confirm to delete
await page.getByRole("button", { name: /confirm/i }).click(); await establishedPage.getByRole("button", { name: /confirm/i }).click();
// Modal should close // Modal should close
await expect(confirmModalTitle).not.toBeVisible(); await expect(confirmModalTitle).not.toBeVisible();
// Wait for page to refresh // Wait for page to refresh
await page.waitForLoadState("networkidle"); await establishedPage.waitForLoadState("networkidle");
// If we had a count, verify it decreased // If we had a count, verify it decreased
if (originalCount > 1) { if (originalCount > 1) {
const newTotalText = page.getByText(/\d+ periods/); const newTotalText = establishedPage.getByText(/\d+ periods/);
const newTotalVisible = await newTotalText const newTotalVisible = await newTotalText.isVisible().catch(() => false);
.isVisible()
.catch(() => false);
if (newTotalVisible) { if (newTotalVisible) {
const newCountMatch = (await newTotalText.textContent())?.match( const newCountMatch = (await newTotalText.textContent())?.match(
/(\d+) periods/, /(\d+) periods/,
@@ -544,5 +484,4 @@ test.describe("period logging", () => {
} }
} }
}); });
});
}); });

View File

@@ -53,31 +53,32 @@ test.describe("plan page", () => {
test("shows current cycle status section", async ({ page }) => { test("shows current cycle status section", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// Look for Current Status section // Wait for page to finish loading - look for Current Status or error state
const statusSection = page.getByRole("heading", { const statusSection = page.getByRole("heading", {
name: "Current Status", name: "Current Status",
}); });
const hasStatus = await statusSection.isVisible().catch(() => false); // Use text content to find error alert (avoid Next.js route announcer)
const errorAlert = page.getByText(/error:/i);
if (hasStatus) { try {
await expect(statusSection).toBeVisible(); // Wait for Current Status section to be visible (data loaded successfully)
await expect(statusSection).toBeVisible({ timeout: 10000 });
// Should show day number // Should show day number
await expect(page.getByText(/day \d+/i)).toBeVisible(); await expect(page.getByText(/day \d+/i)).toBeVisible({ timeout: 5000 });
// Should show training type // Should show training type
await expect(page.getByText(/training type:/i)).toBeVisible(); await expect(page.getByText(/training type:/i)).toBeVisible({
timeout: 5000,
});
// Should show weekly limit // Should show weekly limit
await expect(page.getByText(/weekly limit:/i)).toBeVisible(); await expect(page.getByText(/weekly limit:/i)).toBeVisible({
} else { timeout: 5000,
// If no status, should see loading or error state });
const loading = page.getByText(/loading/i); } catch {
const error = page.getByRole("alert"); // If status section not visible, check for error alert
const hasLoading = await loading.isVisible().catch(() => false); await expect(errorAlert).toBeVisible({ timeout: 5000 });
const hasError = await error.isVisible().catch(() => false);
expect(hasLoading || hasError).toBe(true);
} }
}); });

View File

@@ -1,6 +1,8 @@
// ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase. // ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase.
// ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests. // ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests.
import { type ChildProcess, execSync, spawn } from "node:child_process"; import { type ChildProcess, execSync, spawn } from "node:child_process";
import { createCipheriv, randomBytes } from "node:crypto";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as os from "node:os"; import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
@@ -11,6 +13,45 @@ import {
getMissingCollections, getMissingCollections,
} from "../scripts/setup-db"; } 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. * 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, dataDir: string,
email: string, email: string,
password: string, password: string,
): void { maxRetries = 5,
): Promise<void> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
execSync( execSync(
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`, `pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
{ {
stdio: "pipe", 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. * Sets up API rules for collections to allow user access.
*/ */
async function setupApiRules(pb: PocketBase): Promise<void> { async function setupApiRules(pb: PocketBase): Promise<void> {
// Allow users to update their own user record
// 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 // Allow users to read/write their own period_logs
const periodLogs = await pb.collections.getOne("period_logs"); const periodLogs = await pb.collections.getOne("period_logs");
await pb.collections.update(periodLogs.id, { await pb.collections.update(periodLogs.id, {
@@ -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( async function retryAsync<T>(
pb: PocketBase, operation: () => Promise<T>,
email: string, maxRetries = 5,
password: string, baseDelayMs = 100,
): Promise<string> { ): Promise<T> {
// Calculate date 14 days ago for mid-cycle test data 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(); const fourteenDaysAgo = new Date();
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0]; const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
// Create the test user const user = await retryAsync(() =>
const user = await pb.collection("users").create({ pb.collection("users").create({
email, email,
password, password,
passwordConfirm: password, passwordConfirm: password,
@@ -202,18 +355,180 @@ async function createTestUser(
verified: true, verified: true,
lastPeriodDate, lastPeriodDate,
cycleLength: 28, cycleLength: 28,
notificationTime: "07:00",
timezone: "UTC", timezone: "UTC",
}); }),
);
// Create a period log entry await retryAsync(() =>
await pb.collection("period_logs").create({ pb.collection("period_logs").create({
user: user.id, user: user.id,
startDate: lastPeriodDate, startDate: lastPeriodDate,
}); }),
);
return user.id; 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. * Starts a fresh PocketBase instance for e2e testing.
*/ */
@@ -247,8 +562,8 @@ export async function start(
// Wait for PocketBase to be ready // Wait for PocketBase to be ready
await waitForReady(url); await waitForReady(url);
// Create admin user via CLI // Create admin user via CLI (with retry for database lock during migrations)
createAdminUser(dataDir, config.adminEmail, config.adminPassword); await createAdminUser(dataDir, config.adminEmail, config.adminPassword);
// Connect to PocketBase as admin // Connect to PocketBase as admin
const pb = new PocketBase(url); const pb = new PocketBase(url);
@@ -260,8 +575,8 @@ export async function start(
// Set up collections // Set up collections
await setupCollections(pb); await setupCollections(pb);
// Create test user with period data // Create all test users for different e2e scenarios
await createTestUser(pb, config.testUserEmail, config.testUserPassword); await createAllTestUsers(pb);
currentState = { currentState = {
process: pbProcess, process: pbProcess,

View File

@@ -435,10 +435,12 @@ test.describe("settings", () => {
const newValue = originalValue === "08:00" ? "09:00" : "08:00"; const newValue = originalValue === "08:00" ? "09:00" : "08:00";
await notificationTimeInput.fill(newValue); await notificationTimeInput.fill(newValue);
// Save // Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i }); const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(1500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page // Reload the page
await page.reload(); await page.reload();
@@ -453,7 +455,9 @@ test.describe("settings", () => {
// Restore original value // Restore original value
await notificationTimeAfter.fill(originalValue); await notificationTimeAfter.fill(originalValue);
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
}); });
test("timezone changes persist after page reload", async ({ page }) => { test("timezone changes persist after page reload", async ({ page }) => {
@@ -475,10 +479,12 @@ test.describe("settings", () => {
: "America/New_York"; : "America/New_York";
await timezoneInput.fill(newValue); await timezoneInput.fill(newValue);
// Save // Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i }); const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(1500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page // Reload the page
await page.reload(); await page.reload();
@@ -493,7 +499,9 @@ test.describe("settings", () => {
// Restore original value // Restore original value
await timezoneAfter.fill(originalValue); await timezoneAfter.fill(originalValue);
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
}); });
test("multiple settings changes persist after page reload", async ({ test("multiple settings changes persist after page reload", async ({
@@ -536,10 +544,12 @@ test.describe("settings", () => {
await notificationTimeInput.fill(newNotificationTime); await notificationTimeInput.fill(newNotificationTime);
await timezoneInput.fill(newTimezone); await timezoneInput.fill(newTimezone);
// Save all changes at once // Save all changes at once and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i }); const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(1500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page // Reload the page
await page.reload(); await page.reload();
@@ -561,7 +571,9 @@ test.describe("settings", () => {
await notificationTimeAfter.fill(originalNotificationTime); await notificationTimeAfter.fill(originalNotificationTime);
await timezoneAfter.fill(originalTimezone); await timezoneAfter.fill(originalTimezone);
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
}); });
test("cycle length persistence verifies exact saved value", async ({ test("cycle length persistence verifies exact saved value", async ({
@@ -582,10 +594,12 @@ test.describe("settings", () => {
const newValue = originalValue === "28" ? "31" : "28"; const newValue = originalValue === "28" ? "31" : "28";
await cycleLengthInput.fill(newValue); await cycleLengthInput.fill(newValue);
// Save // Save and wait for success toast
const saveButton = page.getByRole("button", { name: /save/i }); const saveButton = page.getByRole("button", { name: /save/i });
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(1500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
// Reload the page // Reload the page
await page.reload(); await page.reload();
@@ -600,7 +614,9 @@ test.describe("settings", () => {
// Restore original value // Restore original value
await cycleLengthAfter.fill(originalValue); await cycleLengthAfter.fill(originalValue);
await saveButton.click(); await saveButton.click();
await page.waitForTimeout(500); await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
timeout: 10000,
});
}); });
test("settings form shows correct values after save without reload", async ({ test("settings form shows correct values after save without reload", async ({

View File

@@ -23,6 +23,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"next": "16.1.1", "next": "16.1.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"oauth-1.0a": "^2.2.6",
"pino": "^10.1.1", "pino": "^10.1.1",
"pocketbase": "^0.26.5", "pocketbase": "^0.26.5",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",

View File

@@ -22,8 +22,9 @@ export default defineConfig({
// Retry failed tests on CI only // Retry failed tests on CI only
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
// Limit parallel workers on CI to avoid resource issues // Run tests sequentially since all tests share the same test user
workers: process.env.CI ? 1 : undefined, // Parallel execution causes race conditions when tests modify user state
workers: 1,
// Reporter configuration // Reporter configuration
reporter: [["html", { open: "never" }], ["list"]], reporter: [["html", { open: "never" }], ["list"]],
@@ -44,11 +45,12 @@ export default defineConfig({
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Run dev server before starting tests // 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: { webServer: {
command: "pnpm dev", command: "pnpm dev",
url: "http://localhost:3000", url: "http://localhost:3000",
reuseExistingServer: !process.env.CI, reuseExistingServer: false,
timeout: 120 * 1000, // 2 minutes for Next.js to start timeout: 120 * 1000, // 2 minutes for Next.js to start
env: { env: {
// Use the test PocketBase instance (port 8091) // Use the test PocketBase instance (port 8091)

8
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
node-cron: node-cron:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
oauth-1.0a:
specifier: ^2.2.6
version: 2.2.6
pino: pino:
specifier: ^10.1.1 specifier: ^10.1.1
version: 10.1.1 version: 10.1.1
@@ -1773,6 +1776,9 @@ packages:
node-releases@2.0.27: node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
oauth-1.0a@2.2.6:
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
obug@2.1.1: obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -3445,6 +3451,8 @@ snapshots:
node-releases@2.0.27: {} node-releases@2.0.27: {}
oauth-1.0a@2.2.6: {}
obug@2.1.1: {} obug@2.1.1: {}
on-exit-leak-free@2.1.2: {} on-exit-leak-free@2.1.2: {}

View File

@@ -45,13 +45,16 @@ oauth1_adapter = TypeAdapter(OAuth1Token)
oauth2_adapter = TypeAdapter(OAuth2Token) oauth2_adapter = TypeAdapter(OAuth2Token)
expires_at_ts = garth.client.oauth2_token.expires_at expires_at_ts = garth.client.oauth2_token.expires_at
refresh_expires_at_ts = garth.client.oauth2_token.refresh_token_expires_at
tokens = { tokens = {
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'), "oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
"oauth2": oauth2_adapter.dump_python(garth.client.oauth2_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("\n--- Copy everything below this line ---")
print(json.dumps(tokens, indent=2)) print(json.dumps(tokens, indent=2))
print("--- Copy everything above this line ---") 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)")

View File

@@ -7,6 +7,7 @@ import {
getExistingCollectionNames, getExistingCollectionNames,
getMissingCollections, getMissingCollections,
PERIOD_LOGS_COLLECTION, PERIOD_LOGS_COLLECTION,
USER_CUSTOM_FIELDS,
} from "./setup-db"; } from "./setup-db";
describe("PERIOD_LOGS_COLLECTION", () => { describe("PERIOD_LOGS_COLLECTION", () => {
@@ -162,3 +163,21 @@ describe("getMissingCollections", () => {
expect(missing).toHaveLength(0); 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);
});
});

View File

@@ -6,10 +6,12 @@ import PocketBase from "pocketbase";
* Collection field definition for PocketBase. * Collection field definition for PocketBase.
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties. * For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
*/ */
interface CollectionField { export interface CollectionField {
name: string; name: string;
type: string; type: string;
required?: boolean; required?: boolean;
// Text field max length (PocketBase defaults to 5000 if not specified)
max?: number;
// Relation field properties (top-level, not in options) // Relation field properties (top-level, not in options)
collectionId?: string; collectionId?: string;
maxSelect?: number; maxSelect?: number;
@@ -142,11 +144,12 @@ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
* Custom fields to add to the users collection. * Custom fields to add to the users collection.
* These are required for Garmin integration and app functionality. * These are required for Garmin integration and app functionality.
*/ */
const USER_CUSTOM_FIELDS = [ export const USER_CUSTOM_FIELDS: CollectionField[] = [
{ name: "garminConnected", type: "bool" }, { name: "garminConnected", type: "bool" },
{ name: "garminOauth1Token", type: "text" }, { name: "garminOauth1Token", type: "text", max: 20000 },
{ name: "garminOauth2Token", type: "text" }, { name: "garminOauth2Token", type: "text", max: 20000 },
{ name: "garminTokenExpiresAt", type: "date" }, { name: "garminTokenExpiresAt", type: "date" },
{ name: "garminRefreshTokenExpiresAt", type: "date" },
{ name: "calendarToken", type: "text" }, { name: "calendarToken", type: "text" },
{ name: "lastPeriodDate", type: "date" }, { name: "lastPeriodDate", type: "date" },
{ name: "cycleLength", type: "number" }, { 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. * This is idempotent - safe to run multiple times.
*/ */
export async function addUserFields(pb: PocketBase): Promise<void> { export async function addUserFields(pb: PocketBase): Promise<void> {
const usersCollection = await pb.collections.getOne("users"); const usersCollection = await pb.collections.getOne("users");
// Get existing field names // Build a map of existing fields by name
const existingFieldNames = new Set( const existingFieldsMap = new Map<string, Record<string, unknown>>(
(usersCollection.fields || []).map((f: { name: string }) => f.name), (usersCollection.fields || []).map((f: Record<string, unknown>) => [
f.name as string,
f,
]),
); );
// Filter to only new fields // Separate new fields from fields that need updating
const newFields = USER_CUSTOM_FIELDS.filter( const newFields: CollectionField[] = [];
(f) => !existingFieldNames.has(f.name), const fieldsToUpdate: string[] = [];
);
if (newFields.length > 0) { for (const definedField of USER_CUSTOM_FIELDS) {
// Combine existing fields with new ones 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]; const allFields = [...(usersCollection.fields || []), ...newFields];
await pb.collections.update(usersCollection.id, { await pb.collections.update(usersCollection.id, {
fields: allFields, fields: allFields,
}); });
if (newFields.length > 0) {
console.log( console.log(
` Added ${newFields.length} field(s) to users:`, ` Added ${newFields.length} field(s) to users:`,
newFields.map((f) => f.name), newFields.map((f) => f.name),
); );
}
if (fieldsToUpdate.length > 0) {
console.log(
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
fieldsToUpdate,
);
}
} else { } else {
console.log(" All user fields already exist."); console.log(" All user fields already exist with correct settings.");
} }
} }

View File

@@ -79,6 +79,7 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "valid-calendar-token-abc123def", calendarToken: "valid-calendar-token-abc123def",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -14,11 +14,13 @@ interface RouteParams {
} }
export async function GET(_request: NextRequest, { params }: 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 { try {
// Fetch user from database // Fetch user from database
const pb = createPocketBaseClient();
const user = await pb.collection("users").getOne(userId); const user = await pb.collection("users").getOne(userId);
// Check if user has a calendar token set // Check if user has a calendar token set

View File

@@ -41,6 +41,7 @@ describe("POST /api/calendar/regenerate-token", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "old-calendar-token-abc123", calendarToken: "old-calendar-token-abc123",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -1,6 +1,6 @@
// ABOUTME: Unit tests for Garmin sync cron endpoint. // ABOUTME: Unit tests for Garmin sync cron endpoint.
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users. // 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"; import type { User } from "@/types";
@@ -8,6 +8,8 @@ import type { User } from "@/types";
let mockUsers: User[] = []; let mockUsers: User[] = [];
// Track DailyLog creations // Track DailyLog creations
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" }); const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
// Track user updates
const mockPbUpdate = vi.fn().mockResolvedValue({});
// Mock PocketBase // Mock PocketBase
vi.mock("@/lib/pocketbase", () => ({ vi.mock("@/lib/pocketbase", () => ({
@@ -20,6 +22,7 @@ vi.mock("@/lib/pocketbase", () => ({
return []; return [];
}), }),
create: mockPbCreate, create: mockPbCreate,
update: mockPbUpdate,
})), })),
})), })),
})); }));
@@ -28,7 +31,14 @@ vi.mock("@/lib/pocketbase", () => ({
const mockDecrypt = vi.fn((ciphertext: string) => { const mockDecrypt = vi.fn((ciphertext: string) => {
// Return mock OAuth2 token JSON // Return mock OAuth2 token JSON
if (ciphertext.includes("oauth2")) { 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:", ""); return ciphertext.replace("encrypted:", "");
}); });
@@ -57,10 +67,15 @@ vi.mock("@/lib/garmin", () => ({
// Mock email sending // Mock email sending
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined); const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({ vi.mock("@/lib/email", () => ({
sendTokenExpirationWarning: (...args: unknown[]) => sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args), mockSendTokenExpirationWarning(...args),
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
sendPeriodConfirmationEmail: (...args: unknown[]) =>
mockSendPeriodConfirmationEmail(...args),
})); }));
// Mock logger (required for route to run without side effects) // Mock logger (required for route to run without side effects)
@@ -87,6 +102,7 @@ describe("POST /api/cron/garmin-sync", () => {
garminOauth1Token: "encrypted:oauth1-token", garminOauth1Token: "encrypted:oauth1-token",
garminOauth2Token: "encrypted:oauth2-token", garminOauth2Token: "encrypted:oauth2-token",
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token", calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"), lastPeriodDate: new Date("2025-01-01"),
cycleLength: 28, cycleLength: 28,
@@ -112,8 +128,10 @@ describe("POST /api/cron/garmin-sync", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.resetModules();
mockUsers = []; mockUsers = [];
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
process.env.CRON_SECRET = validSecret; process.env.CRON_SECRET = validSecret;
}); });
@@ -188,9 +206,12 @@ describe("POST /api/cron/garmin-sync", () => {
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token"); expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
}); });
it("skips users with expired tokens", async () => { it("skips users with expired refresh tokens", async () => {
mockIsTokenExpired.mockReturnValue(true); // Set refresh token to expired (in the past)
mockUsers = [createMockUser()]; const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`)); const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -415,9 +436,28 @@ describe("POST /api/cron/garmin-sync", () => {
}); });
describe("Token expiration warnings", () => { describe("Token expiration warnings", () => {
it("sends warning email when token expires in exactly 14 days", async () => { // Use fake timers to ensure consistent date calculations
mockUsers = [createMockUser({ email: "user@example.com" })]; beforeEach(() => {
mockDaysUntilExpiry.mockReturnValue(14); 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}`)); const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -430,9 +470,13 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.warningsSent).toBe(1); expect(body.warningsSent).toBe(1);
}); });
it("sends warning email when token expires in exactly 7 days", async () => { it("sends warning email when refresh token expires in exactly 7 days", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })]; mockUsers = [
mockDaysUntilExpiry.mockReturnValue(7); createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(7),
}),
];
const response = await POST(createMockRequest(`Bearer ${validSecret}`)); const response = await POST(createMockRequest(`Bearer ${validSecret}`));
@@ -445,36 +489,40 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.warningsSent).toBe(1); expect(body.warningsSent).toBe(1);
}); });
it("does not send warning when token expires in 30 days", async () => { it("does not send warning when refresh token expires in 30 days", async () => {
mockUsers = [createMockUser()]; mockUsers = [
mockDaysUntilExpiry.mockReturnValue(30); createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`)); await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
}); });
it("does not send warning when token expires in 15 days", async () => { it("does not send warning when refresh token expires in 15 days", async () => {
mockUsers = [createMockUser()]; mockUsers = [
mockDaysUntilExpiry.mockReturnValue(15); createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`)); await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
}); });
it("does not send warning when token expires in 8 days", async () => { it("does not send warning when refresh token expires in 8 days", async () => {
mockUsers = [createMockUser()]; mockUsers = [
mockDaysUntilExpiry.mockReturnValue(8); createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`)); await POST(createMockRequest(`Bearer ${validSecret}`));
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled(); expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
}); });
it("does not send warning when token expires in 6 days", async () => { it("does not send warning when refresh token expires in 6 days", async () => {
mockUsers = [createMockUser()]; mockUsers = [
mockDaysUntilExpiry.mockReturnValue(6); createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
];
await POST(createMockRequest(`Bearer ${validSecret}`)); 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 () => { it("sends warnings for multiple users on different thresholds", async () => {
mockUsers = [ mockUsers = [
createMockUser({ id: "user1", email: "user1@example.com" }), createMockUser({
createMockUser({ id: "user2", email: "user2@example.com" }), 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}`)); 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 () => { it("continues processing sync even if warning email fails", async () => {
mockUsers = [createMockUser({ email: "user@example.com" })]; mockUsers = [
mockDaysUntilExpiry.mockReturnValue(14); createMockUser({
email: "user@example.com",
garminRefreshTokenExpiresAt: daysFromNow(14),
}),
];
mockSendTokenExpirationWarning.mockRejectedValueOnce( mockSendTokenExpirationWarning.mockRejectedValueOnce(
new Error("Email failed"), new Error("Email failed"),
); );
@@ -520,10 +578,12 @@ describe("POST /api/cron/garmin-sync", () => {
expect(body.usersProcessed).toBe(1); expect(body.usersProcessed).toBe(1);
}); });
it("does not send warning for expired tokens", async () => { it("does not send warning for expired refresh tokens", async () => {
mockUsers = [createMockUser()]; // Expired refresh tokens are skipped entirely (not synced), so no warning
mockIsTokenExpired.mockReturnValue(true); const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
mockDaysUntilExpiry.mockReturnValue(-1); mockUsers = [
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
];
await POST(createMockRequest(`Bearer ${validSecret}`)); await POST(createMockRequest(`Bearer ${validSecret}`));

View File

@@ -5,14 +5,17 @@ import { NextResponse } from "next/server";
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle"; import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
import { getDecisionWithOverrides } from "@/lib/decision-engine"; import { getDecisionWithOverrides } from "@/lib/decision-engine";
import { sendTokenExpirationWarning } from "@/lib/email"; import { sendTokenExpirationWarning } from "@/lib/email";
import { decrypt } from "@/lib/encryption"; import { decrypt, encrypt } from "@/lib/encryption";
import { import {
daysUntilExpiry,
fetchBodyBattery, fetchBodyBattery,
fetchHrvStatus, fetchHrvStatus,
fetchIntensityMinutes, fetchIntensityMinutes,
isTokenExpired,
} from "@/lib/garmin"; } from "@/lib/garmin";
import {
exchangeOAuth1ForOAuth2,
isAccessTokenExpired,
type OAuth1TokenData,
} from "@/lib/garmin-auth";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { import {
activeUsersGauge, activeUsersGauge,
@@ -20,13 +23,14 @@ import {
garminSyncTotal, garminSyncTotal,
} from "@/lib/metrics"; } from "@/lib/metrics";
import { createPocketBaseClient } from "@/lib/pocketbase"; import { createPocketBaseClient } from "@/lib/pocketbase";
import type { GarminTokens, User } from "@/types"; import type { User } from "@/types";
interface SyncResult { interface SyncResult {
success: boolean; success: boolean;
usersProcessed: number; usersProcessed: number;
errors: number; errors: number;
skippedExpired: number; skippedExpired: number;
tokensRefreshed: number;
warningsSent: number; warningsSent: number;
timestamp: string; timestamp: string;
} }
@@ -47,6 +51,7 @@ export async function POST(request: Request) {
usersProcessed: 0, usersProcessed: 0,
errors: 0, errors: 0,
skippedExpired: 0, skippedExpired: 0,
tokensRefreshed: 0,
warningsSent: 0, warningsSent: 0,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
@@ -66,38 +71,81 @@ export async function POST(request: Request) {
const userSyncStartTime = Date.now(); const userSyncStartTime = Date.now();
try { 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 // Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
const tokens: GarminTokens = { if (user.garminRefreshTokenExpiresAt) {
oauth1: user.garminOauth1Token, const refreshTokenExpired =
oauth2: user.garminOauth2Token, new Date(user.garminRefreshTokenExpiresAt) <= new Date();
// biome-ignore lint/style/noNonNullAssertion: filtered above if (refreshTokenExpired) {
expires_at: user.garminTokenExpiresAt!.toISOString(), logger.info(
}; { userId: user.id },
"Refresh token expired, skipping user",
if (isTokenExpired(tokens)) { );
result.skippedExpired++; result.skippedExpired++;
continue; continue;
} }
}
// Log sync start // Log sync start
logger.info({ userId: user.id }, "Garmin sync start"); logger.info({ userId: user.id }, "Garmin sync start");
// Check for token expiration warnings (exactly 14 or 7 days) // Check for refresh token expiration warnings (exactly 14 or 7 days)
const daysRemaining = daysUntilExpiry(tokens); 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) { if (daysRemaining === 14 || daysRemaining === 7) {
try { try {
await sendTokenExpirationWarning(user.email, daysRemaining, user.id); await sendTokenExpirationWarning(
user.email,
daysRemaining,
user.id,
);
result.warningsSent++; result.warningsSent++;
} catch { } catch {
// Continue processing even if warning email fails // 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 oauth2Json = decrypt(user.garminOauth2Token);
const oauth2Data = JSON.parse(oauth2Json); let oauth2Data = JSON.parse(oauth2Json);
const accessToken = oauth2Data.accessToken;
// 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 // Fetch Garmin data
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([ const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([

View File

@@ -29,9 +29,15 @@ vi.mock("@/lib/pocketbase", () => ({
// Mock email sending // Mock email sending
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined); const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/email", () => ({ vi.mock("@/lib/email", () => ({
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data), sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
sendTokenExpirationWarning: (...args: unknown[]) =>
mockSendTokenExpirationWarning(...args),
sendPeriodConfirmationEmail: (...args: unknown[]) =>
mockSendPeriodConfirmationEmail(...args),
})); }));
import { POST } from "./route"; import { POST } from "./route";
@@ -48,6 +54,7 @@ describe("POST /api/cron/notifications", () => {
garminOauth1Token: "encrypted:oauth1-token", garminOauth1Token: "encrypted:oauth1-token",
garminOauth2Token: "encrypted:oauth2-token", garminOauth2Token: "encrypted:oauth2-token",
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token", calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"), lastPeriodDate: new Date("2025-01-01"),
cycleLength: 28, cycleLength: 28,

View File

@@ -9,9 +9,29 @@ import type { User } from "@/types";
// Module-level variable to control mock user in tests // Module-level variable to control mock user in tests
let currentMockUser: User | null = null; let currentMockUser: User | null = null;
// Create mock PocketBase getOne function that returns fresh user data
const mockPbGetOne = vi.fn().mockImplementation(() => {
if (!currentMockUser) {
throw new Error("User not found");
}
return Promise.resolve({
id: currentMockUser.id,
email: currentMockUser.email,
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
cycleLength: currentMockUser.cycleLength,
});
});
// Create mock PocketBase client
const mockPb = {
collection: vi.fn(() => ({
getOne: mockPbGetOne,
})),
};
// Mock PocketBase client for database operations // Mock PocketBase client for database operations
vi.mock("@/lib/pocketbase", () => ({ vi.mock("@/lib/pocketbase", () => ({
createPocketBaseClient: vi.fn(() => ({})), createPocketBaseClient: vi.fn(() => mockPb),
loadAuthFromCookies: vi.fn(), loadAuthFromCookies: vi.fn(),
isAuthenticated: vi.fn(() => currentMockUser !== null), isAuthenticated: vi.fn(() => currentMockUser !== null),
getCurrentUser: vi.fn(() => currentMockUser), getCurrentUser: vi.fn(() => currentMockUser),
@@ -24,7 +44,7 @@ vi.mock("@/lib/auth-middleware", () => ({
if (!currentMockUser) { if (!currentMockUser) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
return handler(request, currentMockUser); return handler(request, currentMockUser, mockPb);
}; };
}), }),
})); }));
@@ -39,6 +59,7 @@ describe("GET /api/cycle/current", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-01"), lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31, cycleLength: 31,

View File

@@ -40,9 +40,18 @@ function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
return nextPhaseStart - cycleDay; return nextPhaseStart - cycleDay;
} }
export const GET = withAuth(async (_request, user) => { export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale
const freshUser = await pb.collection("users").getOne(user.id);
// Validate user has required cycle data // Validate user has required cycle data
if (!user.lastPeriodDate) { const lastPeriodDate = freshUser.lastPeriodDate
? new Date(freshUser.lastPeriodDate as string)
: null;
const cycleLength = (freshUser.cycleLength as number) || 28;
if (!lastPeriodDate) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
@@ -53,20 +62,16 @@ export const GET = withAuth(async (_request, user) => {
} }
// Calculate current cycle position // Calculate current cycle position
const cycleDay = getCycleDay( const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
user.lastPeriodDate, const phase = getPhase(cycleDay, cycleLength);
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay, user.cycleLength);
const phaseConfig = getPhaseConfig(phase); const phaseConfig = getPhaseConfig(phase);
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength); const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, cycleLength);
return NextResponse.json({ return NextResponse.json({
cycleDay, cycleDay,
phase, phase,
phaseConfig, phaseConfig,
daysUntilNextPhase, daysUntilNextPhase,
cycleLength: user.cycleLength, cycleLength,
}); });
}); });

View File

@@ -43,6 +43,7 @@ describe("POST /api/cycle/period", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2024-12-15"), lastPeriodDate: new Date("2024-12-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -71,6 +71,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"), garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -100,6 +101,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate, garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -129,6 +131,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate, garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -156,6 +159,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"), garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -185,6 +189,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: pastDate, garminTokenExpiresAt: pastDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -216,6 +221,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate, garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -245,6 +251,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate, garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -274,6 +281,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate, garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -303,6 +311,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "encrypted-token", garminOauth1Token: "encrypted-token",
garminOauth2Token: "encrypted-token", garminOauth2Token: "encrypted-token",
garminTokenExpiresAt: futureDate, garminTokenExpiresAt: futureDate,
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -329,6 +338,7 @@ describe("GET /api/garmin/status", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"), garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -12,26 +12,41 @@ export const GET = withAuth(async (_request, user, pb) => {
const connected = freshUser.garminConnected === true; const connected = freshUser.garminConnected === true;
if (!connected) { if (!connected) {
return NextResponse.json({ return NextResponse.json(
{
connected: false, connected: false,
daysUntilExpiry: null, daysUntilExpiry: null,
expired: false, expired: false,
warningLevel: null, 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) ? String(freshUser.garminTokenExpiresAt)
: ""; : "";
const tokens = { const tokens = {
oauth1: "", oauth1: "",
oauth2: "", oauth2: "",
expires_at: expiresAt, expires_at: accessTokenExpiresAt,
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
}; };
const days = daysUntilExpiry(tokens); 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; let warningLevel: "warning" | "critical" | null = null;
if (days <= 7) { if (days <= 7) {
@@ -40,10 +55,15 @@ export const GET = withAuth(async (_request, user, pb) => {
warningLevel = "warning"; warningLevel = "warning";
} }
return NextResponse.json({ return NextResponse.json(
{
connected: true, connected: true,
daysUntilExpiry: days, daysUntilExpiry: days,
expired, expired,
warningLevel, warningLevel,
}); },
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
}); });

View File

@@ -53,6 +53,7 @@ describe("POST /api/garmin/tokens", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-01-01"), garminTokenExpiresAt: new Date("2025-01-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -141,6 +142,7 @@ describe("POST /api/garmin/tokens", () => {
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`, garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`, garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
garminTokenExpiresAt: expiresAt, garminTokenExpiresAt: expiresAt,
garminRefreshTokenExpiresAt: expect.any(String),
garminConnected: true, garminConnected: true,
}); });
}); });
@@ -267,6 +269,7 @@ describe("DELETE /api/garmin/tokens", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -304,6 +307,7 @@ describe("DELETE /api/garmin/tokens", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: null, garminTokenExpiresAt: null,
garminRefreshTokenExpiresAt: null,
garminConnected: false, garminConnected: false,
}); });
}); });

View File

@@ -9,7 +9,7 @@ import { logger } from "@/lib/logger";
export const POST = withAuth(async (request, user, pb) => { export const POST = withAuth(async (request, user, pb) => {
const body = await request.json(); const body = await request.json();
const { oauth1, oauth2, expires_at } = body; const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
// Validate required fields // Validate required fields
if (!oauth1) { 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 // Encrypt tokens before storing
const encryptedOauth1 = encrypt(JSON.stringify(oauth1)); const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
const encryptedOauth2 = encrypt(JSON.stringify(oauth2)); const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
@@ -61,6 +78,7 @@ export const POST = withAuth(async (request, user, pb) => {
garminOauth1Token: encryptedOauth1, garminOauth1Token: encryptedOauth1,
garminOauth2Token: encryptedOauth2, garminOauth2Token: encryptedOauth2,
garminTokenExpiresAt: expires_at, garminTokenExpiresAt: expires_at,
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
garminConnected: true, 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({ const expiryDays = daysUntilExpiry({
oauth1: "", oauth1: "",
oauth2: "", oauth2: "",
expires_at, expires_at,
refresh_token_expires_at: refreshTokenExpiresAt,
}); });
return NextResponse.json({ return NextResponse.json({
@@ -103,6 +122,7 @@ export const DELETE = withAuth(async (_request, user, pb) => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: null, garminTokenExpiresAt: null,
garminRefreshTokenExpiresAt: null,
garminConnected: false, garminConnected: false,
}); });

View File

@@ -41,6 +41,7 @@ describe("GET /api/history", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -55,6 +55,7 @@ describe("POST /api/overrides", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -187,6 +188,7 @@ describe("DELETE /api/overrides", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -41,6 +41,7 @@ describe("GET /api/period-history", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -50,6 +50,7 @@ describe("PATCH /api/period-logs/[id]", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -276,6 +277,7 @@ describe("DELETE /api/period-logs/[id]", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,

View File

@@ -14,7 +14,22 @@ let currentMockDailyLog: DailyLog | null = null;
// Create mock PocketBase client // Create mock PocketBase client
const mockPb = { const mockPb = {
collection: vi.fn(() => ({ collection: vi.fn((collectionName: string) => ({
// Mock getOne for fetching fresh user data
getOne: vi.fn(async () => {
if (collectionName === "users" && currentMockUser) {
// Return user data in PocketBase record format
return {
id: currentMockUser.id,
email: currentMockUser.email,
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
cycleLength: currentMockUser.cycleLength,
activeOverrides: currentMockUser.activeOverrides,
garminConnected: currentMockUser.garminConnected,
};
}
throw new Error("Record not found");
}),
getFirstListItem: vi.fn(async () => { getFirstListItem: vi.fn(async () => {
if (!currentMockDailyLog) { if (!currentMockDailyLog) {
const error = new Error("No DailyLog found"); const error = new Error("No DailyLog found");
@@ -48,6 +63,7 @@ describe("GET /api/today", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-01"), lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31, cycleLength: 31,

View File

@@ -28,8 +28,13 @@ const DEFAULT_BIOMETRICS: {
}; };
export const GET = withAuth(async (_request, user, pb) => { export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale
// (e.g., after logging a period, the cookie still has old data)
const freshUser = await pb.collection("users").getOne(user.id);
// Validate required user data // Validate required user data
if (!user.lastPeriodDate) { if (!freshUser.lastPeriodDate) {
return NextResponse.json( return NextResponse.json(
{ {
error: error:
@@ -38,14 +43,13 @@ export const GET = withAuth(async (_request, user, pb) => {
{ status: 400 }, { status: 400 },
); );
} }
const lastPeriodDate = new Date(freshUser.lastPeriodDate as string);
const cycleLength = freshUser.cycleLength as number;
const activeOverrides = (freshUser.activeOverrides as string[]) || [];
// Calculate cycle information // Calculate cycle information
const cycleDay = getCycleDay( const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
new Date(user.lastPeriodDate), const phase = getPhase(cycleDay, cycleLength);
user.cycleLength,
new Date(),
);
const phase = getPhase(cycleDay, user.cycleLength);
const phaseConfig = getPhaseConfig(phase); const phaseConfig = getPhaseConfig(phase);
const phaseLimit = getPhaseLimit(phase); const phaseLimit = getPhaseLimit(phase);
@@ -54,16 +58,16 @@ export const GET = withAuth(async (_request, user, pb) => {
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl // EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
let daysUntilNextPhase: number; let daysUntilNextPhase: number;
if (phase === "LATE_LUTEAL") { if (phase === "LATE_LUTEAL") {
daysUntilNextPhase = user.cycleLength - cycleDay + 1; daysUntilNextPhase = cycleLength - cycleDay + 1;
} else if (phase === "MENSTRUAL") { } else if (phase === "MENSTRUAL") {
daysUntilNextPhase = 4 - cycleDay; daysUntilNextPhase = 4 - cycleDay;
} else if (phase === "FOLLICULAR") { } else if (phase === "FOLLICULAR") {
daysUntilNextPhase = user.cycleLength - 15 - cycleDay; daysUntilNextPhase = cycleLength - 15 - cycleDay;
} else if (phase === "OVULATION") { } else if (phase === "OVULATION") {
daysUntilNextPhase = user.cycleLength - 13 - cycleDay; daysUntilNextPhase = cycleLength - 13 - cycleDay;
} else { } else {
// EARLY_LUTEAL // EARLY_LUTEAL
daysUntilNextPhase = user.cycleLength - 6 - cycleDay; daysUntilNextPhase = cycleLength - 6 - cycleDay;
} }
// Try to fetch today's DailyLog for biometrics // Try to fetch today's DailyLog for biometrics
@@ -99,7 +103,10 @@ export const GET = withAuth(async (_request, user, pb) => {
}; };
// Get training decision with override handling // Get training decision with override handling
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides); const decision = getDecisionWithOverrides(
dailyData,
activeOverrides as import("@/types").OverrideType[],
);
// Log decision calculation per observability spec // Log decision calculation per observability spec
logger.info( logger.info(
@@ -120,7 +127,7 @@ export const GET = withAuth(async (_request, user, pb) => {
phase, phase,
phaseConfig, phaseConfig,
daysUntilNextPhase, daysUntilNextPhase,
cycleLength: user.cycleLength, cycleLength,
biometrics, biometrics,
nutrition, nutrition,
}); });

View File

@@ -12,10 +12,29 @@ let currentMockUser: User | null = null;
// Track PocketBase update calls // Track PocketBase update calls
const mockPbUpdate = vi.fn().mockResolvedValue({}); const mockPbUpdate = vi.fn().mockResolvedValue({});
// Track PocketBase getOne calls - returns the current mock user data
const mockPbGetOne = vi.fn().mockImplementation(() => {
if (!currentMockUser) {
throw new Error("User not found");
}
return Promise.resolve({
id: currentMockUser.id,
email: currentMockUser.email,
garminConnected: currentMockUser.garminConnected,
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
cycleLength: currentMockUser.cycleLength,
notificationTime: currentMockUser.notificationTime,
timezone: currentMockUser.timezone,
activeOverrides: currentMockUser.activeOverrides,
calendarToken: currentMockUser.calendarToken,
});
});
// Create mock PocketBase client // Create mock PocketBase client
const mockPb = { const mockPb = {
collection: vi.fn(() => ({ collection: vi.fn(() => ({
update: mockPbUpdate, update: mockPbUpdate,
getOne: mockPbGetOne,
})), })),
}; };
@@ -41,6 +60,7 @@ describe("GET /api/user", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -55,6 +75,7 @@ describe("GET /api/user", () => {
vi.clearAllMocks(); vi.clearAllMocks();
currentMockUser = null; currentMockUser = null;
mockPbUpdate.mockClear(); mockPbUpdate.mockClear();
mockPbGetOne.mockClear();
}); });
it("returns user profile when authenticated", async () => { it("returns user profile when authenticated", async () => {
@@ -76,17 +97,27 @@ describe("GET /api/user", () => {
expect(body.timezone).toBe("America/New_York"); 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; currentMockUser = mockUser;
const mockRequest = {} as NextRequest; const mockRequest = {} as NextRequest;
const response = await GET(mockRequest); const response = await GET(mockRequest);
const body = await response.json(); const body = await response.json();
// Should NOT include encrypted tokens // Should NOT include encrypted Garmin tokens
expect(body.garminOauth1Token).toBeUndefined(); expect(body.garminOauth1Token).toBeUndefined();
expect(body.garminOauth2Token).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 () => { it("includes activeOverrides array", async () => {
@@ -119,6 +150,7 @@ describe("PATCH /api/user", () => {
garminOauth1Token: "encrypted-token-1", garminOauth1Token: "encrypted-token-1",
garminOauth2Token: "encrypted-token-2", garminOauth2Token: "encrypted-token-2",
garminTokenExpiresAt: new Date("2025-06-01"), garminTokenExpiresAt: new Date("2025-06-01"),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-secret-token", calendarToken: "cal-secret-token",
lastPeriodDate: new Date("2025-01-15"), lastPeriodDate: new Date("2025-01-15"),
cycleLength: 28, cycleLength: 28,
@@ -133,6 +165,7 @@ describe("PATCH /api/user", () => {
vi.clearAllMocks(); vi.clearAllMocks();
currentMockUser = null; currentMockUser = null;
mockPbUpdate.mockClear(); mockPbUpdate.mockClear();
mockPbGetOne.mockClear();
}); });
// Helper to create mock request with JSON body // Helper to create mock request with JSON body
@@ -370,9 +403,8 @@ describe("PATCH /api/user", () => {
expect(body.cycleLength).toBe(32); expect(body.cycleLength).toBe(32);
expect(body.notificationTime).toBe("07:30"); expect(body.notificationTime).toBe("07:30");
expect(body.timezone).toBe("America/New_York"); 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.garminOauth1Token).toBeUndefined();
expect(body.garminOauth2Token).toBeUndefined(); expect(body.garminOauth2Token).toBeUndefined();
expect(body.calendarToken).toBeUndefined();
}); });
}); });

View File

@@ -13,24 +13,35 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
/** /**
* GET /api/user * GET /api/user
* Returns the authenticated user's profile. * Returns the authenticated user's profile.
* Fetches fresh data from database to ensure updates are reflected.
* Excludes sensitive fields like encrypted tokens. * Excludes sensitive fields like encrypted tokens.
*/ */
export const GET = withAuth(async (_request, user, _pb) => { export const GET = withAuth(async (_request, user, pb) => {
// Fetch fresh user data from database to get latest values
// The user param from withAuth is from auth store cache which may be stale
const freshUser = await pb.collection("users").getOne(user.id);
// Format date for consistent API response // Format date for consistent API response
const lastPeriodDate = user.lastPeriodDate const lastPeriodDate = freshUser.lastPeriodDate
? user.lastPeriodDate.toISOString().split("T")[0] ? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
: null; : null;
return NextResponse.json({ return NextResponse.json(
id: user.id, {
email: user.email, id: freshUser.id,
garminConnected: user.garminConnected, email: freshUser.email,
cycleLength: user.cycleLength, garminConnected: freshUser.garminConnected ?? false,
cycleLength: freshUser.cycleLength,
lastPeriodDate, lastPeriodDate,
notificationTime: user.notificationTime, notificationTime: freshUser.notificationTime,
timezone: user.timezone, timezone: freshUser.timezone,
activeOverrides: user.activeOverrides, activeOverrides: freshUser.activeOverrides ?? [],
}); calendarToken: (freshUser.calendarToken as string) || null,
},
{
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
},
);
}); });
/** /**

View File

@@ -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) { if (loading) {
return ( return (
@@ -242,7 +247,11 @@ export default function GarminSettingsPage() {
{/* Token Input Section */} {/* Token Input Section */}
{showTokenInput && ( {showTokenInput && (
<div className="border border-input rounded-lg p-6"> <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="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"> <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">

View File

@@ -40,7 +40,10 @@ export function DecisionCard({ decision }: DecisionCardProps) {
const colors = getStatusColors(decision.status); const colors = getStatusColors(decision.status);
return ( return (
<div className={`rounded-lg p-6 ${colors.background}`}> <div
data-testid="decision-card"
className={`rounded-lg p-6 ${colors.background}`}
>
<div className="text-4xl mb-2">{decision.icon}</div> <div className="text-4xl mb-2">{decision.icon}</div>
<h2 className="text-2xl font-bold">{decision.status}</h2> <h2 className="text-2xl font-bold">{decision.status}</h2>
<p className={colors.text}>{decision.reason}</p> <p className={colors.text}>{decision.reason}</p>

View File

@@ -58,6 +58,7 @@ describe("withAuth", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: new Date(), garminTokenExpiresAt: new Date(),
garminRefreshTokenExpiresAt: null,
calendarToken: "cal-token", calendarToken: "cal-token",
lastPeriodDate: new Date("2025-01-01"), lastPeriodDate: new Date("2025-01-01"),
cycleLength: 31, cycleLength: 31,

146
src/lib/garmin-auth.test.ts Normal file
View 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
View 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;
}

View File

@@ -110,6 +110,38 @@ describe("daysUntilExpiry", () => {
expect(days).toBeGreaterThanOrEqual(6); expect(days).toBeGreaterThanOrEqual(6);
expect(days).toBeLessThanOrEqual(7); 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", () => { describe("fetchGarminData", () => {

View File

@@ -36,8 +36,15 @@ export function isTokenExpired(tokens: GarminTokens): boolean {
return expiresAt <= new Date(); 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 { 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 now = new Date();
const diffMs = expiresAt.getTime() - now.getTime(); const diffMs = expiresAt.getTime() - now.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24)); return Math.floor(diffMs / (1000 * 60 * 60 * 24));

View File

@@ -68,6 +68,7 @@ describe("getCurrentUser", () => {
garminOauth1Token: "encrypted1", garminOauth1Token: "encrypted1",
garminOauth2Token: "encrypted2", garminOauth2Token: "encrypted2",
garminTokenExpiresAt: "2025-06-01T00:00:00Z", garminTokenExpiresAt: "2025-06-01T00:00:00Z",
garminRefreshTokenExpiresAt: "2025-07-01T00:00:00Z",
calendarToken: "cal-token-123", calendarToken: "cal-token-123",
lastPeriodDate: "2025-01-01", lastPeriodDate: "2025-01-01",
cycleLength: 28, cycleLength: 28,
@@ -105,6 +106,7 @@ describe("getCurrentUser", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: "", garminTokenExpiresAt: "",
garminRefreshTokenExpiresAt: "",
calendarToken: "token", calendarToken: "token",
lastPeriodDate: "2025-01-15", lastPeriodDate: "2025-01-15",
cycleLength: 31, cycleLength: 31,
@@ -139,6 +141,7 @@ describe("getCurrentUser", () => {
garminOauth1Token: "", garminOauth1Token: "",
garminOauth2Token: "", garminOauth2Token: "",
garminTokenExpiresAt: "not-a-date", garminTokenExpiresAt: "not-a-date",
garminRefreshTokenExpiresAt: "also-not-a-date",
calendarToken: "token", calendarToken: "token",
lastPeriodDate: "", lastPeriodDate: "",
cycleLength: 28, cycleLength: 28,

View File

@@ -96,6 +96,7 @@ function mapRecordToUser(record: RecordModel): User {
garminOauth1Token: record.garminOauth1Token as string, garminOauth1Token: record.garminOauth1Token as string,
garminOauth2Token: record.garminOauth2Token as string, garminOauth2Token: record.garminOauth2Token as string,
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt), garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
calendarToken: record.calendarToken as string, calendarToken: record.calendarToken as string,
lastPeriodDate: parseDate(record.lastPeriodDate), lastPeriodDate: parseDate(record.lastPeriodDate),
cycleLength: record.cycleLength as number, cycleLength: record.cycleLength as number,

View File

@@ -22,7 +22,8 @@ export interface User {
garminConnected: boolean; garminConnected: boolean;
garminOauth1Token: string; // encrypted JSON garminOauth1Token: string; // encrypted JSON
garminOauth2Token: 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 // Calendar
calendarToken: string; // random secret for ICS URL calendarToken: string; // random secret for ICS URL
@@ -87,6 +88,7 @@ export interface GarminTokens {
oauth1: string; oauth1: string;
oauth2: string; oauth2: string;
expires_at: string; expires_at: string;
refresh_token_expires_at?: string;
} }
export interface PhaseConfig { export interface PhaseConfig {