Compare commits
5 Commits
7dd08ab5ce
...
4a874476c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a874476c3 | |||
| ff3d8fad2c | |||
| b221acee40 | |||
| 6df145d916 | |||
| 00b84d0b22 |
@@ -147,6 +147,11 @@ These are optional enhancements to improve E2E coverage. Not required for featur
|
|||||||
|
|
||||||
## Revision History
|
## 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)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
86
e2e/fixtures.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// ABOUTME: Playwright test fixtures for different user states.
|
||||||
|
// ABOUTME: Provides pre-authenticated pages for onboarding, established, calendar, and garmin users.
|
||||||
|
import { test as base, type Page } from "@playwright/test";
|
||||||
|
import { TEST_USERS, type TestUserPreset } from "./pocketbase-harness";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in a user via the email/password form.
|
||||||
|
* Throws if the email form is not visible (OIDC-only mode).
|
||||||
|
*/
|
||||||
|
async function loginUser(
|
||||||
|
page: Page,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!hasEmailForm) {
|
||||||
|
throw new Error(
|
||||||
|
"Email/password form not visible - app may be in OIDC-only mode",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailInput.fill(email);
|
||||||
|
await page.getByLabel(/password/i).fill(password);
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Wait for successful redirect to dashboard
|
||||||
|
await page.waitForURL("/", { timeout: 15000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a fixture for a specific user preset.
|
||||||
|
*/
|
||||||
|
function createUserFixture(preset: TestUserPreset) {
|
||||||
|
return async (
|
||||||
|
{ page }: { page: Page },
|
||||||
|
use: (page: Page) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
const user = TEST_USERS[preset];
|
||||||
|
await loginUser(page, user.email, user.password);
|
||||||
|
await use(page);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended test fixtures providing pre-authenticated pages for each user type.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { test, expect } from './fixtures';
|
||||||
|
*
|
||||||
|
* test('onboarding user sees set date button', async ({ onboardingPage }) => {
|
||||||
|
* await onboardingPage.goto('/');
|
||||||
|
* // User has no period data, will see onboarding UI
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* test('established user sees dashboard', async ({ establishedPage }) => {
|
||||||
|
* await establishedPage.goto('/');
|
||||||
|
* // User has period data from 14 days ago
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
type TestFixtures = {
|
||||||
|
/** User with no period data - sees onboarding UI */
|
||||||
|
onboardingPage: Page;
|
||||||
|
/** User with period data (14 days ago) - sees normal dashboard */
|
||||||
|
establishedPage: Page;
|
||||||
|
/** User with period data and calendar token - can copy/regenerate URL */
|
||||||
|
calendarPage: Page;
|
||||||
|
/** User with valid Garmin tokens (90 days until expiry) */
|
||||||
|
garminPage: Page;
|
||||||
|
/** User with expired Garmin tokens */
|
||||||
|
garminExpiredPage: Page;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = base.extend<TestFixtures>({
|
||||||
|
onboardingPage: createUserFixture("onboarding"),
|
||||||
|
establishedPage: createUserFixture("established"),
|
||||||
|
calendarPage: createUserFixture("calendar"),
|
||||||
|
garminPage: createUserFixture("garmin"),
|
||||||
|
garminExpiredPage: createUserFixture("garminExpired"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from "@playwright/test";
|
||||||
@@ -40,6 +40,19 @@ test.describe("garmin connection", () => {
|
|||||||
await page.waitForURL("/", { timeout: 10000 });
|
await page.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 }) => {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`));
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
146
src/lib/garmin-auth.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// ABOUTME: Unit tests for Garmin OAuth1 to OAuth2 token exchange functionality.
|
||||||
|
// ABOUTME: Tests access token expiry checks and token exchange logic.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { exchangeOAuth1ForOAuth2, isAccessTokenExpired } from "./garmin-auth";
|
||||||
|
|
||||||
|
describe("isAccessTokenExpired", () => {
|
||||||
|
it("returns false when token expires in the future", () => {
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setHours(futureDate.getHours() + 2);
|
||||||
|
expect(isAccessTokenExpired(futureDate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when token has expired", () => {
|
||||||
|
const pastDate = new Date();
|
||||||
|
pastDate.setHours(pastDate.getHours() - 1);
|
||||||
|
expect(isAccessTokenExpired(pastDate)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when token expires within 5 minute buffer", () => {
|
||||||
|
const nearFutureDate = new Date();
|
||||||
|
nearFutureDate.setMinutes(nearFutureDate.getMinutes() + 3);
|
||||||
|
expect(isAccessTokenExpired(nearFutureDate)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when token expires beyond 5 minute buffer", () => {
|
||||||
|
const safeDate = new Date();
|
||||||
|
safeDate.setMinutes(safeDate.getMinutes() + 10);
|
||||||
|
expect(isAccessTokenExpired(safeDate)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles ISO string dates", () => {
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setHours(futureDate.getHours() + 2);
|
||||||
|
expect(isAccessTokenExpired(futureDate.toISOString())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("exchangeOAuth1ForOAuth2", () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls Garmin exchange endpoint with OAuth1 authorization", async () => {
|
||||||
|
const mockOAuth2Response = {
|
||||||
|
scope: "test-scope",
|
||||||
|
jti: "test-jti",
|
||||||
|
access_token: "new-access-token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
refresh_token: "new-refresh-token",
|
||||||
|
expires_in: 3600,
|
||||||
|
refresh_token_expires_in: 2592000,
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockOAuth2Response),
|
||||||
|
});
|
||||||
|
|
||||||
|
const oauth1Token = {
|
||||||
|
oauth_token: "test-oauth1-token",
|
||||||
|
oauth_token_secret: "test-oauth1-secret",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await exchangeOAuth1ForOAuth2(oauth1Token);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: expect.stringContaining("OAuth"),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.oauth2).toEqual(mockOAuth2Response);
|
||||||
|
expect(result.expires_at).toBeDefined();
|
||||||
|
expect(result.refresh_token_expires_at).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when exchange fails", async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
text: () => Promise.resolve("Unauthorized"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const oauth1Token = {
|
||||||
|
oauth_token: "invalid-token",
|
||||||
|
oauth_token_secret: "invalid-secret",
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(exchangeOAuth1ForOAuth2(oauth1Token)).rejects.toThrow(
|
||||||
|
"OAuth exchange failed: 401",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates correct expiry timestamps", async () => {
|
||||||
|
const expiresIn = 3600; // 1 hour
|
||||||
|
const refreshExpiresIn = 2592000; // 30 days
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
scope: "test-scope",
|
||||||
|
jti: "test-jti",
|
||||||
|
access_token: "token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
refresh_token: "refresh",
|
||||||
|
expires_in: expiresIn,
|
||||||
|
refresh_token_expires_in: refreshExpiresIn,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const result = await exchangeOAuth1ForOAuth2({
|
||||||
|
oauth_token: "token",
|
||||||
|
oauth_token_secret: "secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresAt = new Date(result.expires_at).getTime();
|
||||||
|
const refreshExpiresAt = new Date(
|
||||||
|
result.refresh_token_expires_at,
|
||||||
|
).getTime();
|
||||||
|
|
||||||
|
// Allow 5 second tolerance for test execution time
|
||||||
|
expect(expiresAt).toBeGreaterThanOrEqual(now + expiresIn * 1000 - 5000);
|
||||||
|
expect(expiresAt).toBeLessThanOrEqual(now + expiresIn * 1000 + 5000);
|
||||||
|
|
||||||
|
expect(refreshExpiresAt).toBeGreaterThanOrEqual(
|
||||||
|
now + refreshExpiresIn * 1000 - 5000,
|
||||||
|
);
|
||||||
|
expect(refreshExpiresAt).toBeLessThanOrEqual(
|
||||||
|
now + refreshExpiresIn * 1000 + 5000,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
src/lib/garmin-auth.ts
Normal file
114
src/lib/garmin-auth.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// ABOUTME: Garmin OAuth1 to OAuth2 token exchange functionality.
|
||||||
|
// ABOUTME: Uses OAuth1 tokens to refresh expired OAuth2 access tokens.
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import OAuth from "oauth-1.0a";
|
||||||
|
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
const GARMIN_EXCHANGE_URL =
|
||||||
|
"https://connectapi.garmin.com/oauth-service/oauth/exchange/user/2.0";
|
||||||
|
|
||||||
|
const OAUTH_CONSUMER = {
|
||||||
|
key: "fc3e99d2-118c-44b8-8ae3-03370dde24c0",
|
||||||
|
secret: "E08WAR897WEy2knn7aFBrvegVAf0AFdWBBF",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OAuth1TokenData {
|
||||||
|
oauth_token: string;
|
||||||
|
oauth_token_secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuth2TokenData {
|
||||||
|
scope: string;
|
||||||
|
jti: string;
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token_expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshResult {
|
||||||
|
oauth2: OAuth2TokenData;
|
||||||
|
expires_at: string;
|
||||||
|
refresh_token_expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFunctionSha1(baseString: string, key: string): string {
|
||||||
|
return createHmac("sha1", key).update(baseString).digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange OAuth1 token for a fresh OAuth2 token.
|
||||||
|
* This is how Garmin "refreshes" tokens - by re-exchanging OAuth1 for OAuth2.
|
||||||
|
* The OAuth1 token lasts ~1 year, OAuth2 access token ~21 hours.
|
||||||
|
*/
|
||||||
|
export async function exchangeOAuth1ForOAuth2(
|
||||||
|
oauth1Token: OAuth1TokenData,
|
||||||
|
): Promise<RefreshResult> {
|
||||||
|
const oauth = new OAuth({
|
||||||
|
consumer: OAUTH_CONSUMER,
|
||||||
|
signature_method: "HMAC-SHA1",
|
||||||
|
hash_function: hashFunctionSha1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requestData = {
|
||||||
|
url: GARMIN_EXCHANGE_URL,
|
||||||
|
method: "POST",
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = {
|
||||||
|
key: oauth1Token.oauth_token,
|
||||||
|
secret: oauth1Token.oauth_token_secret,
|
||||||
|
};
|
||||||
|
|
||||||
|
const authHeader = oauth.toHeader(oauth.authorize(requestData, token));
|
||||||
|
|
||||||
|
logger.info("Exchanging OAuth1 token for fresh OAuth2 token");
|
||||||
|
|
||||||
|
const response = await fetch(GARMIN_EXCHANGE_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...authHeader,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
logger.error(
|
||||||
|
{ status: response.status, body: text },
|
||||||
|
"OAuth1 to OAuth2 exchange failed",
|
||||||
|
);
|
||||||
|
throw new Error(`OAuth exchange failed: ${response.status} - ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauth2Data = (await response.json()) as OAuth2TokenData;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = new Date(now + oauth2Data.expires_in * 1000).toISOString();
|
||||||
|
const refreshTokenExpiresAt = new Date(
|
||||||
|
now + oauth2Data.refresh_token_expires_in * 1000,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ expiresAt, refreshTokenExpiresAt },
|
||||||
|
"OAuth2 token refreshed successfully",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
oauth2: oauth2Data,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if access token is expired or expiring within the buffer period.
|
||||||
|
* Buffer is 5 minutes to ensure we refresh before actual expiry.
|
||||||
|
*/
|
||||||
|
export function isAccessTokenExpired(expiresAt: Date | string): boolean {
|
||||||
|
const expiryTime = new Date(expiresAt).getTime();
|
||||||
|
const bufferMs = 5 * 60 * 1000; // 5 minutes buffer
|
||||||
|
return Date.now() >= expiryTime - bufferMs;
|
||||||
|
}
|
||||||
@@ -110,6 +110,38 @@ describe("daysUntilExpiry", () => {
|
|||||||
expect(days).toBeGreaterThanOrEqual(6);
|
expect(days).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", () => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user