Compare commits
57 Commits
8c59b3bd67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3d341e51 | |||
| 1a9a327a30 | |||
| d4b04a17be | |||
| 092d8bb3dd | |||
| 0d5785aaaa | |||
| 9c4fcd8b52 | |||
| 140de56450 | |||
| ccbc86016d | |||
| 6543c79a04 | |||
| ed14aea0ea | |||
| 8956e04eca | |||
| 6cd0c06396 | |||
| a1495ff23f | |||
| 4dad370e66 | |||
| a184909957 | |||
| 3e2d9047fb | |||
| 55a3505b55 | |||
| 14bd0407f9 | |||
| 1f7c804a4b | |||
| 5f8e913555 | |||
| f923e1ce48 | |||
| 599a66bbb5 | |||
| 923b5fdb01 | |||
| c080e7054d | |||
| 83fd29b6c6 | |||
| cf89675b92 | |||
| 59d70ee414 | |||
| 51f4c8eb80 | |||
| 98293f5ab5 | |||
| 85b535f04a | |||
| 7ed827f82c | |||
| 3a06bff4d4 | |||
| 4ba9f44cef | |||
| 0579ca2534 | |||
| 4a874476c3 | |||
| ff3d8fad2c | |||
| b221acee40 | |||
| 6df145d916 | |||
| 00b84d0b22 | |||
| 7dd08ab5ce | |||
| 9709cf27ab | |||
| 04a532bb01 | |||
| b6f139883f | |||
| f3d7f8bd35 | |||
| e2a600700d | |||
| c4d56f23e2 | |||
| 79414b813a | |||
| 5bfe51d630 | |||
| b3c711b9af | |||
| f6b05a0765 | |||
| 78c658822e | |||
| 2558930507 | |||
| f4a3f7d9fd | |||
| 54b57d5160 | |||
| 2ade07e12a | |||
| 27f084f950 | |||
| 3b9e023736 |
@@ -11,8 +11,10 @@ NODE_ENV=development
|
||||
POCKETBASE_URL=http://localhost:8090
|
||||
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
||||
# Email (Mailgun)
|
||||
MAILGUN_API_KEY=key-xxxxxxxxxxxx
|
||||
MAILGUN_DOMAIN=yourdomain.com
|
||||
MAILGUN_URL=https://api.eu.mailgun.net # Use https://api.mailgun.net for US region
|
||||
EMAIL_FROM=phaseflow@yourdomain.com
|
||||
|
||||
# Encryption (for Garmin tokens)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# ABOUTME: Gitea Actions workflow for CI quality gates on pull requests.
|
||||
# ABOUTME: Runs lint, typecheck, and unit tests before merge is allowed.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linter
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run typecheck
|
||||
run: pnpm tsc --noEmit
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test:run
|
||||
env:
|
||||
# Required env vars for tests (dummy values for CI)
|
||||
NEXT_PUBLIC_POCKETBASE_URL: http://localhost:8090
|
||||
RESEND_API_KEY: re_test_key
|
||||
ENCRYPTION_KEY: 12345678901234567890123456789012
|
||||
CRON_SECRET: test_cron_secret
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -59,3 +59,5 @@ result
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
.env.phaseflow
|
||||
|
||||
22
AGENTS.md
22
AGENTS.md
@@ -18,11 +18,33 @@ Run these after implementing to get immediate feedback:
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Production URL: https://phaseflow.v.paler.net
|
||||
- Database: PocketBase at `NEXT_PUBLIC_POCKETBASE_URL` env var
|
||||
- Deployment config: `../alo-cluster/services/phaseflow.hcl` (Nomad job)
|
||||
- Garmin tokens encrypted with AES-256 using `ENCRYPTION_KEY` (32 chars)
|
||||
- Path aliases: `@/*` maps to `./src/*`
|
||||
- Pre-commit hooks: Biome lint + Vitest tests via Lefthook
|
||||
- CI/CD: Automatic deployment on git push to main (do not manually trigger Nomad jobs)
|
||||
|
||||
## Production Logs
|
||||
|
||||
Access production logs via Nomad CLI:
|
||||
|
||||
```bash
|
||||
# Check job status and get current allocation ID
|
||||
nomad job status phaseflow
|
||||
|
||||
# View app logs (replace ALLOC_ID with current allocation)
|
||||
nomad alloc logs ALLOC_ID app
|
||||
|
||||
# Tail recent logs
|
||||
nomad alloc logs ALLOC_ID app | tail -100
|
||||
|
||||
# Filter for specific log patterns
|
||||
nomad alloc logs ALLOC_ID app | grep -E "pattern"
|
||||
```
|
||||
|
||||
The allocation has two tasks: `app` (Next.js) and `pocketbase` (database).
|
||||
|
||||
## Database Setup
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
159
e2e/auth.spec.ts
159
e2e/auth.spec.ts
@@ -226,4 +226,163 @@ test.describe("authentication", () => {
|
||||
expect(["ok", "unhealthy"]).toContain(body.status);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("OIDC authentication flow", () => {
|
||||
// Mock PocketBase auth-methods to return OIDC provider
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/collections/users/auth-methods*", (route) => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
usernamePassword: true,
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers: [
|
||||
{
|
||||
name: "oidc",
|
||||
displayName: "Test Provider",
|
||||
state: "mock-state",
|
||||
codeVerifier: "mock-verifier",
|
||||
codeChallenge: "mock-challenge",
|
||||
codeChallengeMethod: "S256",
|
||||
authURL: "https://mock.example.com/auth",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("OIDC button shows provider name when configured", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
||||
await expect(oidcButton).toBeVisible();
|
||||
await expect(oidcButton).toContainText("Test Provider");
|
||||
});
|
||||
|
||||
test("OIDC button shows loading state during authentication", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Find button by initial text
|
||||
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
||||
await expect(oidcButton).toBeVisible();
|
||||
|
||||
// Click and immediately check for loading state
|
||||
// The button text changes to "Signing in..." so we need a different locator
|
||||
await oidcButton.click();
|
||||
|
||||
// Find the button that shows loading state (text changed)
|
||||
const loadingButton = page.getByRole("button", { name: /signing in/i });
|
||||
await expect(loadingButton).toBeVisible();
|
||||
await expect(loadingButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("OIDC button is disabled when rate limited", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
||||
|
||||
// Initial state should not be disabled
|
||||
await expect(oidcButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("session persistence", () => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("session persists after page refresh", async ({ page }) => {
|
||||
// Verify we're on dashboard
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Refresh the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should still be on dashboard, not redirected to login
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
// Dashboard content should be visible (not login page)
|
||||
const dashboardContent = page.getByRole("heading").first();
|
||||
await expect(dashboardContent).toBeVisible();
|
||||
});
|
||||
|
||||
test("session persists when navigating between pages", async ({ page }) => {
|
||||
// Navigate to settings
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should be on settings, not redirected to login
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
|
||||
// Navigate to calendar
|
||||
await page.goto("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should be on calendar, not redirected to login
|
||||
await expect(page).toHaveURL(/\/calendar/);
|
||||
|
||||
// Navigate back to dashboard
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should still be authenticated
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("logout clears session and redirects to login", async ({ page }) => {
|
||||
// Navigate to settings where logout button is located
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Find and click logout button
|
||||
const logoutButton = page.getByRole("button", { name: /log ?out/i });
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await logoutButton.click();
|
||||
|
||||
// Should redirect to login page
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
|
||||
|
||||
// Now try to access protected route - should redirect to login
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,195 +1,52 @@
|
||||
// ABOUTME: E2E tests for calendar functionality including ICS feed and calendar view.
|
||||
// ABOUTME: Tests calendar display, navigation, and ICS subscription features.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("calendar", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("calendar page redirects to login when not authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/calendar");
|
||||
import { test as baseTest } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
// Should redirect to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
baseTest.describe("calendar", () => {
|
||||
baseTest.describe("unauthenticated", () => {
|
||||
baseTest(
|
||||
"calendar page redirects to login when not authenticated",
|
||||
async ({ page }) => {
|
||||
await page.goto("/calendar");
|
||||
|
||||
// Should redirect to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("authenticated", () => {
|
||||
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login via the login page
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEmailForm) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailInput.fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
await page.goto("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("displays calendar page with heading", async ({ page }) => {
|
||||
// Check for the main calendar heading (h1)
|
||||
const heading = page.getByRole("heading", {
|
||||
name: "Calendar",
|
||||
exact: true,
|
||||
});
|
||||
await expect(heading).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows month view calendar", async ({ page }) => {
|
||||
// Look for calendar grid structure
|
||||
const calendarGrid = page
|
||||
.getByRole("grid")
|
||||
.or(page.locator('[data-testid="month-view"]'));
|
||||
await expect(calendarGrid).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows month and year display", async ({ page }) => {
|
||||
// Calendar should show current month/year
|
||||
const monthYear = page.getByText(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
);
|
||||
await expect(monthYear.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("has navigation controls for months", async ({ page }) => {
|
||||
// Look for previous/next month buttons
|
||||
const prevButton = page.getByRole("button", {
|
||||
name: /prev|previous|←|back/i,
|
||||
});
|
||||
const nextButton = page.getByRole("button", { name: /next|→|forward/i });
|
||||
|
||||
// At least one navigation control should be visible
|
||||
const hasPrev = await prevButton.isVisible().catch(() => false);
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
|
||||
expect(hasPrev || hasNext).toBe(true);
|
||||
});
|
||||
|
||||
test("can navigate to previous month", async ({ page }) => {
|
||||
const prevButton = page.getByRole("button", { name: /prev|previous|←/i });
|
||||
const hasPrev = await prevButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasPrev) {
|
||||
// Click previous month button
|
||||
await prevButton.click();
|
||||
|
||||
// Wait for update - verify page doesn't error
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify calendar is still rendered
|
||||
const monthYear = page.getByText(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
baseTest.describe("ICS endpoint", () => {
|
||||
baseTest(
|
||||
"ICS endpoint returns error for invalid user",
|
||||
async ({ page }) => {
|
||||
const response = await page.request.get(
|
||||
"/api/calendar/invalid-user-id/invalid-token.ics",
|
||||
);
|
||||
await expect(monthYear.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can navigate to next month", async ({ page }) => {
|
||||
const nextButton = page.getByRole("button", { name: /next|→/i });
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
// Should return 404 (user not found) or 500 (PocketBase not connected in test env)
|
||||
expect([404, 500]).toContain(response.status());
|
||||
},
|
||||
);
|
||||
|
||||
if (hasNext) {
|
||||
// Click next
|
||||
await nextButton.click();
|
||||
baseTest(
|
||||
"ICS endpoint returns error for invalid token",
|
||||
async ({ page }) => {
|
||||
// Need a valid user ID but invalid token - this would require setup
|
||||
// For now, just verify the endpoint exists and returns appropriate error
|
||||
const response = await page.request.get(
|
||||
"/api/calendar/test/invalid.ics",
|
||||
);
|
||||
|
||||
// Wait for update
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows ICS subscription section", async ({ page }) => {
|
||||
// Look for calendar subscription / ICS section
|
||||
const subscriptionText = page.getByText(
|
||||
/subscribe|subscription|calendar.*url|ics/i,
|
||||
);
|
||||
const hasSubscription = await subscriptionText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// This may not be visible if user hasn't generated a token
|
||||
if (hasSubscription) {
|
||||
await expect(subscriptionText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows generate or regenerate token button", async ({ page }) => {
|
||||
// Look for generate/regenerate button
|
||||
const tokenButton = page.getByRole("button", {
|
||||
name: /generate|regenerate/i,
|
||||
});
|
||||
const hasButton = await tokenButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasButton) {
|
||||
await expect(tokenButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows copy button when URL exists", async ({ page }) => {
|
||||
// Copy button only shows when URL is generated
|
||||
const copyButton = page.getByRole("button", { name: /copy/i });
|
||||
const hasCopy = await copyButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasCopy) {
|
||||
await expect(copyButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows back navigation", async ({ page }) => {
|
||||
const backLink = page.getByRole("link", { name: /back|home|dashboard/i });
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate back to dashboard", async ({ page }) => {
|
||||
const backLink = page.getByRole("link", { name: /back|home|dashboard/i });
|
||||
await backLink.click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
// Should return 404 (user not found), 401 (invalid token), or 500 (PocketBase not connected)
|
||||
expect([401, 404, 500]).toContain(response.status());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("ICS endpoint", () => {
|
||||
test("ICS endpoint returns error for invalid user", async ({ page }) => {
|
||||
const response = await page.request.get(
|
||||
"/api/calendar/invalid-user-id/invalid-token.ics",
|
||||
);
|
||||
|
||||
// Should return 404 (user not found) or 500 (PocketBase not connected in test env)
|
||||
expect([404, 500]).toContain(response.status());
|
||||
});
|
||||
|
||||
test("ICS endpoint returns error for invalid token", async ({ page }) => {
|
||||
// Need a valid user ID but invalid token - this would require setup
|
||||
// For now, just verify the endpoint exists and returns appropriate error
|
||||
const response = await page.request.get("/api/calendar/test/invalid.ics");
|
||||
|
||||
// Should return 404 (user not found), 401 (invalid token), or 500 (PocketBase not connected)
|
||||
expect([401, 404, 500]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("calendar regenerate token API", () => {
|
||||
test("regenerate token requires authentication", async ({ page }) => {
|
||||
baseTest.describe("calendar regenerate token API", () => {
|
||||
baseTest("regenerate token requires authentication", async ({ page }) => {
|
||||
const response = await page.request.post(
|
||||
"/api/calendar/regenerate-token",
|
||||
);
|
||||
@@ -199,3 +56,700 @@ test.describe("calendar", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("calendar authenticated", () => {
|
||||
test("displays calendar page with heading", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Check for the main calendar heading (h1)
|
||||
const heading = establishedPage.getByRole("heading", {
|
||||
name: "Calendar",
|
||||
exact: true,
|
||||
});
|
||||
await expect(heading).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows month view calendar", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for calendar grid structure
|
||||
const calendarGrid = establishedPage
|
||||
.getByRole("grid")
|
||||
.or(establishedPage.locator('[data-testid="month-view"]'));
|
||||
await expect(calendarGrid).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows month and year display", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Calendar should show current month/year
|
||||
const monthYear = establishedPage.getByText(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
);
|
||||
await expect(monthYear.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("has navigation controls for months", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for previous/next month buttons
|
||||
const prevButton = establishedPage.getByRole("button", {
|
||||
name: /prev|previous|←|back/i,
|
||||
});
|
||||
const nextButton = establishedPage.getByRole("button", {
|
||||
name: /next|→|forward/i,
|
||||
});
|
||||
|
||||
// At least one navigation control should be visible
|
||||
const hasPrev = await prevButton.isVisible().catch(() => false);
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
|
||||
expect(hasPrev || hasNext).toBe(true);
|
||||
});
|
||||
|
||||
test("can navigate to previous month", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
const prevButton = establishedPage.getByRole("button", {
|
||||
name: /prev|previous|←/i,
|
||||
});
|
||||
const hasPrev = await prevButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasPrev) {
|
||||
// Click previous month button
|
||||
await prevButton.click();
|
||||
|
||||
// Wait for update - verify page doesn't error
|
||||
await establishedPage.waitForTimeout(500);
|
||||
|
||||
// Verify calendar is still rendered
|
||||
const monthYear = establishedPage.getByText(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
);
|
||||
await expect(monthYear.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can navigate to next month", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
const nextButton = establishedPage.getByRole("button", { name: /next|→/i });
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasNext) {
|
||||
// Click next
|
||||
await nextButton.click();
|
||||
|
||||
// Wait for update
|
||||
await establishedPage.waitForTimeout(500);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows ICS subscription section", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for calendar subscription / ICS section
|
||||
const subscriptionText = establishedPage.getByText(
|
||||
/subscribe|subscription|calendar.*url|ics/i,
|
||||
);
|
||||
const hasSubscription = await subscriptionText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// This may not be visible if user hasn't generated a token
|
||||
if (hasSubscription) {
|
||||
await expect(subscriptionText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows back navigation", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
const backLink = establishedPage.getByRole("link", {
|
||||
name: /back|home|dashboard/i,
|
||||
});
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate back to dashboard", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
const backLink = establishedPage.getByRole("link", {
|
||||
name: /back|home|dashboard/i,
|
||||
});
|
||||
await backLink.click();
|
||||
|
||||
await expect(establishedPage).toHaveURL("/");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("calendar display features", () => {
|
||||
test("today is highlighted in calendar view", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Today's date should be highlighted with distinct styling
|
||||
const today = new Date();
|
||||
const dayNumber = today.getDate().toString();
|
||||
|
||||
// Look for today button/cell with special styling
|
||||
const todayCell = establishedPage
|
||||
.locator('[data-today="true"]')
|
||||
.or(establishedPage.locator('[aria-current="date"]'))
|
||||
.or(
|
||||
establishedPage.getByRole("button", {
|
||||
name: new RegExp(`${dayNumber}`),
|
||||
}),
|
||||
);
|
||||
|
||||
const hasTodayHighlight = await todayCell
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasTodayHighlight) {
|
||||
await expect(todayCell.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("phase colors are visible in calendar days", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Calendar days should have phase coloring (background color classes)
|
||||
const dayButtons = establishedPage.getByRole("button").filter({
|
||||
has: establishedPage.locator('[class*="bg-"]'),
|
||||
});
|
||||
|
||||
const hasColoredDays = await dayButtons
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// If there's cycle data, some days should have color
|
||||
if (hasColoredDays) {
|
||||
await expect(dayButtons.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("calendar shows phase legend", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for phase legend with phase names
|
||||
const legendText = establishedPage.getByText(
|
||||
/menstrual|follicular|ovulation|luteal/i,
|
||||
);
|
||||
const hasLegend = await legendText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasLegend) {
|
||||
await expect(legendText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("calendar has Today button for quick navigation", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
const todayButton = establishedPage.getByRole("button", { name: /today/i });
|
||||
const hasTodayButton = await todayButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasTodayButton) {
|
||||
await expect(todayButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can navigate multiple months and return to today", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Navigate forward a few months
|
||||
const nextButton = establishedPage.getByRole("button", { name: /next|→/i });
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasNext) {
|
||||
await nextButton.click();
|
||||
await establishedPage.waitForTimeout(300);
|
||||
await nextButton.click();
|
||||
await establishedPage.waitForTimeout(300);
|
||||
|
||||
// Look for Today button to return
|
||||
const todayButton = establishedPage.getByRole("button", {
|
||||
name: /today/i,
|
||||
});
|
||||
const hasTodayButton = await todayButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasTodayButton) {
|
||||
await todayButton.click();
|
||||
await establishedPage.waitForTimeout(300);
|
||||
|
||||
// Should be back to current month
|
||||
const currentMonth = new Date().toLocaleString("default", {
|
||||
month: "long",
|
||||
});
|
||||
const monthText = establishedPage.getByText(
|
||||
new RegExp(currentMonth, "i"),
|
||||
);
|
||||
const isCurrentMonth = await monthText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(isCurrentMonth).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("ICS feed - generate token flow", () => {
|
||||
test("can generate calendar URL", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Established user has no token - should see generate button
|
||||
const generateButton = establishedPage.getByRole("button", {
|
||||
name: /generate/i,
|
||||
});
|
||||
await expect(generateButton).toBeVisible();
|
||||
|
||||
await generateButton.click();
|
||||
await establishedPage.waitForTimeout(1000);
|
||||
|
||||
// After generating, URL should be displayed
|
||||
const urlDisplay = establishedPage.getByText(/\.ics|calendar.*url/i);
|
||||
await expect(urlDisplay.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows generate or regenerate token button", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for generate/regenerate button
|
||||
const tokenButton = establishedPage.getByRole("button", {
|
||||
name: /generate|regenerate/i,
|
||||
});
|
||||
await expect(tokenButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("ICS feed - with token", () => {
|
||||
// Helper to ensure URL is generated
|
||||
async function ensureCalendarUrlGenerated(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const urlInput = page.getByRole("textbox");
|
||||
const hasUrl = await urlInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrl) {
|
||||
// Generate the URL if not present
|
||||
const generateButton = page.getByRole("button", { name: /generate/i });
|
||||
if (await generateButton.isVisible().catch(() => false)) {
|
||||
await generateButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("calendar URL is displayed after generation", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(establishedPage);
|
||||
|
||||
// URL should now be visible
|
||||
const urlInput = establishedPage.getByRole("textbox");
|
||||
await expect(urlInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("calendar URL contains user ID and token", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(establishedPage);
|
||||
|
||||
const urlInput = establishedPage.getByRole("textbox");
|
||||
await expect(urlInput).toBeVisible();
|
||||
|
||||
const url = await urlInput.inputValue();
|
||||
// URL should contain /api/calendar/ and end with .ics
|
||||
expect(url).toContain("/api/calendar/");
|
||||
expect(url).toContain(".ics");
|
||||
});
|
||||
|
||||
test("shows copy button when URL exists", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(establishedPage);
|
||||
|
||||
// Copy button should be visible after generating token
|
||||
const copyButton = establishedPage.getByRole("button", { name: /copy/i });
|
||||
await expect(copyButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("copy button copies URL to clipboard", async ({
|
||||
establishedPage,
|
||||
context,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(establishedPage);
|
||||
|
||||
// Grant clipboard permissions
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
const copyButton = establishedPage.getByRole("button", { name: /copy/i });
|
||||
await expect(copyButton).toBeVisible();
|
||||
|
||||
await copyButton.click();
|
||||
|
||||
// Verify clipboard has content (clipboard read may not work in all env)
|
||||
const clipboardContent = await establishedPage
|
||||
.evaluate(() => navigator.clipboard.readText())
|
||||
.catch(() => null);
|
||||
|
||||
if (clipboardContent) {
|
||||
expect(clipboardContent).toContain(".ics");
|
||||
}
|
||||
});
|
||||
|
||||
test("shows regenerate button after generating token", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(establishedPage);
|
||||
|
||||
// User with token should see regenerate option
|
||||
const regenerateButton = establishedPage.getByRole("button", {
|
||||
name: /regenerate/i,
|
||||
});
|
||||
await expect(regenerateButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("ICS feed content validation", () => {
|
||||
// Helper to ensure URL is generated
|
||||
async function ensureCalendarUrlGenerated(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<void> {
|
||||
const urlInput = page.getByRole("textbox");
|
||||
const hasUrl = await urlInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrl) {
|
||||
const generateButton = page.getByRole("button", { name: /generate/i });
|
||||
if (await generateButton.isVisible().catch(() => false)) {
|
||||
await generateButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getIcsContent(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<string | null> {
|
||||
const urlInput = page.getByRole("textbox");
|
||||
const hasUrlInput = await urlInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasUrlInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = await urlInput.inputValue();
|
||||
const response = await page.request.get(url);
|
||||
if (response.ok()) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
test("ICS feed contains valid VCALENDAR structure", async ({
|
||||
calendarPage,
|
||||
}) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const icsContent = await getIcsContent(calendarPage);
|
||||
expect(icsContent).not.toBeNull();
|
||||
|
||||
// Verify basic ICS structure
|
||||
expect(icsContent).toContain("BEGIN:VCALENDAR");
|
||||
expect(icsContent).toContain("END:VCALENDAR");
|
||||
expect(icsContent).toContain("VERSION:2.0");
|
||||
expect(icsContent).toContain("PRODID:");
|
||||
});
|
||||
|
||||
test("ICS feed contains VEVENT entries", async ({ calendarPage }) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const icsContent = await getIcsContent(calendarPage);
|
||||
expect(icsContent).not.toBeNull();
|
||||
|
||||
// Should have at least some events
|
||||
expect(icsContent).toContain("BEGIN:VEVENT");
|
||||
expect(icsContent).toContain("END:VEVENT");
|
||||
});
|
||||
|
||||
test("ICS feed contains phase events with emojis", async ({
|
||||
calendarPage,
|
||||
}) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const icsContent = await getIcsContent(calendarPage);
|
||||
expect(icsContent).not.toBeNull();
|
||||
|
||||
// Per calendar.md spec, events should have emojis:
|
||||
// 🩸 Menstrual, 🌱 Follicular, 🌸 Ovulation, 🌙 Early Luteal, 🌑 Late Luteal
|
||||
const phaseEmojis = ["🩸", "🌱", "🌸", "🌙", "🌑"];
|
||||
const hasEmojis = phaseEmojis.some((emoji) => icsContent?.includes(emoji));
|
||||
expect(hasEmojis).toBe(true);
|
||||
});
|
||||
|
||||
test("ICS feed has CATEGORIES for calendar color coding", async ({
|
||||
calendarPage,
|
||||
}) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const icsContent = await getIcsContent(calendarPage);
|
||||
expect(icsContent).not.toBeNull();
|
||||
|
||||
// Per calendar.md spec, phases have color categories:
|
||||
// Red, Green, Pink, Yellow, Orange
|
||||
const colorCategories = ["Red", "Green", "Pink", "Yellow", "Orange"];
|
||||
const hasCategories = colorCategories.some((color) =>
|
||||
icsContent?.includes(`CATEGORIES:${color}`),
|
||||
);
|
||||
|
||||
// If user has cycle data, categories should be present
|
||||
if (
|
||||
icsContent?.includes("MENSTRUAL") ||
|
||||
icsContent?.includes("FOLLICULAR")
|
||||
) {
|
||||
expect(hasCategories).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ICS feed spans approximately 90 days", async ({ calendarPage }) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const icsContent = await getIcsContent(calendarPage);
|
||||
expect(icsContent).not.toBeNull();
|
||||
|
||||
// Count DTSTART entries to estimate event span
|
||||
const dtStartMatches = icsContent?.match(/DTSTART/g);
|
||||
|
||||
// Should have multiple events (phases + warnings)
|
||||
// 3 months of phases (~15 phase events) + warning events
|
||||
if (dtStartMatches) {
|
||||
expect(dtStartMatches.length).toBeGreaterThan(5);
|
||||
}
|
||||
});
|
||||
|
||||
test("ICS feed includes warning events", async ({ calendarPage }) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const icsContent = await getIcsContent(calendarPage);
|
||||
expect(icsContent).not.toBeNull();
|
||||
|
||||
// Per ics.ts, warning events include these phrases
|
||||
const warningIndicators = [
|
||||
"Late Luteal Phase",
|
||||
"CRITICAL PHASE",
|
||||
"⚠️",
|
||||
"🔴",
|
||||
];
|
||||
const hasWarnings = warningIndicators.some((indicator) =>
|
||||
icsContent?.includes(indicator),
|
||||
);
|
||||
|
||||
// Warnings should be present if feed has events
|
||||
if (icsContent?.includes("BEGIN:VEVENT")) {
|
||||
expect(hasWarnings).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("ICS content type is text/calendar", async ({ calendarPage }) => {
|
||||
await calendarPage.goto("/calendar");
|
||||
await calendarPage.waitForLoadState("networkidle");
|
||||
|
||||
await ensureCalendarUrlGenerated(calendarPage);
|
||||
|
||||
const urlInput = calendarPage.getByRole("textbox");
|
||||
await expect(urlInput).toBeVisible();
|
||||
|
||||
const url = await urlInput.inputValue();
|
||||
const response = await calendarPage.request.get(url);
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const contentType = response.headers()["content-type"];
|
||||
expect(contentType).toContain("text/calendar");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("calendar accessibility", () => {
|
||||
test("calendar grid has proper ARIA role and label", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Calendar should have role="grid" per WAI-ARIA calendar pattern
|
||||
const calendarGrid = establishedPage.getByRole("grid", {
|
||||
name: /calendar/i,
|
||||
});
|
||||
await expect(calendarGrid).toBeVisible();
|
||||
});
|
||||
|
||||
test("day cells have descriptive aria-labels", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Day buttons should have descriptive aria-labels including date and phase info
|
||||
const dayButtons = establishedPage.locator("button[data-day]");
|
||||
const hasDayButtons = await dayButtons
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasDayButtons) {
|
||||
// No day buttons with data-day attribute - test different selector
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first visible day button's aria-label
|
||||
const firstDayButton = dayButtons.first();
|
||||
const ariaLabel = await firstDayButton.getAttribute("aria-label");
|
||||
|
||||
// Aria-label should contain date information (month and year)
|
||||
expect(ariaLabel).toMatch(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
);
|
||||
expect(ariaLabel).toMatch(/\d{4}/); // Should contain year
|
||||
});
|
||||
|
||||
test("keyboard navigation with arrow keys works", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Focus on a day button in the calendar grid
|
||||
const dayButtons = establishedPage.locator("button[data-day]");
|
||||
const hasDayButtons = await dayButtons
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasDayButtons) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Click a day button to focus it
|
||||
const calendarGrid = establishedPage.getByRole("grid", {
|
||||
name: /calendar/i,
|
||||
});
|
||||
const hasGrid = await calendarGrid.isVisible().catch(() => false);
|
||||
|
||||
if (!hasGrid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus the grid and press Tab to focus first day
|
||||
await calendarGrid.focus();
|
||||
await establishedPage.keyboard.press("Tab");
|
||||
|
||||
// Get currently focused element
|
||||
const focusedBefore = await establishedPage.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return el ? el.getAttribute("data-day") : null;
|
||||
});
|
||||
|
||||
// Press ArrowRight to move to next day
|
||||
await establishedPage.keyboard.press("ArrowRight");
|
||||
|
||||
// Get new focused element
|
||||
const focusedAfter = await establishedPage.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
return el ? el.getAttribute("data-day") : null;
|
||||
});
|
||||
|
||||
// If both values exist, verify navigation occurred
|
||||
if (focusedBefore && focusedAfter) {
|
||||
expect(focusedAfter).not.toBe(focusedBefore);
|
||||
}
|
||||
});
|
||||
|
||||
test("navigation buttons have accessible labels", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/calendar");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Previous and next month buttons should have aria-labels
|
||||
const prevButton = establishedPage.getByRole("button", {
|
||||
name: /previous month/i,
|
||||
});
|
||||
const nextButton = establishedPage.getByRole("button", {
|
||||
name: /next month/i,
|
||||
});
|
||||
|
||||
const hasPrev = await prevButton.isVisible().catch(() => false);
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
|
||||
// At least one navigation button should be accessible
|
||||
expect(hasPrev || hasNext).toBe(true);
|
||||
|
||||
if (hasPrev) {
|
||||
await expect(prevButton).toBeVisible();
|
||||
}
|
||||
if (hasNext) {
|
||||
await expect(nextButton).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
375
e2e/cycle.spec.ts
Normal file
375
e2e/cycle.spec.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
// ABOUTME: E2E tests for cycle tracking functionality.
|
||||
// ABOUTME: Tests cycle day display, phase transitions, and period logging.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("cycle tracking", () => {
|
||||
test.describe("cycle API", () => {
|
||||
test("cycle/current endpoint requires authentication", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get("/api/cycle/current");
|
||||
|
||||
// Should return 401 when not authenticated
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("cycle display", () => {
|
||||
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("dashboard shows current cycle day", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for loading to complete
|
||||
await page
|
||||
.waitForSelector('[aria-label="Loading cycle info"]', {
|
||||
state: "detached",
|
||||
timeout: 15000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Look for cycle day text (e.g., "Day 12")
|
||||
const dayText = page.getByText(/day \d+/i);
|
||||
const hasDay = await dayText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// If no cycle data set, should show onboarding
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasDay || hasOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("dashboard shows current phase name", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page
|
||||
.waitForSelector('[aria-label="Loading cycle info"]', {
|
||||
state: "detached",
|
||||
timeout: 15000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Look for phase name (one of the five phases)
|
||||
const phases = [
|
||||
"MENSTRUAL",
|
||||
"FOLLICULAR",
|
||||
"OVULATION",
|
||||
"EARLY LUTEAL",
|
||||
"LATE LUTEAL",
|
||||
];
|
||||
const phaseTexts = phases.map((p) => page.getByText(new RegExp(p, "i")));
|
||||
|
||||
let hasPhase = false;
|
||||
for (const phaseText of phaseTexts) {
|
||||
if (
|
||||
await phaseText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
hasPhase = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no cycle data, onboarding should be visible
|
||||
const onboarding = page.getByText(/set.*period|log.*period/i);
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasPhase || hasOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("plan page shows all 5 phases", async ({ page }) => {
|
||||
await page.goto("/plan");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page
|
||||
.waitForSelector('[aria-label="Loading"]', {
|
||||
state: "detached",
|
||||
timeout: 15000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Phase overview section should show all phases
|
||||
const phaseOverview = page.getByRole("heading", {
|
||||
name: "Phase Overview",
|
||||
});
|
||||
const hasOverview = await phaseOverview.isVisible().catch(() => false);
|
||||
|
||||
if (hasOverview) {
|
||||
await expect(page.getByTestId("phase-MENSTRUAL")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-FOLLICULAR")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-OVULATION")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-EARLY_LUTEAL")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-LATE_LUTEAL")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("phase cards show weekly intensity limits", async ({ page }) => {
|
||||
await page.goto("/plan");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page
|
||||
.waitForSelector('[aria-label="Loading"]', {
|
||||
state: "detached",
|
||||
timeout: 15000,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const phaseOverview = page.getByRole("heading", {
|
||||
name: "Phase Overview",
|
||||
});
|
||||
const hasOverview = await phaseOverview.isVisible().catch(() => false);
|
||||
|
||||
if (hasOverview) {
|
||||
// Each phase card should show min/week limit - use testid for specificity
|
||||
await expect(
|
||||
page.getByTestId("phase-MENSTRUAL").getByText("30 min/week"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("phase-FOLLICULAR").getByText("120 min/week"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("phase-OVULATION").getByText("80 min/week"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("phase-EARLY_LUTEAL").getByText("100 min/week"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("phase-LATE_LUTEAL").getByText("50 min/week"),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("cycle settings", () => {
|
||||
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("settings page allows cycle length configuration", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for cycle length input
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const hasCycleLength = await cycleLengthInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasCycleLength) {
|
||||
// Should be a number input
|
||||
const inputType = await cycleLengthInput.getAttribute("type");
|
||||
expect(inputType).toBe("number");
|
||||
|
||||
// Should have valid range (21-45 per spec)
|
||||
const min = await cycleLengthInput.getAttribute("min");
|
||||
const max = await cycleLengthInput.getAttribute("max");
|
||||
expect(min).toBe("21");
|
||||
expect(max).toBe("45");
|
||||
}
|
||||
});
|
||||
|
||||
test("settings page shows current cycle length value", async ({ page }) => {
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const hasCycleLength = await cycleLengthInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasCycleLength) {
|
||||
// Should have a value between 21-45
|
||||
const value = await cycleLengthInput.inputValue();
|
||||
const numValue = Number.parseInt(value, 10);
|
||||
expect(numValue).toBeGreaterThanOrEqual(21);
|
||||
expect(numValue).toBeLessThanOrEqual(45);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging", () => {
|
||||
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 history page is accessible", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should show Period History heading
|
||||
const heading = page.getByRole("heading", { name: /period history/i });
|
||||
await expect(heading).toBeVisible();
|
||||
});
|
||||
|
||||
test("period history shows table or empty state", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should show either period data table or empty state
|
||||
const table = page.locator("table");
|
||||
const emptyState = page.getByText(/no periods|no history/i);
|
||||
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
const hasEmpty = await emptyState
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasTable || hasEmpty).toBe(true);
|
||||
});
|
||||
|
||||
test("period history has link back to dashboard", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const dashboardLink = page.getByRole("link", { name: /dashboard/i });
|
||||
const hasLink = await dashboardLink.isVisible().catch(() => false);
|
||||
|
||||
// May have different link text
|
||||
const backLink = page.getByRole("link", { name: /back/i });
|
||||
const hasBackLink = await backLink.isVisible().catch(() => false);
|
||||
|
||||
expect(hasLink || hasBackLink).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("calendar integration", () => {
|
||||
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("calendar page shows phase colors in legend", async ({ page }) => {
|
||||
await page.goto("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Calendar should show phase legend with all phases
|
||||
const legend = page.getByText(/menstrual|follicular|ovulation|luteal/i);
|
||||
const hasLegend = await legend
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasLegend) {
|
||||
// Check for phase emojis in legend per spec
|
||||
const menstrualEmoji = page.getByText(/🩸.*menstrual/i);
|
||||
const follicularEmoji = page.getByText(/🌱.*follicular/i);
|
||||
|
||||
const hasMenstrual = await menstrualEmoji
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasFollicular = await follicularEmoji
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasMenstrual || hasFollicular).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
90
e2e/dark-mode.spec.ts
Normal file
90
e2e/dark-mode.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// ABOUTME: E2E tests for dark mode system preference detection.
|
||||
// ABOUTME: Verifies app respects OS-level dark mode setting via prefers-color-scheme.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Helper to parse background color and determine if it's light or dark
|
||||
function isLightBackground(colorValue: string): boolean {
|
||||
// Try rgb/rgba format: rgb(255, 255, 255) or rgba(255, 255, 255, 1)
|
||||
const rgbMatch = colorValue.match(
|
||||
/rgba?\s*\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/,
|
||||
);
|
||||
|
||||
if (rgbMatch) {
|
||||
const r = parseFloat(rgbMatch[1]);
|
||||
const g = parseFloat(rgbMatch[2]);
|
||||
const b = parseFloat(rgbMatch[3]);
|
||||
// Calculate relative luminance (simplified)
|
||||
const luminance = (r + g + b) / 3;
|
||||
return luminance > 128;
|
||||
}
|
||||
|
||||
// Try oklch format: oklch(1 0 0) where first value is lightness
|
||||
const oklchMatch = colorValue.match(/oklch\s*\(\s*([\d.]+)/);
|
||||
if (oklchMatch) {
|
||||
const lightness = parseFloat(oklchMatch[1]);
|
||||
return lightness > 0.5;
|
||||
}
|
||||
|
||||
// Try color() format with oklch: color(oklch 1 0 0)
|
||||
const colorOklchMatch = colorValue.match(/color\s*\(\s*oklch\s+([\d.]+)/);
|
||||
if (colorOklchMatch) {
|
||||
const lightness = parseFloat(colorOklchMatch[1]);
|
||||
return lightness > 0.5;
|
||||
}
|
||||
|
||||
// Try lab() format: lab(100 0 0) where first value is lightness 0-100
|
||||
const labMatch = colorValue.match(/lab\s*\(\s*([\d.]+)/);
|
||||
if (labMatch) {
|
||||
const lightness = parseFloat(labMatch[1]);
|
||||
return lightness > 50;
|
||||
}
|
||||
|
||||
// Default: couldn't parse, assume we need to fail the test
|
||||
return false;
|
||||
}
|
||||
|
||||
test.describe("dark mode", () => {
|
||||
test("applies light mode styling when system prefers light", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Set system preference to light mode
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Get computed background color of the body
|
||||
const bodyBgColor = await page.evaluate(() => {
|
||||
return window.getComputedStyle(document.body).backgroundColor;
|
||||
});
|
||||
|
||||
// In light mode, background should be white/light (oklch(1 0 0) = white)
|
||||
const isLight = isLightBackground(bodyBgColor);
|
||||
expect(
|
||||
isLight,
|
||||
`Expected light background in light mode, got: ${bodyBgColor}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("applies dark mode styling when system prefers dark", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Set system preference to dark mode
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
// Get computed background color of the body
|
||||
const bodyBgColor = await page.evaluate(() => {
|
||||
return window.getComputedStyle(document.body).backgroundColor;
|
||||
});
|
||||
|
||||
// In dark mode, background should be dark (oklch(0.145 0 0) = near black)
|
||||
const isLight = isLightBackground(bodyBgColor);
|
||||
expect(
|
||||
isLight,
|
||||
`Expected dark background in dark mode, got: ${bodyBgColor}`,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
386
e2e/decision-engine.spec.ts
Normal file
386
e2e/decision-engine.spec.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
// ABOUTME: E2E tests for the decision engine integration through the dashboard UI.
|
||||
// ABOUTME: Tests decision display, status colors, and override interactions.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("decision engine", () => {
|
||||
test.describe("decision display", () => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("decision card shows one of the valid statuses", async ({ page }) => {
|
||||
// Wait for dashboard to fully load (loading states to disappear)
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for loading indicators to disappear (skeleton loading states)
|
||||
await page
|
||||
.waitForSelector('[aria-label="Loading decision"]', {
|
||||
state: "detached",
|
||||
timeout: 15000,
|
||||
})
|
||||
.catch(() => {
|
||||
// May not have loading indicator if already loaded
|
||||
});
|
||||
|
||||
// Look for any of the valid decision statuses
|
||||
const validStatuses = ["REST", "GENTLE", "LIGHT", "REDUCED", "TRAIN"];
|
||||
|
||||
// Wait for decision card or status text to appear
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const statusText = page.getByText(/^(REST|GENTLE|LIGHT|REDUCED|TRAIN)$/);
|
||||
const onboarding = page.getByText(/connect garmin|set.*period/i);
|
||||
|
||||
// Wait for one of these to be visible
|
||||
await Promise.race([
|
||||
decisionCard.waitFor({ timeout: 10000 }),
|
||||
statusText.first().waitFor({ timeout: 10000 }),
|
||||
onboarding.first().waitFor({ timeout: 10000 }),
|
||||
]).catch(() => {
|
||||
// One of them should appear
|
||||
});
|
||||
|
||||
const hasDecisionCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasDecisionCard) {
|
||||
const cardText = await decisionCard.textContent();
|
||||
const hasValidStatus = validStatuses.some((status) =>
|
||||
cardText?.includes(status),
|
||||
);
|
||||
expect(hasValidStatus).toBe(true);
|
||||
} else {
|
||||
// Check for any status text on the page (fallback)
|
||||
const hasStatus = await statusText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Either has decision card or shows onboarding (valid states)
|
||||
const hasOnboarding = await onboarding
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
expect(hasStatus || hasOnboarding).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("decision displays a reason", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasDecisionCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasDecisionCard) {
|
||||
// Decision card should contain some explanatory text (the reason)
|
||||
const cardText = await decisionCard.textContent();
|
||||
// Reason should be longer than just the status word
|
||||
expect(cardText && cardText.length > 10).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("REST status displays with appropriate styling", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// If REST is displayed, it should have red/danger styling
|
||||
const restText = page.getByText("REST");
|
||||
const hasRest = await restText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasRest) {
|
||||
// REST should be in a container with red background or text
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasCard) {
|
||||
// Check that card has some styling (we can't easily check colors in Playwright)
|
||||
const cardClasses = await decisionCard.getAttribute("class");
|
||||
expect(cardClasses).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("TRAIN status displays with appropriate styling", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// If TRAIN is displayed, it should have green/success styling
|
||||
const trainText = page.getByText("TRAIN");
|
||||
const hasTrain = await trainText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasTrain) {
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasCard) {
|
||||
const cardClasses = await decisionCard.getAttribute("class");
|
||||
expect(cardClasses).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("override integration", () => {
|
||||
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("flare override forces REST decision", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for OVERRIDES section to appear
|
||||
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
|
||||
const hasOverrides = await overridesHeading
|
||||
.waitFor({ timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasOverrides) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find flare mode checkbox
|
||||
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
|
||||
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
|
||||
|
||||
if (!hasFlare) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable flare override
|
||||
const wasChecked = await flareCheckbox.isChecked();
|
||||
if (!wasChecked) {
|
||||
await flareCheckbox.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Decision should now show REST
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasCard) {
|
||||
const cardText = await decisionCard.textContent();
|
||||
expect(cardText).toContain("REST");
|
||||
}
|
||||
|
||||
// Clean up - disable flare override
|
||||
if (!wasChecked) {
|
||||
await flareCheckbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
test("sleep override forces GENTLE decision", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
|
||||
const hasOverrides = await overridesHeading
|
||||
.waitFor({ timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasOverrides) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find poor sleep checkbox
|
||||
const sleepCheckbox = page.getByRole("checkbox", { name: /poor sleep/i });
|
||||
const hasSleep = await sleepCheckbox.isVisible().catch(() => false);
|
||||
|
||||
if (!hasSleep) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable sleep override
|
||||
const wasChecked = await sleepCheckbox.isChecked();
|
||||
if (!wasChecked) {
|
||||
await sleepCheckbox.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Decision should now show GENTLE (unless flare/stress are also active)
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasCard) {
|
||||
const cardText = await decisionCard.textContent();
|
||||
// Sleep forces GENTLE, but flare/stress would override to REST
|
||||
expect(cardText?.includes("GENTLE") || cardText?.includes("REST")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (!wasChecked) {
|
||||
await sleepCheckbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
test("multiple overrides respect priority", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
|
||||
const hasOverrides = await overridesHeading
|
||||
.waitFor({ timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasOverrides) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
|
||||
const sleepCheckbox = page.getByRole("checkbox", { name: /poor sleep/i });
|
||||
|
||||
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
|
||||
const hasSleep = await sleepCheckbox.isVisible().catch(() => false);
|
||||
|
||||
if (!hasFlare || !hasSleep) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Record initial states
|
||||
const flareWasChecked = await flareCheckbox.isChecked();
|
||||
const sleepWasChecked = await sleepCheckbox.isChecked();
|
||||
|
||||
// Enable both flare (REST) and sleep (GENTLE)
|
||||
if (!flareWasChecked) await flareCheckbox.click();
|
||||
if (!sleepWasChecked) await sleepCheckbox.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Flare has higher priority, so should show REST
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (hasCard) {
|
||||
const cardText = await decisionCard.textContent();
|
||||
expect(cardText).toContain("REST"); // flare > sleep
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (!flareWasChecked) await flareCheckbox.click();
|
||||
if (!sleepWasChecked) await sleepCheckbox.click();
|
||||
});
|
||||
|
||||
test("disabling override restores original decision", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const overridesHeading = page.getByRole("heading", { name: "OVERRIDES" });
|
||||
const hasOverrides = await overridesHeading
|
||||
.waitFor({ timeout: 10000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasOverrides) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const flareCheckbox = page.getByRole("checkbox", { name: /flare mode/i });
|
||||
const hasFlare = await flareCheckbox.isVisible().catch(() => false);
|
||||
|
||||
if (!hasFlare) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Record initial decision
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
const hasCard = await decisionCard.isVisible().catch(() => false);
|
||||
|
||||
if (!hasCard) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
const initialDecision = await decisionCard.textContent();
|
||||
const flareWasChecked = await flareCheckbox.isChecked();
|
||||
|
||||
// Toggle flare on (if not already)
|
||||
if (!flareWasChecked) {
|
||||
// Wait for both API calls when clicking the checkbox
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/overrides"),
|
||||
page.waitForResponse("**/api/today"),
|
||||
flareCheckbox.click(),
|
||||
]);
|
||||
|
||||
// Should now be REST (flare mode forces rest)
|
||||
const restDecision = await decisionCard.textContent();
|
||||
expect(restDecision).toContain("REST");
|
||||
|
||||
// Toggle flare off and wait for API calls
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/overrides"),
|
||||
page.waitForResponse("**/api/today"),
|
||||
flareCheckbox.click(),
|
||||
]);
|
||||
|
||||
// Should return to original (or close to it)
|
||||
const restoredDecision = await decisionCard.textContent();
|
||||
// The exact decision may vary based on time, but it should change from REST
|
||||
expect(
|
||||
restoredDecision !== restDecision ||
|
||||
initialDecision?.includes("REST"),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
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";
|
||||
551
e2e/garmin.spec.ts
Normal file
551
e2e/garmin.spec.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
// ABOUTME: E2E tests for Garmin token connection flow.
|
||||
// ABOUTME: Tests saving tokens, verifying connection status, and disconnection.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("garmin connection", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("redirects to login when not authenticated", async ({ page }) => {
|
||||
await page.goto("/settings/garmin");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("authenticated", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login via the login page
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEmailForm) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailInput.fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
// Wait for redirect to dashboard, then navigate to garmin settings
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
await page.goto("/settings/garmin");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Clean up: Disconnect if already connected to ensure clean state
|
||||
const disconnectButton = page.getByRole("button", {
|
||||
name: /disconnect/i,
|
||||
});
|
||||
const isConnected = await disconnectButton.isVisible().catch(() => false);
|
||||
|
||||
if (isConnected) {
|
||||
await disconnectButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
// Wait for disconnect to complete
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
});
|
||||
|
||||
test("shows not connected initially for new user", async ({ page }) => {
|
||||
// Verify initial state shows "Not Connected"
|
||||
const notConnectedText = page.getByText(/not connected/i);
|
||||
await expect(notConnectedText).toBeVisible();
|
||||
|
||||
// Token input should be visible
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await expect(tokenInput).toBeVisible();
|
||||
|
||||
// Save button should be visible
|
||||
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||
await expect(saveButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("can save valid tokens and become connected", async ({ page }) => {
|
||||
// Verify initial state shows "Not Connected"
|
||||
await expect(page.getByText(/not connected/i)).toBeVisible();
|
||||
|
||||
// Create valid token JSON - expires 90 days from now
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-oauth1-token", secret: "test-oauth1-secret" },
|
||||
oauth2: { access_token: "test-oauth2-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
// Enter tokens in textarea
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
|
||||
// Click Save Tokens button
|
||||
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for success toast - sonner renders toasts with role="status"
|
||||
const successToast = page.getByText(/tokens saved successfully/i);
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify status changes to "Connected" with green indicator
|
||||
const connectedText = page.getByText("Connected", { exact: true });
|
||||
await expect(connectedText).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Green indicator should be visible (the circular badge)
|
||||
const greenIndicator = page.locator(".bg-green-500").first();
|
||||
await expect(greenIndicator).toBeVisible();
|
||||
|
||||
// Disconnect button should now be visible
|
||||
const disconnectButton = page.getByRole("button", {
|
||||
name: /disconnect/i,
|
||||
});
|
||||
await expect(disconnectButton).toBeVisible();
|
||||
|
||||
// Token input should be hidden when connected
|
||||
await expect(tokenInput).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("shows error toast for invalid JSON", async ({ page }) => {
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill("not valid json");
|
||||
|
||||
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/invalid json/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("shows error toast for missing required fields", async ({ page }) => {
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill('{"oauth1": {}}'); // Missing oauth2 and expires_at
|
||||
|
||||
const saveButton = page.getByRole("button", { name: /save tokens/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Error toast should appear for missing oauth2
|
||||
const errorToast = page.getByText(/oauth2 is required/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("can disconnect after connecting", async ({ page }) => {
|
||||
// First connect
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Click disconnect
|
||||
const disconnectButton = page.getByRole("button", {
|
||||
name: /disconnect/i,
|
||||
});
|
||||
await disconnectButton.click();
|
||||
|
||||
// Wait for disconnect success toast
|
||||
const successToast = page.getByText(/garmin disconnected/i);
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify status returns to "Not Connected"
|
||||
await expect(page.getByText(/not connected/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Token input should be visible again
|
||||
await expect(page.locator("#tokenInput")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows days until expiry when connected", async ({ page }) => {
|
||||
// Connect with tokens expiring in 45 days
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 45);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should show days until expiry (approximately 45 days)
|
||||
const expiryText = page.getByText(/\d+ days/i);
|
||||
await expect(expiryText).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows yellow warning banner when token expires in 10 days", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Connect with tokens expiring in 10 days (warning level: 8-14 days)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 10);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-warning", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should show warning banner with yellow styling
|
||||
const warningBanner = page.getByTestId("expiry-warning");
|
||||
await expect(warningBanner).toBeVisible();
|
||||
await expect(warningBanner).toContainText("Token expiring soon");
|
||||
|
||||
// Verify yellow warning styling (not red critical)
|
||||
await expect(warningBanner).toHaveClass(/bg-yellow/);
|
||||
});
|
||||
|
||||
test("shows red critical banner when token expires in 5 days", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Connect with tokens expiring in 5 days (critical level: <= 7 days)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 5);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-critical", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Should show critical banner with red styling
|
||||
const warningBanner = page.getByTestId("expiry-warning");
|
||||
await expect(warningBanner).toBeVisible();
|
||||
await expect(warningBanner).toContainText("Token expires soon!");
|
||||
|
||||
// Verify red critical styling
|
||||
await expect(warningBanner).toHaveClass(/bg-red/);
|
||||
});
|
||||
|
||||
test("shows expired state with token input when tokens have expired", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Connect with tokens that expire yesterday (already expired)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() - 1);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-expired", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should show expired state
|
||||
const expiredText = page.getByText("Token Expired");
|
||||
await expect(expiredText).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Token input should be visible to allow re-entry
|
||||
await expect(page.locator("#tokenInput")).toBeVisible();
|
||||
|
||||
// Red indicator should be visible
|
||||
const redIndicator = page.locator(".bg-red-500").first();
|
||||
await expect(redIndicator).toBeVisible();
|
||||
});
|
||||
|
||||
test("connection persists after page reload", async ({ page }) => {
|
||||
// First connect with valid tokens
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 60);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-persist", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Should still show connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Green indicator should still be visible
|
||||
const greenIndicator = page.locator(".bg-green-500").first();
|
||||
await expect(greenIndicator).toBeVisible();
|
||||
});
|
||||
|
||||
test("can reconnect after disconnecting", async ({ page }) => {
|
||||
// First connect
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-reconnect", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
await page.getByRole("button", { name: /disconnect/i }).click();
|
||||
await expect(page.getByText(/not connected/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reconnect with new tokens
|
||||
const newExpiresAt = new Date();
|
||||
newExpiresAt.setDate(newExpiresAt.getDate() + 90);
|
||||
|
||||
const newTokens = JSON.stringify({
|
||||
oauth1: {
|
||||
token: "test-token-reconnect-new",
|
||||
secret: "test-secret-new",
|
||||
},
|
||||
oauth2: { access_token: "test-access-token-new" },
|
||||
expires_at: newExpiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const newTokenInput = page.locator("#tokenInput");
|
||||
await newTokenInput.fill(newTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for reconnection
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Green indicator should be visible
|
||||
const greenIndicator = page.locator(".bg-green-500").first();
|
||||
await expect(greenIndicator).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows error toast when network fails during token save", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Intercept the POST request and simulate network failure
|
||||
await page.route("**/api/garmin/tokens", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Internal server error" }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Enter valid tokens
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-network", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/internal server error/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Token input should still be visible for retry (this is the key behavior)
|
||||
await expect(tokenInput).toBeVisible();
|
||||
|
||||
// Should NOT show success - either "Not Connected" or "Token Expired" state
|
||||
// (depends on prior test state), but definitely not "Connected" without expiry
|
||||
const connectedWithoutExpiry =
|
||||
(await page.getByText("Connected", { exact: true }).isVisible()) &&
|
||||
!(await page.getByText(/token expired/i).isVisible());
|
||||
expect(connectedWithoutExpiry).toBe(false);
|
||||
});
|
||||
|
||||
test("shows error toast when network fails during disconnect", async ({
|
||||
page,
|
||||
}) => {
|
||||
// First connect successfully
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-disconnect-error", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Wait for connected state
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Now intercept DELETE request to simulate network failure
|
||||
await page.route("**/api/garmin/tokens", (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Failed to disconnect" }),
|
||||
});
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Click disconnect
|
||||
await page.getByRole("button", { name: /disconnect/i }).click();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/failed to disconnect/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should still show connected state (disconnect failed)
|
||||
await expect(page.getByText("Connected", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows error state when status fetch fails", async ({ page }) => {
|
||||
// Intercept status fetch to simulate network failure
|
||||
await page.route("**/api/garmin/status", (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Service unavailable" }),
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to garmin settings (need to re-navigate to trigger fresh fetch)
|
||||
await page.goto("/settings/garmin");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Error alert should be visible (use specific text to avoid matching route announcer)
|
||||
const errorAlert = page.getByText("Service unavailable");
|
||||
await expect(errorAlert).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Error toast should also appear
|
||||
const errorToast = page.getByText(/unable to fetch data/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("can retry and succeed after network failure", async ({ page }) => {
|
||||
let requestCount = 0;
|
||||
|
||||
// First request fails, subsequent requests succeed
|
||||
await page.route("**/api/garmin/tokens", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
requestCount++;
|
||||
if (requestCount === 1) {
|
||||
// First attempt fails
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
body: JSON.stringify({ error: "Temporary failure" }),
|
||||
});
|
||||
} else {
|
||||
// Subsequent attempts succeed
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ success: true }),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const validTokens = JSON.stringify({
|
||||
oauth1: { token: "test-token-retry", secret: "test-secret" },
|
||||
oauth2: { access_token: "test-access-token" },
|
||||
expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const tokenInput = page.locator("#tokenInput");
|
||||
await tokenInput.fill(validTokens);
|
||||
|
||||
// First attempt - should fail
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Error toast should appear
|
||||
const errorToast = page.getByText(/temporary failure/i);
|
||||
await expect(errorToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Wait for toast to disappear or proceed with retry
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Retry - should succeed now
|
||||
await page.getByRole("button", { name: /save tokens/i }).click();
|
||||
|
||||
// Success toast should appear
|
||||
const successToast = page.getByText(/tokens saved successfully/i);
|
||||
await expect(successToast).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// ABOUTME: Runs before all e2e tests to provide a fresh database with test data.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { DEFAULT_CONFIG, start } from "./pocketbase-harness";
|
||||
import { DEFAULT_CONFIG, start, TEST_USERS } from "./pocketbase-harness";
|
||||
|
||||
const STATE_FILE = path.join(__dirname, ".harness-state.json");
|
||||
|
||||
@@ -24,9 +24,27 @@ export default async function globalSetup(): Promise<void> {
|
||||
// Set environment variables for the test process
|
||||
process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url;
|
||||
process.env.POCKETBASE_URL = state.url;
|
||||
process.env.TEST_USER_EMAIL = DEFAULT_CONFIG.testUserEmail;
|
||||
process.env.TEST_USER_PASSWORD = DEFAULT_CONFIG.testUserPassword;
|
||||
|
||||
// Export credentials for each test user type
|
||||
process.env.TEST_USER_ONBOARDING_EMAIL = TEST_USERS.onboarding.email;
|
||||
process.env.TEST_USER_ONBOARDING_PASSWORD = TEST_USERS.onboarding.password;
|
||||
process.env.TEST_USER_ESTABLISHED_EMAIL = TEST_USERS.established.email;
|
||||
process.env.TEST_USER_ESTABLISHED_PASSWORD = TEST_USERS.established.password;
|
||||
process.env.TEST_USER_CALENDAR_EMAIL = TEST_USERS.calendar.email;
|
||||
process.env.TEST_USER_CALENDAR_PASSWORD = TEST_USERS.calendar.password;
|
||||
process.env.TEST_USER_GARMIN_EMAIL = TEST_USERS.garmin.email;
|
||||
process.env.TEST_USER_GARMIN_PASSWORD = TEST_USERS.garmin.password;
|
||||
process.env.TEST_USER_GARMIN_EXPIRED_EMAIL = TEST_USERS.garminExpired.email;
|
||||
process.env.TEST_USER_GARMIN_EXPIRED_PASSWORD =
|
||||
TEST_USERS.garminExpired.password;
|
||||
|
||||
// Keep backward compatibility - default to established user
|
||||
process.env.TEST_USER_EMAIL = TEST_USERS.established.email;
|
||||
process.env.TEST_USER_PASSWORD = TEST_USERS.established.password;
|
||||
|
||||
console.log(`PocketBase running at ${state.url}`);
|
||||
console.log(`Test user: ${DEFAULT_CONFIG.testUserEmail}`);
|
||||
console.log("Test users created:");
|
||||
for (const [preset, user] of Object.entries(TEST_USERS)) {
|
||||
console.log(` ${preset}: ${user.email}`);
|
||||
}
|
||||
}
|
||||
|
||||
49
e2e/health.spec.ts
Normal file
49
e2e/health.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// ABOUTME: E2E tests for health and observability endpoints.
|
||||
// ABOUTME: Tests health check endpoint response and performance.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("health and observability", () => {
|
||||
test.describe("health endpoint", () => {
|
||||
test("health endpoint returns 200 when healthy", async ({ request }) => {
|
||||
const response = await request.get("/api/health");
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body).toHaveProperty("timestamp");
|
||||
expect(body).toHaveProperty("version");
|
||||
});
|
||||
|
||||
test("health endpoint responds quickly", async ({ request }) => {
|
||||
const startTime = Date.now();
|
||||
const response = await request.get("/api/health");
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
// E2E includes network latency; allow 500ms for full round-trip
|
||||
// (the handler itself executes in <100ms per spec)
|
||||
expect(endTime - startTime).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("metrics endpoint", () => {
|
||||
test("metrics endpoint is accessible and returns Prometheus format", async ({
|
||||
request,
|
||||
}) => {
|
||||
const response = await request.get("/api/metrics");
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const contentType = response.headers()["content-type"];
|
||||
expect(contentType).toContain("text/plain");
|
||||
|
||||
const body = await response.text();
|
||||
// Prometheus format should contain HELP and TYPE comments
|
||||
expect(body).toMatch(/^# HELP/m);
|
||||
expect(body).toMatch(/^# TYPE/m);
|
||||
// Should contain our custom metrics
|
||||
expect(body).toContain("phaseflow_");
|
||||
});
|
||||
});
|
||||
});
|
||||
154
e2e/history.spec.ts
Normal file
154
e2e/history.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// ABOUTME: E2E tests for the history page showing past training decisions.
|
||||
// ABOUTME: Tests table display, pagination, date filtering, and empty states.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("history page", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("redirects to login when not authenticated", async ({ page }) => {
|
||||
await page.goto("/history");
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("authenticated", () => {
|
||||
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login via the login page
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEmailForm) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailInput.fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
// Wait for redirect to dashboard then navigate to history
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
await page.goto("/history");
|
||||
});
|
||||
|
||||
test("displays history page with title", async ({ page }) => {
|
||||
// Check for history page title
|
||||
const heading = page.getByRole("heading", { name: "History" });
|
||||
await expect(heading).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows date filter controls", async ({ page }) => {
|
||||
// Check for date filter inputs
|
||||
const startDateInput = page.getByLabel(/start date/i);
|
||||
const endDateInput = page.getByLabel(/end date/i);
|
||||
|
||||
await expect(startDateInput).toBeVisible();
|
||||
await expect(endDateInput).toBeVisible();
|
||||
|
||||
// Check for Apply and Clear buttons
|
||||
const applyButton = page.getByRole("button", { name: /apply/i });
|
||||
const clearButton = page.getByRole("button", { name: /clear/i });
|
||||
|
||||
await expect(applyButton).toBeVisible();
|
||||
await expect(clearButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows table with correct columns when data exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Wait for data to load
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check if there's data or empty state
|
||||
const table = page.locator("table");
|
||||
const emptyState = page.getByText(/no history found/i);
|
||||
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
const hasEmptyState = await emptyState.isVisible().catch(() => false);
|
||||
|
||||
if (hasTable) {
|
||||
// Verify table headers exist
|
||||
const headers = page.locator("thead th");
|
||||
await expect(headers).toHaveCount(6);
|
||||
|
||||
// Check for specific column headers
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /date/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /day.*phase/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /decision/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /body battery/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /hrv/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("columnheader", { name: /intensity/i }),
|
||||
).toBeVisible();
|
||||
} else if (hasEmptyState) {
|
||||
// Empty state is valid when no history data
|
||||
await expect(emptyState).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows empty state when no data", async ({ page }) => {
|
||||
// This test verifies empty state UI is present when applicable
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emptyState = page.getByText(/no history found/i);
|
||||
const table = page.locator("table tbody tr");
|
||||
|
||||
const hasRows = await table
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const hasEmptyState = await emptyState.isVisible().catch(() => false);
|
||||
|
||||
// Either has data rows OR shows empty state (both valid)
|
||||
expect(hasRows || hasEmptyState).toBe(true);
|
||||
});
|
||||
|
||||
test("has link back to dashboard", async ({ page }) => {
|
||||
const dashboardLink = page.getByRole("link", {
|
||||
name: /back to dashboard/i,
|
||||
});
|
||||
await expect(dashboardLink).toBeVisible();
|
||||
|
||||
// Click and verify navigation
|
||||
await dashboardLink.click();
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("shows entry count", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for entries count text (e.g., "5 entries")
|
||||
const entriesText = page.getByText(/\d+ entries/);
|
||||
const hasEntriesText = await entriesText.isVisible().catch(() => false);
|
||||
|
||||
// May not be visible if no data, check for either count or empty state
|
||||
const emptyState = page.getByText(/no history found/i);
|
||||
const hasEmptyState = await emptyState.isVisible().catch(() => false);
|
||||
|
||||
expect(hasEntriesText || hasEmptyState).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
223
e2e/mobile.spec.ts
Normal file
223
e2e/mobile.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// ABOUTME: E2E tests for mobile viewport behavior and responsive design.
|
||||
// ABOUTME: Tests that the dashboard displays correctly on mobile-sized screens.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Mobile viewport: iPhone SE/8 (375x667)
|
||||
const MOBILE_VIEWPORT = { width: 375, height: 667 };
|
||||
|
||||
test.describe("mobile viewport", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("login page renders correctly on mobile viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await page.goto("/login");
|
||||
|
||||
// Login form should be visible - heading is "PhaseFlow"
|
||||
const heading = page.getByRole("heading", { name: /phaseflow/i });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// Email input should be visible
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
await expect(emailInput).toBeVisible();
|
||||
|
||||
// Viewport width should be mobile
|
||||
const viewportSize = page.viewportSize();
|
||||
expect(viewportSize?.width).toBe(375);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("authenticated", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set mobile viewport before navigating
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
|
||||
// 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();
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("dashboard displays correctly on mobile viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Verify viewport is mobile size
|
||||
const viewportSize = page.viewportSize();
|
||||
expect(viewportSize?.width).toBe(375);
|
||||
|
||||
// Header should be visible
|
||||
const header = page.getByRole("heading", { name: /phaseflow/i });
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
// Settings link should be visible
|
||||
const settingsLink = page.getByRole("link", { name: /settings/i });
|
||||
await expect(settingsLink).toBeVisible();
|
||||
|
||||
// Decision card should be visible
|
||||
const decisionCard = page.locator('[data-testid="decision-card"]');
|
||||
await expect(decisionCard).toBeVisible();
|
||||
|
||||
// Data panel should be visible
|
||||
const dataPanel = page.getByText(/body battery|hrv/i).first();
|
||||
await expect(dataPanel).toBeVisible();
|
||||
});
|
||||
|
||||
test("dashboard uses single-column layout on mobile", async ({ page }) => {
|
||||
// On mobile (<768px), the Data Panel and Nutrition Panel should stack vertically
|
||||
// This is controlled by the md:grid-cols-2 class
|
||||
|
||||
// Find the grid container that holds Data Panel and Nutrition Panel
|
||||
// It should NOT have two-column grid on mobile (should be single column)
|
||||
const gridContainer = page.locator(".grid.gap-4").first();
|
||||
const containerExists = await gridContainer
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (containerExists) {
|
||||
// Get the computed grid template columns
|
||||
const gridTemplateColumns = await gridContainer.evaluate((el) => {
|
||||
return window.getComputedStyle(el).gridTemplateColumns;
|
||||
});
|
||||
|
||||
// On mobile (375px < 768px), should NOT be two columns
|
||||
// Single column would be "none" or a single value like "1fr"
|
||||
// Two columns would be something like "1fr 1fr" or "repeat(2, 1fr)"
|
||||
const isTwoColumn = gridTemplateColumns.includes(" ");
|
||||
expect(isTwoColumn).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("navigation elements are interactive on mobile", async ({ page }) => {
|
||||
// Settings link should be clickable
|
||||
const settingsLink = page.getByRole("link", { name: /settings/i });
|
||||
await expect(settingsLink).toBeVisible();
|
||||
|
||||
// Click settings and verify navigation
|
||||
await settingsLink.click();
|
||||
await expect(page).toHaveURL(/\/settings/);
|
||||
|
||||
// Back button should work to return to dashboard
|
||||
const backLink = page.getByRole("link", { name: /back|dashboard|home/i });
|
||||
await expect(backLink).toBeVisible();
|
||||
await backLink.click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("calendar page renders correctly on mobile viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to calendar
|
||||
await page.goto("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Verify viewport is still mobile size
|
||||
const viewportSize = page.viewportSize();
|
||||
expect(viewportSize?.width).toBe(375);
|
||||
|
||||
// Calendar page title heading should be visible (exact match to avoid "Calendar Subscription")
|
||||
const heading = page.getByRole("heading", {
|
||||
name: "Calendar",
|
||||
exact: true,
|
||||
});
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Calendar grid should be visible
|
||||
const calendarGrid = page
|
||||
.getByRole("grid")
|
||||
.or(page.locator('[data-testid="month-view"]'));
|
||||
await expect(calendarGrid).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Month navigation should be visible
|
||||
const monthYear = page.getByText(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
);
|
||||
await expect(monthYear.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("calendar day cells are touch-friendly on mobile", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to calendar
|
||||
await page.goto("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Get day buttons
|
||||
const dayButtons = page.locator("button[data-day]");
|
||||
const hasDayButtons = await dayButtons
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!hasDayButtons) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check that day buttons have reasonable tap target size
|
||||
// Per dashboard spec: "Touch-friendly 44x44px minimum tap targets"
|
||||
const firstDayButton = dayButtons.first();
|
||||
const boundingBox = await firstDayButton.boundingBox();
|
||||
|
||||
if (boundingBox) {
|
||||
// Width and height should be at least 32px for touch targets
|
||||
// (some flexibility since mobile displays may compress slightly)
|
||||
expect(boundingBox.width).toBeGreaterThanOrEqual(32);
|
||||
expect(boundingBox.height).toBeGreaterThanOrEqual(32);
|
||||
}
|
||||
});
|
||||
|
||||
test("calendar navigation works on mobile", async ({ page }) => {
|
||||
// Navigate to calendar
|
||||
await page.goto("/calendar");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Find and click next month button
|
||||
const nextButton = page.getByRole("button", {
|
||||
name: /next|→|forward/i,
|
||||
});
|
||||
const hasNext = await nextButton.isVisible().catch(() => false);
|
||||
|
||||
if (hasNext) {
|
||||
// Click next
|
||||
await nextButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Calendar should still be functional after navigation
|
||||
const calendarGrid = page
|
||||
.getByRole("grid")
|
||||
.or(page.locator('[data-testid="month-view"]'));
|
||||
await expect(calendarGrid).toBeVisible();
|
||||
|
||||
// Month display should still be visible
|
||||
const monthYear = page.getByText(
|
||||
/january|february|march|april|may|june|july|august|september|october|november|december/i,
|
||||
);
|
||||
await expect(monthYear.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,143 +1,31 @@
|
||||
// ABOUTME: E2E tests for period logging functionality.
|
||||
// ABOUTME: Tests period start logging, date selection, and period history.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("period logging", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("period history page redirects to login when not authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/period-history");
|
||||
import { test as baseTest } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
// Should redirect to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
baseTest.describe("period logging", () => {
|
||||
baseTest.describe("unauthenticated", () => {
|
||||
baseTest(
|
||||
"period history page redirects to login when not authenticated",
|
||||
async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
|
||||
// Should redirect to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("authenticated", () => {
|
||||
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login via the login page
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEmailForm) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailInput.fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
});
|
||||
|
||||
test("dashboard shows period date prompt for new users", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Check if onboarding banner for period date is visible
|
||||
// This depends on whether the test user has period data set
|
||||
const onboardingBanner = page.getByText(
|
||||
/period|log your period|set.*date/i,
|
||||
);
|
||||
const hasOnboarding = await onboardingBanner
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Either has onboarding prompt or has cycle data - both are valid states
|
||||
if (hasOnboarding) {
|
||||
await expect(onboardingBanner.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("period history page is accessible", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
|
||||
// Should show period history content
|
||||
await expect(page.getByRole("heading")).toBeVisible();
|
||||
});
|
||||
|
||||
test("period history shows table or empty state", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
|
||||
// Wait for loading to complete
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Look for either table or empty state message
|
||||
const table = page.getByRole("table");
|
||||
const emptyState = page.getByText("No period history found");
|
||||
const totalText = page.getByText(/\d+ periods/);
|
||||
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
const hasEmpty = await emptyState.isVisible().catch(() => false);
|
||||
const hasTotal = await totalText.isVisible().catch(() => false);
|
||||
|
||||
// Either table, empty state, or total count should be present
|
||||
expect(hasTable || hasEmpty || hasTotal).toBe(true);
|
||||
});
|
||||
|
||||
test("period history shows average cycle length if data exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/period-history");
|
||||
|
||||
// Average cycle length is shown when there's enough data
|
||||
const avgText = page.getByText(/average.*cycle|cycle.*average|avg/i);
|
||||
const hasAvg = await avgText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// This is optional - depends on having data
|
||||
if (hasAvg) {
|
||||
await expect(avgText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("period history shows back navigation", async ({ page }) => {
|
||||
await page.goto("/period-history");
|
||||
|
||||
// Look for back link
|
||||
const backLink = page.getByRole("link", { name: /back|dashboard|home/i });
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to period history from dashboard", async ({ page }) => {
|
||||
// Look for navigation to period history
|
||||
const periodHistoryLink = page.getByRole("link", {
|
||||
name: /period.*history|history/i,
|
||||
});
|
||||
const hasLink = await periodHistoryLink.isVisible().catch(() => false);
|
||||
|
||||
if (hasLink) {
|
||||
await periodHistoryLink.click();
|
||||
await expect(page).toHaveURL(/\/period-history/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("API endpoints", () => {
|
||||
test("period history API requires authentication", async ({ page }) => {
|
||||
baseTest.describe("API endpoints", () => {
|
||||
baseTest("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 }) => {
|
||||
baseTest("period log API requires authentication", async ({ page }) => {
|
||||
const response = await page.request.post("/api/cycle/period", {
|
||||
data: { startDate: "2024-01-15" },
|
||||
});
|
||||
@@ -147,3 +35,453 @@ test.describe("period logging", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging authenticated", () => {
|
||||
test("dashboard shows period date prompt for new users", async ({
|
||||
onboardingPage,
|
||||
}) => {
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Onboarding user has no period data, should see onboarding banner
|
||||
const onboardingBanner = onboardingPage.getByText(
|
||||
/period|log your period|set.*date/i,
|
||||
);
|
||||
await expect(onboardingBanner.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("period history page is accessible", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Should show period history content
|
||||
await expect(establishedPage.getByRole("heading")).toBeVisible();
|
||||
});
|
||||
|
||||
test("period history shows table or empty state", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Wait for loading to complete
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for either table or empty state message
|
||||
const table = establishedPage.getByRole("table");
|
||||
const emptyState = establishedPage.getByText("No period history found");
|
||||
const totalText = establishedPage.getByText(/\d+ periods/);
|
||||
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
const hasEmpty = await emptyState.isVisible().catch(() => false);
|
||||
const hasTotal = await totalText.isVisible().catch(() => false);
|
||||
|
||||
// Either table, empty state, or total count should be present
|
||||
expect(hasTable || hasEmpty || hasTotal).toBe(true);
|
||||
});
|
||||
|
||||
test("period history shows average cycle length if data exists", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Average cycle length is shown when there's enough data
|
||||
const avgText = establishedPage.getByText(
|
||||
/average.*cycle|cycle.*average|avg/i,
|
||||
);
|
||||
const hasAvg = await avgText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// This is optional - depends on having data
|
||||
if (hasAvg) {
|
||||
await expect(avgText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("period history shows back navigation", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
|
||||
// Look for back link
|
||||
const backLink = establishedPage.getByRole("link", {
|
||||
name: /back|dashboard|home/i,
|
||||
});
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to period history from dashboard", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
// Look for navigation to period history
|
||||
const periodHistoryLink = establishedPage.getByRole("link", {
|
||||
name: /period.*history|history/i,
|
||||
});
|
||||
const hasLink = await periodHistoryLink.isVisible().catch(() => false);
|
||||
|
||||
if (hasLink) {
|
||||
await periodHistoryLink.click();
|
||||
await expect(establishedPage).toHaveURL(/\/period-history/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging flow - onboarding user", () => {
|
||||
test("period date modal opens from dashboard onboarding banner", async ({
|
||||
onboardingPage,
|
||||
}) => {
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Onboarding user should see "Set date" button
|
||||
const setDateButton = onboardingPage.getByRole("button", {
|
||||
name: /set date/i,
|
||||
});
|
||||
await expect(setDateButton).toBeVisible();
|
||||
|
||||
// Click the set date button
|
||||
await setDateButton.click();
|
||||
|
||||
// Modal should open with "Set Period Date" title
|
||||
const modalTitle = onboardingPage.getByRole("heading", {
|
||||
name: /set period date/i,
|
||||
});
|
||||
await expect(modalTitle).toBeVisible();
|
||||
|
||||
// Should have a date input
|
||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||
await expect(dateInput).toBeVisible();
|
||||
|
||||
// Should have Cancel and Save buttons
|
||||
await expect(
|
||||
onboardingPage.getByRole("button", { name: /cancel/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
onboardingPage.getByRole("button", { name: /save/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Cancel should close the modal
|
||||
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(modalTitle).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("period date input restricts future dates", async ({
|
||||
onboardingPage,
|
||||
}) => {
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Open the modal
|
||||
const setDateButton = onboardingPage.getByRole("button", {
|
||||
name: /set date/i,
|
||||
});
|
||||
await setDateButton.click();
|
||||
|
||||
// Get the date input and check its max attribute
|
||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||
await expect(dateInput).toBeVisible();
|
||||
|
||||
// The max attribute should be set to today's date (YYYY-MM-DD format)
|
||||
const maxValue = await dateInput.getAttribute("max");
|
||||
expect(maxValue).toBeTruthy();
|
||||
|
||||
// Parse max date and verify it's today or earlier
|
||||
const maxDate = new Date(maxValue as string);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
maxDate.setHours(0, 0, 0, 0);
|
||||
|
||||
expect(maxDate.getTime()).toBeLessThanOrEqual(today.getTime());
|
||||
|
||||
// Close modal
|
||||
await onboardingPage.getByRole("button", { name: /cancel/i }).click();
|
||||
});
|
||||
|
||||
test("logging period from modal updates dashboard cycle info", async ({
|
||||
onboardingPage,
|
||||
}) => {
|
||||
await onboardingPage.goto("/");
|
||||
|
||||
// Click the set date button
|
||||
const setDateButton = onboardingPage.getByRole("button", {
|
||||
name: /set date/i,
|
||||
});
|
||||
await setDateButton.click();
|
||||
|
||||
// Wait for modal to be visible
|
||||
const modalTitle = onboardingPage.getByRole("heading", {
|
||||
name: /set period date/i,
|
||||
});
|
||||
await expect(modalTitle).toBeVisible();
|
||||
|
||||
// Calculate a valid date (7 days ago)
|
||||
const testDate = new Date();
|
||||
testDate.setDate(testDate.getDate() - 7);
|
||||
const dateStr = testDate.toISOString().split("T")[0];
|
||||
|
||||
// Fill in the date
|
||||
const dateInput = onboardingPage.locator('input[type="date"]');
|
||||
await dateInput.fill(dateStr);
|
||||
|
||||
// Click Save button
|
||||
await onboardingPage.getByRole("button", { name: /save/i }).click();
|
||||
|
||||
// Modal should close after successful save
|
||||
await expect(modalTitle).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Wait for network activity to settle
|
||||
await onboardingPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for cycle day display (e.g., "Day 8 · Follicular" or similar)
|
||||
// The page fetches /api/cycle/period, then /api/today and /api/user
|
||||
// Content only renders when both todayData and userData are available
|
||||
// Use .first() as the pattern may match multiple elements on the page
|
||||
const cycleInfo = onboardingPage.getByText(/day\s+\d+\s+·/i).first();
|
||||
await expect(cycleInfo).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("period logging flow - established user", () => {
|
||||
test("period date cannot be in the future", async ({ establishedPage }) => {
|
||||
// Navigate to period history
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for an "Add Period" or "Log Period" button
|
||||
const addButton = establishedPage.getByRole("button", {
|
||||
name: /add.*period|log.*period|new.*period/i,
|
||||
});
|
||||
const hasAddButton = await addButton.isVisible().catch(() => false);
|
||||
|
||||
if (!hasAddButton) {
|
||||
// Established user may have an edit button instead - also valid
|
||||
const editButton = establishedPage.getByRole("button", { name: /edit/i });
|
||||
const hasEdit = await editButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasEdit).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("period history displays cycle length between periods", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for cycle length column or text
|
||||
const cycleLengthText = establishedPage.getByText(
|
||||
/cycle.*length|\d+\s*days/i,
|
||||
);
|
||||
const hasCycleLength = await cycleLengthText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// If there's period data, cycle length should be visible
|
||||
const table = establishedPage.getByRole("table");
|
||||
const hasTable = await table.isVisible().catch(() => false);
|
||||
|
||||
if (hasTable) {
|
||||
// Table has header for cycle length
|
||||
const header = establishedPage.getByRole("columnheader", {
|
||||
name: /cycle.*length|days/i,
|
||||
});
|
||||
const hasHeader = await header.isVisible().catch(() => false);
|
||||
expect(hasHeader || hasCycleLength).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("period history shows prediction accuracy when available", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for prediction-related text (early/late, accuracy)
|
||||
const predictionText = establishedPage.getByText(
|
||||
/early|late|accuracy|predicted/i,
|
||||
);
|
||||
const hasPrediction = await predictionText
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
// Prediction info may not be visible if not enough data
|
||||
if (hasPrediction) {
|
||||
await expect(predictionText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can delete period log from history", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for delete button
|
||||
const deleteButton = establishedPage.getByRole("button", {
|
||||
name: /delete/i,
|
||||
});
|
||||
const hasDelete = await deleteButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasDelete) {
|
||||
// Delete button exists for period entries
|
||||
await expect(deleteButton.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("can edit period log from history", async ({ establishedPage }) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for edit button
|
||||
const editButton = establishedPage.getByRole("button", { name: /edit/i });
|
||||
const hasEdit = await editButton
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasEdit) {
|
||||
// Edit button exists for period entries
|
||||
await expect(editButton.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("edit period modal flow changes date successfully", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for edit button and table to ensure we have data
|
||||
const editButton = establishedPage
|
||||
.getByRole("button", { name: /edit/i })
|
||||
.first();
|
||||
await expect(editButton).toBeVisible();
|
||||
|
||||
// Get the original date from the first row
|
||||
const firstRow = establishedPage.locator("tbody tr").first();
|
||||
const originalDateCell = firstRow.locator("td").first();
|
||||
const originalDateText = await originalDateCell.textContent();
|
||||
|
||||
// Click edit button
|
||||
await editButton.click();
|
||||
|
||||
// Edit modal should appear
|
||||
const editModalTitle = establishedPage.getByRole("heading", {
|
||||
name: /edit period date/i,
|
||||
});
|
||||
await expect(editModalTitle).toBeVisible();
|
||||
|
||||
// Get the date input in the edit modal
|
||||
const editDateInput = establishedPage.locator("#editDate");
|
||||
await expect(editDateInput).toBeVisible();
|
||||
|
||||
// Calculate a new date (21 days ago to avoid conflicts)
|
||||
const newDate = new Date();
|
||||
newDate.setDate(newDate.getDate() - 21);
|
||||
const newDateStr = newDate.toISOString().split("T")[0];
|
||||
|
||||
// Clear and fill new date
|
||||
await editDateInput.fill(newDateStr);
|
||||
|
||||
// Click Save in the edit modal
|
||||
await establishedPage.getByRole("button", { name: /save/i }).click();
|
||||
|
||||
// Modal should close
|
||||
await expect(editModalTitle).not.toBeVisible();
|
||||
|
||||
// Wait for table to refresh
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Verify the date changed (the row should have new date text)
|
||||
const updatedDateCell = establishedPage
|
||||
.locator("tbody tr")
|
||||
.first()
|
||||
.locator("td")
|
||||
.first();
|
||||
const updatedDateText = await updatedDateCell.textContent();
|
||||
|
||||
// If we had original data, verify it changed
|
||||
if (originalDateText) {
|
||||
// Format the new date to match display format (e.g., "Jan 1, 2024")
|
||||
const formattedNewDate = newDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
expect(updatedDateText).toContain(
|
||||
formattedNewDate.split(",")[0].split(" ")[0],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("delete period confirmation flow removes entry", async ({
|
||||
establishedPage,
|
||||
}) => {
|
||||
await establishedPage.goto("/period-history");
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// Look for delete button
|
||||
const deleteButton = establishedPage
|
||||
.getByRole("button", { name: /delete/i })
|
||||
.first();
|
||||
await expect(deleteButton).toBeVisible();
|
||||
|
||||
// Get the total count text before deletion
|
||||
const totalText = establishedPage.getByText(/\d+ periods/);
|
||||
const hasTotal = await totalText.isVisible().catch(() => false);
|
||||
let originalCount = 0;
|
||||
if (hasTotal) {
|
||||
const countMatch = (await totalText.textContent())?.match(
|
||||
/(\d+) periods/,
|
||||
);
|
||||
if (countMatch) {
|
||||
originalCount = parseInt(countMatch[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Click delete button
|
||||
await deleteButton.click();
|
||||
|
||||
// Confirmation modal should appear
|
||||
const confirmModalTitle = establishedPage.getByRole("heading", {
|
||||
name: /delete period/i,
|
||||
});
|
||||
await expect(confirmModalTitle).toBeVisible();
|
||||
|
||||
// Should show warning message
|
||||
const warningText = establishedPage.getByText(/are you sure.*delete/i);
|
||||
await expect(warningText).toBeVisible();
|
||||
|
||||
// Should have Cancel and Confirm buttons
|
||||
await expect(
|
||||
establishedPage.getByRole("button", { name: /cancel/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
establishedPage.getByRole("button", { name: /confirm/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Click Confirm to delete
|
||||
await establishedPage.getByRole("button", { name: /confirm/i }).click();
|
||||
|
||||
// Modal should close
|
||||
await expect(confirmModalTitle).not.toBeVisible();
|
||||
|
||||
// Wait for page to refresh
|
||||
await establishedPage.waitForLoadState("networkidle");
|
||||
|
||||
// If we had a count, verify it decreased
|
||||
if (originalCount > 1) {
|
||||
const newTotalText = establishedPage.getByText(/\d+ periods/);
|
||||
const newTotalVisible = await newTotalText.isVisible().catch(() => false);
|
||||
if (newTotalVisible) {
|
||||
const newCountMatch = (await newTotalText.textContent())?.match(
|
||||
/(\d+) periods/,
|
||||
);
|
||||
if (newCountMatch) {
|
||||
const newCount = parseInt(newCountMatch[1], 10);
|
||||
expect(newCount).toBe(originalCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
166
e2e/plan.spec.ts
Normal file
166
e2e/plan.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// ABOUTME: E2E tests for the exercise plan reference page.
|
||||
// ABOUTME: Tests phase display, training guidelines, and current status.
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("plan page", () => {
|
||||
test.describe("unauthenticated", () => {
|
||||
test("redirects to login when not authenticated", async ({ page }) => {
|
||||
await page.goto("/plan");
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("authenticated", () => {
|
||||
// These tests require TEST_USER_EMAIL and TEST_USER_PASSWORD env vars
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const email = process.env.TEST_USER_EMAIL;
|
||||
const password = process.env.TEST_USER_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Login via the login page
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const emailInput = page.getByLabel(/email/i);
|
||||
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||
|
||||
if (!hasEmailForm) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
await emailInput.fill(email);
|
||||
await page.getByLabel(/password/i).fill(password);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
|
||||
// Wait for redirect to dashboard then navigate to plan
|
||||
await page.waitForURL("/", { timeout: 10000 });
|
||||
await page.goto("/plan");
|
||||
});
|
||||
|
||||
test("displays exercise plan page with title", async ({ page }) => {
|
||||
// Check for plan page title
|
||||
const heading = page.getByRole("heading", { name: "Exercise Plan" });
|
||||
await expect(heading).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows current cycle status section", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Wait for page to finish loading - look for Current Status or error state
|
||||
const statusSection = page.getByRole("heading", {
|
||||
name: "Current Status",
|
||||
});
|
||||
// Use text content to find error alert (avoid Next.js route announcer)
|
||||
const errorAlert = page.getByText(/error:/i);
|
||||
|
||||
try {
|
||||
// Wait for Current Status section to be visible (data loaded successfully)
|
||||
await expect(statusSection).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should show day number
|
||||
await expect(page.getByText(/day \d+/i)).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should show training type
|
||||
await expect(page.getByText(/training type:/i)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// Should show weekly limit
|
||||
await expect(page.getByText(/weekly limit:/i)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// If status section not visible, check for error alert
|
||||
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("shows all 5 phase cards", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check for Phase Overview section
|
||||
const phaseOverview = page.getByRole("heading", {
|
||||
name: "Phase Overview",
|
||||
});
|
||||
const hasPhaseOverview = await phaseOverview
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasPhaseOverview) {
|
||||
// Should show all 5 phase cards using data-testid
|
||||
await expect(page.getByTestId("phase-MENSTRUAL")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-FOLLICULAR")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-OVULATION")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-EARLY_LUTEAL")).toBeVisible();
|
||||
await expect(page.getByTestId("phase-LATE_LUTEAL")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows strength training reference table", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check for Strength Training section
|
||||
const strengthSection = page.getByRole("heading", {
|
||||
name: /strength training/i,
|
||||
});
|
||||
const hasStrength = await strengthSection.isVisible().catch(() => false);
|
||||
|
||||
if (hasStrength) {
|
||||
// Should have exercise table
|
||||
const table = page.locator("table");
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// Check for some exercises
|
||||
await expect(page.getByText("Squats")).toBeVisible();
|
||||
await expect(page.getByText("Push-ups")).toBeVisible();
|
||||
await expect(page.getByText("Plank")).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows rebounding techniques", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check for Rebounding Techniques section
|
||||
const reboundingSection = page.getByRole("heading", {
|
||||
name: /rebounding techniques/i,
|
||||
});
|
||||
const hasRebounding = await reboundingSection
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (hasRebounding) {
|
||||
// Should show techniques section - use first() for specific match
|
||||
await expect(
|
||||
page.getByText("Health bounce, lymphatic drainage"),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows weekly guidelines", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check for Weekly Guidelines section
|
||||
const weeklySection = page.getByRole("heading", {
|
||||
name: "Weekly Guidelines",
|
||||
});
|
||||
const hasWeekly = await weeklySection.isVisible().catch(() => false);
|
||||
|
||||
if (hasWeekly) {
|
||||
// Should show guidelines for each phase - use exact matches
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Menstrual Phase (Days 1-3)" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Follicular Phase (Days 4-14)" }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,59 @@
|
||||
// ABOUTME: PocketBase test harness for e2e tests - starts, configures, and stops PocketBase.
|
||||
// ABOUTME: Provides ephemeral PocketBase instances with test data for Playwright tests.
|
||||
|
||||
import { type ChildProcess, execSync, spawn } from "node:child_process";
|
||||
import { createCipheriv, randomBytes } from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import PocketBase from "pocketbase";
|
||||
import {
|
||||
addUserFields,
|
||||
createCollection,
|
||||
getExistingCollectionNames,
|
||||
getMissingCollections,
|
||||
setupApiRules,
|
||||
} from "../scripts/setup-db";
|
||||
|
||||
/**
|
||||
* Test user presets for different e2e test scenarios.
|
||||
*/
|
||||
export type TestUserPreset =
|
||||
| "onboarding"
|
||||
| "established"
|
||||
| "calendar"
|
||||
| "garmin"
|
||||
| "garminExpired";
|
||||
|
||||
/**
|
||||
* Configuration for each test user type.
|
||||
*/
|
||||
export const TEST_USERS: Record<
|
||||
TestUserPreset,
|
||||
{ email: string; password: string }
|
||||
> = {
|
||||
onboarding: {
|
||||
email: "e2e-onboarding@phaseflow.local",
|
||||
password: "e2e-onboarding-123",
|
||||
},
|
||||
established: {
|
||||
email: "e2e-test@phaseflow.local",
|
||||
password: "e2e-test-password-123",
|
||||
},
|
||||
calendar: {
|
||||
email: "e2e-calendar@phaseflow.local",
|
||||
password: "e2e-calendar-123",
|
||||
},
|
||||
garmin: {
|
||||
email: "e2e-garmin@phaseflow.local",
|
||||
password: "e2e-garmin-123",
|
||||
},
|
||||
garminExpired: {
|
||||
email: "e2e-garmin-expired@phaseflow.local",
|
||||
password: "e2e-garmin-expired-123",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for the test harness.
|
||||
*/
|
||||
@@ -83,82 +126,52 @@ async function waitForReady(url: string, timeoutMs = 30000): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the admin superuser using the PocketBase CLI.
|
||||
* Sleeps for the specified number of milliseconds.
|
||||
*/
|
||||
function createAdminUser(
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the admin superuser using the PocketBase CLI.
|
||||
* Retries on database lock errors since PocketBase may still be running migrations.
|
||||
*/
|
||||
async function createAdminUser(
|
||||
dataDir: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): void {
|
||||
execSync(
|
||||
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
}
|
||||
maxRetries = 5,
|
||||
): Promise<void> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
/**
|
||||
* Adds custom fields to the users collection.
|
||||
*/
|
||||
async function addUserFields(pb: PocketBase): Promise<void> {
|
||||
const usersCollection = await pb.collections.getOne("users");
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
execSync(
|
||||
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
|
||||
{
|
||||
stdio: "pipe",
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
const errorMsg = String(lastError.message || lastError);
|
||||
|
||||
// Define the custom user fields
|
||||
const customFields = [
|
||||
{ name: "garminConnected", type: "bool" },
|
||||
{ name: "garminOauth1Token", type: "text" },
|
||||
{ name: "garminOauth2Token", type: "text" },
|
||||
{ name: "garminTokenExpiresAt", type: "date" },
|
||||
{ name: "calendarToken", type: "text" },
|
||||
{ name: "lastPeriodDate", type: "date" },
|
||||
{ name: "cycleLength", type: "number" },
|
||||
{ name: "notificationTime", type: "text" },
|
||||
{ name: "timezone", type: "text" },
|
||||
{ name: "activeOverrides", type: "json" },
|
||||
];
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get existing field names
|
||||
const existingFieldNames = new Set(
|
||||
(usersCollection.fields || []).map((f: { name: string }) => f.name),
|
||||
);
|
||||
|
||||
// Filter to only new fields
|
||||
const newFields = customFields.filter((f) => !existingFieldNames.has(f.name));
|
||||
|
||||
if (newFields.length > 0) {
|
||||
// Combine existing fields with new ones
|
||||
const allFields = [...(usersCollection.fields || []), ...newFields];
|
||||
|
||||
await pb.collections.update(usersCollection.id, {
|
||||
fields: allFields,
|
||||
});
|
||||
// For other errors, throw immediately
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up API rules for collections to allow user access.
|
||||
*/
|
||||
async function setupApiRules(pb: PocketBase): Promise<void> {
|
||||
// Allow users to read/write their own period_logs
|
||||
const periodLogs = await pb.collections.getOne("period_logs");
|
||||
await pb.collections.update(periodLogs.id, {
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
});
|
||||
|
||||
// Allow users to read/write their own dailyLogs
|
||||
const dailyLogs = await pb.collections.getOne("dailyLogs");
|
||||
await pb.collections.update(dailyLogs.id, {
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
});
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,39 +194,272 @@ async function setupCollections(pb: PocketBase): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the test user with period data.
|
||||
* Retries an async operation with exponential backoff.
|
||||
*/
|
||||
async function createTestUser(
|
||||
pb: PocketBase,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
// Calculate date 14 days ago for mid-cycle test data
|
||||
async function retryAsync<T>(
|
||||
operation: () => Promise<T>,
|
||||
maxRetries = 5,
|
||||
baseDelayMs = 100,
|
||||
): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
const errorMsg = String(lastError.message || lastError);
|
||||
|
||||
// Retry on transient errors (database busy, connection issues)
|
||||
if (
|
||||
errorMsg.includes("database is locked") ||
|
||||
errorMsg.includes("SQLITE_BUSY") ||
|
||||
errorMsg.includes("Failed to create record")
|
||||
) {
|
||||
await sleep(baseDelayMs * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, throw immediately
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string using AES-256-GCM (matches src/lib/encryption.ts format).
|
||||
* Uses the test encryption key from playwright.config.ts.
|
||||
*/
|
||||
function encryptForTest(plaintext: string): string {
|
||||
const key = Buffer.from(
|
||||
"e2e-test-encryption-key-32chars".padEnd(32, "0").slice(0, 32),
|
||||
);
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the onboarding test user (no period data).
|
||||
*/
|
||||
async function createOnboardingUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.onboarding;
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the established test user with period data (default user).
|
||||
*/
|
||||
async function createEstablishedUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.established;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Create the test user
|
||||
const user = await pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
timezone: "UTC",
|
||||
});
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a period log entry
|
||||
await pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
});
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the calendar test user with period data and calendar token.
|
||||
*/
|
||||
async function createCalendarUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.calendar;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
calendarToken: "e2e-test-calendar-token-12345678",
|
||||
}),
|
||||
);
|
||||
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Garmin test user with period data and valid Garmin tokens.
|
||||
*/
|
||||
async function createGarminUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.garmin;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Token expires 90 days in the future
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 90);
|
||||
|
||||
const oauth1Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
oauth_token: "test-oauth1-token",
|
||||
oauth_token_secret: "test-oauth1-secret",
|
||||
}),
|
||||
);
|
||||
|
||||
const oauth2Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
access_token: "test-access-token",
|
||||
refresh_token: "test-refresh-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 7776000,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
garminConnected: true,
|
||||
garminOauth1Token: oauth1Token,
|
||||
garminOauth2Token: oauth2Token,
|
||||
garminTokenExpiresAt: expiresAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Garmin expired test user with period data and expired Garmin tokens.
|
||||
*/
|
||||
async function createGarminExpiredUser(pb: PocketBase): Promise<string> {
|
||||
const { email, password } = TEST_USERS.garminExpired;
|
||||
const fourteenDaysAgo = new Date();
|
||||
fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
|
||||
const lastPeriodDate = fourteenDaysAgo.toISOString().split("T")[0];
|
||||
|
||||
// Token expired 1 day ago
|
||||
const expiredAt = new Date();
|
||||
expiredAt.setDate(expiredAt.getDate() - 1);
|
||||
|
||||
const oauth1Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
oauth_token: "test-expired-oauth1-token",
|
||||
oauth_token_secret: "test-expired-oauth1-secret",
|
||||
}),
|
||||
);
|
||||
|
||||
const oauth2Token = encryptForTest(
|
||||
JSON.stringify({
|
||||
access_token: "test-expired-access-token",
|
||||
refresh_token: "test-expired-refresh-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 7776000,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await retryAsync(() =>
|
||||
pb.collection("users").create({
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
emailVisibility: true,
|
||||
verified: true,
|
||||
lastPeriodDate,
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
garminConnected: true,
|
||||
garminOauth1Token: oauth1Token,
|
||||
garminOauth2Token: oauth2Token,
|
||||
garminTokenExpiresAt: expiredAt.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
await retryAsync(() =>
|
||||
pb.collection("period_logs").create({
|
||||
user: user.id,
|
||||
startDate: lastPeriodDate,
|
||||
}),
|
||||
);
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all test users for e2e tests.
|
||||
*/
|
||||
async function createAllTestUsers(pb: PocketBase): Promise<void> {
|
||||
await createOnboardingUser(pb);
|
||||
await createEstablishedUser(pb);
|
||||
await createCalendarUser(pb);
|
||||
await createGarminUser(pb);
|
||||
await createGarminExpiredUser(pb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a fresh PocketBase instance for e2e testing.
|
||||
*/
|
||||
@@ -247,8 +493,8 @@ export async function start(
|
||||
// Wait for PocketBase to be ready
|
||||
await waitForReady(url);
|
||||
|
||||
// Create admin user via CLI
|
||||
createAdminUser(dataDir, config.adminEmail, config.adminPassword);
|
||||
// Create admin user via CLI (with retry for database lock during migrations)
|
||||
await createAdminUser(dataDir, config.adminEmail, config.adminPassword);
|
||||
|
||||
// Connect to PocketBase as admin
|
||||
const pb = new PocketBase(url);
|
||||
@@ -260,8 +506,8 @@ export async function start(
|
||||
// Set up collections
|
||||
await setupCollections(pb);
|
||||
|
||||
// Create test user with period data
|
||||
await createTestUser(pb, config.testUserEmail, config.testUserPassword);
|
||||
// Create all test users for different e2e scenarios
|
||||
await createAllTestUsers(pb);
|
||||
|
||||
currentState = {
|
||||
process: pbProcess,
|
||||
|
||||
@@ -190,4 +190,780 @@ test.describe("settings", () => {
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("settings form validation", () => {
|
||||
// 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 });
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("notification time field accepts valid HH:MM format", async ({
|
||||
page,
|
||||
}) => {
|
||||
const notificationTimeInput = page.getByLabel(/notification time/i);
|
||||
const isVisible = await notificationTimeInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter a valid time
|
||||
await notificationTimeInput.fill("07:00");
|
||||
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for save
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// No error should be shown
|
||||
const errorMessage = page.locator('[role="alert"]').filter({
|
||||
hasText: /error|failed|invalid/i,
|
||||
});
|
||||
const hasError = await errorMessage.isVisible().catch(() => false);
|
||||
expect(hasError).toBe(false);
|
||||
});
|
||||
|
||||
test("cycle length rejects value below minimum (21)", async ({ page }) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter invalid value (too low)
|
||||
await cycleLengthInput.fill("20");
|
||||
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for validation
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Either HTML5 validation or error message should appear
|
||||
// Input should have min attribute or form shows error
|
||||
const inputMin = await cycleLengthInput.getAttribute("min");
|
||||
if (inputMin) {
|
||||
expect(Number.parseInt(inputMin, 10)).toBeGreaterThanOrEqual(21);
|
||||
}
|
||||
});
|
||||
|
||||
test("cycle length rejects value above maximum (45)", async ({ page }) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter invalid value (too high)
|
||||
await cycleLengthInput.fill("50");
|
||||
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for validation
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Input should have max attribute
|
||||
const inputMax = await cycleLengthInput.getAttribute("max");
|
||||
if (inputMax) {
|
||||
expect(Number.parseInt(inputMax, 10)).toBeLessThanOrEqual(45);
|
||||
}
|
||||
});
|
||||
|
||||
test("timezone field is editable", async ({ page }) => {
|
||||
const timezoneInput = page.getByLabel(/timezone/i);
|
||||
const isVisible = await timezoneInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Timezone could be select or input
|
||||
const inputType = await timezoneInput.evaluate((el) =>
|
||||
el.tagName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (inputType === "select") {
|
||||
// Should have options
|
||||
const options = timezoneInput.locator("option");
|
||||
const optionCount = await options.count();
|
||||
expect(optionCount).toBeGreaterThan(0);
|
||||
} else {
|
||||
// Should be able to type in it
|
||||
const isEditable = await timezoneInput.isEditable();
|
||||
expect(isEditable).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("current cycle length value is displayed", async ({ page }) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Should have a current value
|
||||
const value = await cycleLengthInput.inputValue();
|
||||
|
||||
// Value should be a number in valid range
|
||||
const numValue = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(numValue)) {
|
||||
expect(numValue).toBeGreaterThanOrEqual(21);
|
||||
expect(numValue).toBeLessThanOrEqual(45);
|
||||
}
|
||||
});
|
||||
|
||||
test("settings changes persist after page reload", async ({ page }) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current value
|
||||
const originalValue = await cycleLengthInput.inputValue();
|
||||
|
||||
// Set a different valid value
|
||||
const newValue = originalValue === "28" ? "30" : "28";
|
||||
await cycleLengthInput.fill(newValue);
|
||||
|
||||
// Save
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check the value persisted
|
||||
const cycleLengthAfter = page.getByLabel(/cycle length/i);
|
||||
const afterValue = await cycleLengthAfter.inputValue();
|
||||
|
||||
// Either it persisted or was rejected - check it's a valid number
|
||||
const numValue = Number.parseInt(afterValue, 10);
|
||||
expect(numValue).toBeGreaterThanOrEqual(21);
|
||||
expect(numValue).toBeLessThanOrEqual(45);
|
||||
|
||||
// Restore original value
|
||||
await cycleLengthAfter.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test("save button shows loading state during submission", async ({
|
||||
page,
|
||||
}) => {
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
const isVisible = await saveButton.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial state should not be disabled
|
||||
const isDisabledBefore = await saveButton.isDisabled();
|
||||
expect(isDisabledBefore).toBe(false);
|
||||
|
||||
// Click save and quickly check for loading state
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for submission to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// After completion, button should be enabled again
|
||||
const isDisabledAfter = await saveButton.isDisabled();
|
||||
expect(isDisabledAfter).toBe(false);
|
||||
});
|
||||
|
||||
test("notification time changes persist after page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const notificationTimeInput = page.getByLabel(/notification time/i);
|
||||
const isVisible = await notificationTimeInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current value
|
||||
const originalValue = await notificationTimeInput.inputValue();
|
||||
|
||||
// Set a different valid time (toggle between 08:00 and 09:00)
|
||||
const newValue = originalValue === "08:00" ? "09:00" : "08:00";
|
||||
await notificationTimeInput.fill(newValue);
|
||||
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check the value persisted
|
||||
const notificationTimeAfter = page.getByLabel(/notification time/i);
|
||||
const afterValue = await notificationTimeAfter.inputValue();
|
||||
|
||||
expect(afterValue).toBe(newValue);
|
||||
|
||||
// Restore original value
|
||||
await notificationTimeAfter.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("timezone changes persist after page reload", async ({ page }) => {
|
||||
const timezoneInput = page.getByLabel(/timezone/i);
|
||||
const isVisible = await timezoneInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current value
|
||||
const originalValue = await timezoneInput.inputValue();
|
||||
|
||||
// Set a different timezone (toggle between two common timezones)
|
||||
const newValue =
|
||||
originalValue === "America/New_York"
|
||||
? "America/Los_Angeles"
|
||||
: "America/New_York";
|
||||
await timezoneInput.fill(newValue);
|
||||
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check the value persisted
|
||||
const timezoneAfter = page.getByLabel(/timezone/i);
|
||||
const afterValue = await timezoneAfter.inputValue();
|
||||
|
||||
expect(afterValue).toBe(newValue);
|
||||
|
||||
// Restore original value
|
||||
await timezoneAfter.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("multiple settings changes persist after page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const notificationTimeInput = page.getByLabel(/notification time/i);
|
||||
const timezoneInput = page.getByLabel(/timezone/i);
|
||||
|
||||
const cycleLengthVisible = await cycleLengthInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const notificationTimeVisible = await notificationTimeInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const timezoneVisible = await timezoneInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!cycleLengthVisible || !notificationTimeVisible || !timezoneVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all original values
|
||||
const originalCycleLength = await cycleLengthInput.inputValue();
|
||||
const originalNotificationTime = await notificationTimeInput.inputValue();
|
||||
const originalTimezone = await timezoneInput.inputValue();
|
||||
|
||||
// Set different values for all fields
|
||||
const newCycleLength = originalCycleLength === "28" ? "30" : "28";
|
||||
const newNotificationTime =
|
||||
originalNotificationTime === "08:00" ? "09:00" : "08:00";
|
||||
const newTimezone =
|
||||
originalTimezone === "America/New_York"
|
||||
? "America/Los_Angeles"
|
||||
: "America/New_York";
|
||||
|
||||
await cycleLengthInput.fill(newCycleLength);
|
||||
await notificationTimeInput.fill(newNotificationTime);
|
||||
await timezoneInput.fill(newTimezone);
|
||||
|
||||
// Save all changes at once and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Verify all values persisted
|
||||
const cycleLengthAfter = page.getByLabel(/cycle length/i);
|
||||
const notificationTimeAfter = page.getByLabel(/notification time/i);
|
||||
const timezoneAfter = page.getByLabel(/timezone/i);
|
||||
|
||||
expect(await cycleLengthAfter.inputValue()).toBe(newCycleLength);
|
||||
expect(await notificationTimeAfter.inputValue()).toBe(
|
||||
newNotificationTime,
|
||||
);
|
||||
expect(await timezoneAfter.inputValue()).toBe(newTimezone);
|
||||
|
||||
// Restore all original values
|
||||
await cycleLengthAfter.fill(originalCycleLength);
|
||||
await notificationTimeAfter.fill(originalNotificationTime);
|
||||
await timezoneAfter.fill(originalTimezone);
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("cycle length persistence verifies exact saved value", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current value
|
||||
const originalValue = await cycleLengthInput.inputValue();
|
||||
|
||||
// Set a specific different valid value
|
||||
const newValue = originalValue === "28" ? "31" : "28";
|
||||
await cycleLengthInput.fill(newValue);
|
||||
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check the exact value persisted (not just range validation)
|
||||
const cycleLengthAfter = page.getByLabel(/cycle length/i);
|
||||
const afterValue = await cycleLengthAfter.inputValue();
|
||||
|
||||
expect(afterValue).toBe(newValue);
|
||||
|
||||
// Restore original value
|
||||
await cycleLengthAfter.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("settings form shows correct values after save without reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const notificationTimeInput = page.getByLabel(/notification time/i);
|
||||
|
||||
const cycleLengthVisible = await cycleLengthInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const notificationTimeVisible = await notificationTimeInput
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
if (!cycleLengthVisible || !notificationTimeVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get original values
|
||||
const originalCycleLength = await cycleLengthInput.inputValue();
|
||||
const originalNotificationTime = await notificationTimeInput.inputValue();
|
||||
|
||||
// Change values
|
||||
const newCycleLength = originalCycleLength === "28" ? "29" : "28";
|
||||
const newNotificationTime =
|
||||
originalNotificationTime === "08:00" ? "10:00" : "08:00";
|
||||
|
||||
await cycleLengthInput.fill(newCycleLength);
|
||||
await notificationTimeInput.fill(newNotificationTime);
|
||||
|
||||
// Save
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Verify values are still showing the new values without reload
|
||||
expect(await cycleLengthInput.inputValue()).toBe(newCycleLength);
|
||||
expect(await notificationTimeInput.inputValue()).toBe(
|
||||
newNotificationTime,
|
||||
);
|
||||
|
||||
// Restore original values
|
||||
await cycleLengthInput.fill(originalCycleLength);
|
||||
await notificationTimeInput.fill(originalNotificationTime);
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("error recovery", () => {
|
||||
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 });
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("shows error message and allows retry when save fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||
const isVisible = await cycleLengthInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get original value for restoration
|
||||
const originalValue = await cycleLengthInput.inputValue();
|
||||
|
||||
// Intercept the save request and make it fail once, then succeed
|
||||
let failureCount = 0;
|
||||
await page.route("**/api/user", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
if (failureCount === 0) {
|
||||
failureCount++;
|
||||
// First request fails with server error
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Server error" }),
|
||||
});
|
||||
} else {
|
||||
// Subsequent requests succeed - let them through
|
||||
await route.continue();
|
||||
}
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Change the cycle length
|
||||
const newValue = originalValue === "28" ? "32" : "28";
|
||||
await cycleLengthInput.fill(newValue);
|
||||
|
||||
// Click save - should fail
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for error handling to complete
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The key test is that the form remains usable after a failed save
|
||||
// Error handling may show a toast or just keep the form editable
|
||||
|
||||
// Verify form is still editable (not stuck in loading state)
|
||||
const isEditable = await cycleLengthInput.isEditable();
|
||||
expect(isEditable).toBe(true);
|
||||
|
||||
// Verify save button is enabled for retry
|
||||
const isButtonEnabled = !(await saveButton.isDisabled());
|
||||
expect(isButtonEnabled).toBe(true);
|
||||
|
||||
// Try saving again - should succeed this time
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Form should still be functional
|
||||
const isEditableAfterRetry = await cycleLengthInput.isEditable();
|
||||
expect(isEditableAfterRetry).toBe(true);
|
||||
|
||||
// Clean up route interception
|
||||
await page.unroute("**/api/user");
|
||||
|
||||
// Restore original value
|
||||
await cycleLengthInput.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("intensity goals section", () => {
|
||||
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 });
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("displays Weekly Intensity Goals section", async ({ page }) => {
|
||||
const sectionHeading = page.getByRole("heading", {
|
||||
name: /weekly intensity goals/i,
|
||||
});
|
||||
await expect(sectionHeading).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays input for menstrual phase goal", async ({ page }) => {
|
||||
const menstrualInput = page.getByLabel(/menstrual/i);
|
||||
await expect(menstrualInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays input for follicular phase goal", async ({ page }) => {
|
||||
const follicularInput = page.getByLabel(/follicular/i);
|
||||
await expect(follicularInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays input for ovulation phase goal", async ({ page }) => {
|
||||
const ovulationInput = page.getByLabel(/ovulation/i);
|
||||
await expect(ovulationInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays input for early luteal phase goal", async ({ page }) => {
|
||||
const earlyLutealInput = page.getByLabel(/early luteal/i);
|
||||
await expect(earlyLutealInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays input for late luteal phase goal", async ({ page }) => {
|
||||
const lateLutealInput = page.getByLabel(/late luteal/i);
|
||||
await expect(lateLutealInput).toBeVisible();
|
||||
});
|
||||
|
||||
test("can modify menstrual phase goal and save", async ({ page }) => {
|
||||
const menstrualInput = page.getByLabel(/menstrual/i);
|
||||
const isVisible = await menstrualInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get original value
|
||||
const originalValue = await menstrualInput.inputValue();
|
||||
|
||||
// Set a different value
|
||||
const newValue = originalValue === "75" ? "80" : "75";
|
||||
await menstrualInput.fill(newValue);
|
||||
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Restore original value
|
||||
await menstrualInput.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("persists intensity goal value after page reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const menstrualInput = page.getByLabel(/menstrual/i);
|
||||
const isVisible = await menstrualInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get original value
|
||||
const originalValue = await menstrualInput.inputValue();
|
||||
|
||||
// Set a different value
|
||||
const newValue = originalValue === "75" ? "85" : "75";
|
||||
await menstrualInput.fill(newValue);
|
||||
|
||||
// Save and wait for success toast
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Reload the page
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Check the value persisted
|
||||
const menstrualAfter = page.getByLabel(/menstrual/i);
|
||||
const afterValue = await menstrualAfter.inputValue();
|
||||
|
||||
expect(afterValue).toBe(newValue);
|
||||
|
||||
// Restore original value
|
||||
await menstrualAfter.fill(originalValue);
|
||||
await saveButton.click();
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test("intensity goal inputs have number type and min attribute", async ({
|
||||
page,
|
||||
}) => {
|
||||
const menstrualInput = page.getByLabel(/menstrual/i);
|
||||
const isVisible = await menstrualInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check type attribute
|
||||
const inputType = await menstrualInput.getAttribute("type");
|
||||
expect(inputType).toBe("number");
|
||||
|
||||
// Check min attribute
|
||||
const inputMin = await menstrualInput.getAttribute("min");
|
||||
expect(inputMin).toBe("0");
|
||||
});
|
||||
|
||||
test("all intensity goal inputs are disabled while saving", async ({
|
||||
page,
|
||||
}) => {
|
||||
const menstrualInput = page.getByLabel(/menstrual/i);
|
||||
const isVisible = await menstrualInput.isVisible().catch(() => false);
|
||||
|
||||
if (!isVisible) {
|
||||
test.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start saving (slow down the response to catch disabled state)
|
||||
await page.route("**/api/user", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
// Delay response to allow testing disabled state
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
const saveButton = page.getByRole("button", { name: /save/i });
|
||||
await saveButton.click();
|
||||
|
||||
// Check inputs are disabled during save
|
||||
await expect(menstrualInput).toBeDisabled();
|
||||
|
||||
// Wait for save to complete
|
||||
await expect(page.getByText(/settings saved successfully/i)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Clean up route interception
|
||||
await page.unroute("**/api/user");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
// ABOUTME: Next.js configuration for PhaseFlow application.
|
||||
// ABOUTME: Configures standalone output and injects git commit hash for build verification.
|
||||
import { execSync } from "node:child_process";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
// Get git commit hash at build time for deployment verification
|
||||
function getGitCommit(): string {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD").toString().trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
env: {
|
||||
GIT_COMMIT: process.env.GIT_COMMIT || getGitCommit(),
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -19,16 +19,18 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"form-data": "^4.0.1",
|
||||
"ics": "^3.8.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mailgun.js": "^11.1.0",
|
||||
"next": "16.1.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"pino": "^10.1.1",
|
||||
"pocketbase": "^0.26.5",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"resend": "^6.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -22,8 +22,9 @@ export default defineConfig({
|
||||
// Retry failed tests on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Limit parallel workers on CI to avoid resource issues
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
// Run tests sequentially since all tests share the same test user
|
||||
// Parallel execution causes race conditions when tests modify user state
|
||||
workers: 1,
|
||||
|
||||
// Reporter configuration
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
@@ -44,16 +45,19 @@ export default defineConfig({
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
|
||||
// Run dev server before starting tests
|
||||
// Note: POCKETBASE_URL is set by global-setup.ts for the test PocketBase instance
|
||||
// Note: POCKETBASE_URL is set for the test PocketBase instance on port 8091
|
||||
// We never reuse existing servers to ensure the correct PocketBase URL is used
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120 * 1000, // 2 minutes for Next.js to start
|
||||
env: {
|
||||
// Use the test PocketBase instance (port 8091)
|
||||
NEXT_PUBLIC_POCKETBASE_URL: "http://127.0.0.1:8091",
|
||||
POCKETBASE_URL: "http://127.0.0.1:8091",
|
||||
// Required for Garmin token encryption
|
||||
ENCRYPTION_KEY: "e2e-test-encryption-key-32chars",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
267
pnpm-lock.yaml
generated
267
pnpm-lock.yaml
generated
@@ -17,18 +17,27 @@ importers:
|
||||
drizzle-orm:
|
||||
specifier: ^0.45.1
|
||||
version: 0.45.1(@opentelemetry/api@1.9.0)
|
||||
form-data:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.5
|
||||
ics:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
mailgun.js:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
next:
|
||||
specifier: 16.1.1
|
||||
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
node-cron:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
oauth-1.0a:
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6
|
||||
pino:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1
|
||||
@@ -44,9 +53,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
resend:
|
||||
specifier: ^6.7.0
|
||||
version: 6.7.0
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -1116,9 +1122,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -1334,10 +1337,19 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
axios@1.13.2:
|
||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||
|
||||
base-64@1.0.0:
|
||||
resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==}
|
||||
|
||||
baseline-browser-mapping@2.9.14:
|
||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||
hasBin: true
|
||||
@@ -1356,6 +1368,10 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
caniuse-lite@1.0.30001763:
|
||||
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
||||
|
||||
@@ -1373,6 +1389,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
@@ -1406,6 +1426,10 @@ packages:
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1516,6 +1540,10 @@ packages:
|
||||
sqlite3:
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
electron-to-chromium@1.5.267:
|
||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||
|
||||
@@ -1527,9 +1555,25 @@ packages:
|
||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild-register@3.6.0:
|
||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||
peerDependencies:
|
||||
@@ -1561,9 +1605,6 @@ packages:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1573,6 +1614,19 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
follow-redirects@1.15.11:
|
||||
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1583,16 +1637,43 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -1730,9 +1811,25 @@ packages:
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mailgun.js@11.1.0:
|
||||
resolution: {integrity: sha512-pXYcQT3nU32gMjUjZpl2FdQN4Vv2iobqYiXqyyevk0vXTKQj8Or0ifLXLNAGqMHnymTjV0OphBpurkchvHsRAg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdn-data@2.12.2:
|
||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
min-indent@1.0.1:
|
||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1773,6 +1870,9 @@ packages:
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
oauth-1.0a@2.2.6:
|
||||
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
@@ -1838,6 +1938,9 @@ packages:
|
||||
property-expr@2.0.6:
|
||||
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
||||
|
||||
proxy-from-env@1.1.0:
|
||||
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1873,15 +1976,6 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resend@6.7.0:
|
||||
resolution: {integrity: sha512-2ZV0NDZsh4Gh+Nd1hvluZIitmGJ59O4+OxMufymG6Y8uz1Jgt2uS1seSENnkIUlmwg7/dwmfIJC9rAufByz7wA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@react-email/render': '*'
|
||||
peerDependenciesMeta:
|
||||
'@react-email/render':
|
||||
optional: true
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@@ -1947,9 +2041,6 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -1970,9 +2061,6 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
svix@1.84.1:
|
||||
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -2053,9 +2141,8 @@ packages:
|
||||
peerDependencies:
|
||||
browserslist: '>= 4.21.0'
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
url-join@4.0.1:
|
||||
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
@@ -2845,8 +2932,6 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||
optional: true
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
@@ -3065,8 +3150,20 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
axios@1.13.2:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.11
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
base-64@1.0.0: {}
|
||||
|
||||
baseline-browser-mapping@2.9.14: {}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
@@ -3085,6 +3182,11 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
caniuse-lite@1.0.30001763: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
@@ -3097,6 +3199,10 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
css-tree@3.1.0:
|
||||
@@ -3126,6 +3232,8 @@ snapshots:
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
@@ -3147,6 +3255,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
electron-to-chromium@1.5.267: {}
|
||||
|
||||
enhanced-resolve@5.18.4:
|
||||
@@ -3156,8 +3270,23 @@ snapshots:
|
||||
|
||||
entities@6.0.1: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -3256,26 +3385,66 @@ snapshots:
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
get-tsconfig@4.13.0:
|
||||
dependencies:
|
||||
resolve-pkg-maps: 1.0.0
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.8.0
|
||||
@@ -3407,8 +3576,24 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mailgun.js@11.1.0:
|
||||
dependencies:
|
||||
axios: 1.13.2
|
||||
base-64: 1.0.0
|
||||
url-join: 4.0.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
@@ -3445,6 +3630,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
oauth-1.0a@2.2.6: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
@@ -3516,6 +3703,8 @@ snapshots:
|
||||
|
||||
property-expr@2.0.6: {}
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
@@ -3540,10 +3729,6 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resend@6.7.0:
|
||||
dependencies:
|
||||
svix: 1.84.1
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rollup@4.55.1:
|
||||
@@ -3648,11 +3833,6 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
strip-indent@3.0.0:
|
||||
@@ -3666,11 +3846,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.5
|
||||
|
||||
svix@1.84.1:
|
||||
dependencies:
|
||||
standardwebhooks: 1.0.0
|
||||
uuid: 10.0.0
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
@@ -3732,7 +3907,7 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
uuid@10.0.0: {}
|
||||
url-join@4.0.1: {}
|
||||
|
||||
vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||
dependencies:
|
||||
|
||||
@@ -45,13 +45,16 @@ oauth1_adapter = TypeAdapter(OAuth1Token)
|
||||
oauth2_adapter = TypeAdapter(OAuth2Token)
|
||||
|
||||
expires_at_ts = garth.client.oauth2_token.expires_at
|
||||
refresh_expires_at_ts = garth.client.oauth2_token.refresh_token_expires_at
|
||||
tokens = {
|
||||
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
|
||||
"oauth2": oauth2_adapter.dump_python(garth.client.oauth2_token, mode='json'),
|
||||
"expires_at": datetime.fromtimestamp(expires_at_ts).isoformat()
|
||||
"expires_at": datetime.fromtimestamp(expires_at_ts).isoformat(),
|
||||
"refresh_token_expires_at": datetime.fromtimestamp(refresh_expires_at_ts).isoformat()
|
||||
}
|
||||
|
||||
print("\n--- Copy everything below this line ---")
|
||||
print(json.dumps(tokens, indent=2))
|
||||
print("--- Copy everything above this line ---")
|
||||
print(f"\nTokens expire: {tokens['expires_at']}")
|
||||
print(f"\nAccess token expires: {tokens['expires_at']}")
|
||||
print(f"Refresh token expires: {tokens['refresh_token_expires_at']} (re-run script before this date)")
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getExistingCollectionNames,
|
||||
getMissingCollections,
|
||||
PERIOD_LOGS_COLLECTION,
|
||||
USER_CUSTOM_FIELDS,
|
||||
} from "./setup-db";
|
||||
|
||||
describe("PERIOD_LOGS_COLLECTION", () => {
|
||||
@@ -162,3 +163,69 @@ describe("getMissingCollections", () => {
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("USER_CUSTOM_FIELDS garmin token max lengths", () => {
|
||||
it("should have sufficient max length for garminOauth2Token field", () => {
|
||||
const oauth2Field = USER_CUSTOM_FIELDS.find(
|
||||
(f) => f.name === "garminOauth2Token",
|
||||
);
|
||||
expect(oauth2Field).toBeDefined();
|
||||
expect(oauth2Field?.max).toBeGreaterThanOrEqual(10000);
|
||||
});
|
||||
|
||||
it("should have sufficient max length for garminOauth1Token field", () => {
|
||||
const oauth1Field = USER_CUSTOM_FIELDS.find(
|
||||
(f) => f.name === "garminOauth1Token",
|
||||
);
|
||||
expect(oauth1Field).toBeDefined();
|
||||
expect(oauth1Field?.max).toBeGreaterThanOrEqual(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupApiRules", () => {
|
||||
it("configures user-owned record rules for period_logs and dailyLogs", async () => {
|
||||
const { setupApiRules } = await import("./setup-db");
|
||||
|
||||
const updateMock = vi.fn().mockResolvedValue({});
|
||||
const mockPb = {
|
||||
collections: {
|
||||
getOne: vi.fn().mockImplementation((name: string) => {
|
||||
return Promise.resolve({ id: `${name}-id`, name });
|
||||
}),
|
||||
update: updateMock,
|
||||
},
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
await setupApiRules(mockPb as any);
|
||||
|
||||
// Should have called getOne for users, period_logs, and dailyLogs
|
||||
expect(mockPb.collections.getOne).toHaveBeenCalledWith("users");
|
||||
expect(mockPb.collections.getOne).toHaveBeenCalledWith("period_logs");
|
||||
expect(mockPb.collections.getOne).toHaveBeenCalledWith("dailyLogs");
|
||||
|
||||
// Check users collection rules
|
||||
expect(updateMock).toHaveBeenCalledWith("users-id", {
|
||||
viewRule: "",
|
||||
updateRule: "id = @request.auth.id",
|
||||
});
|
||||
|
||||
// Check period_logs collection rules
|
||||
expect(updateMock).toHaveBeenCalledWith("period_logs-id", {
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
});
|
||||
|
||||
// Check dailyLogs collection rules
|
||||
expect(updateMock).toHaveBeenCalledWith("dailyLogs-id", {
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,12 @@ import PocketBase from "pocketbase";
|
||||
* Collection field definition for PocketBase.
|
||||
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
|
||||
*/
|
||||
interface CollectionField {
|
||||
export interface CollectionField {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
// Text field max length (PocketBase defaults to 5000 if not specified)
|
||||
max?: number;
|
||||
// Relation field properties (top-level, not in options)
|
||||
collectionId?: string;
|
||||
maxSelect?: number;
|
||||
@@ -138,6 +140,90 @@ export const DAILY_LOGS_COLLECTION: CollectionDefinition = {
|
||||
*/
|
||||
const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION];
|
||||
|
||||
/**
|
||||
* Custom fields to add to the users collection.
|
||||
* These are required for Garmin integration and app functionality.
|
||||
*/
|
||||
export const USER_CUSTOM_FIELDS: CollectionField[] = [
|
||||
{ name: "garminConnected", type: "bool" },
|
||||
{ name: "garminOauth1Token", type: "text", max: 20000 },
|
||||
{ name: "garminOauth2Token", type: "text", max: 20000 },
|
||||
{ name: "garminTokenExpiresAt", type: "date" },
|
||||
{ name: "garminRefreshTokenExpiresAt", type: "date" },
|
||||
{ name: "calendarToken", type: "text" },
|
||||
{ name: "lastPeriodDate", type: "date" },
|
||||
{ name: "cycleLength", type: "number" },
|
||||
{ name: "notificationTime", type: "text" },
|
||||
{ name: "timezone", type: "text" },
|
||||
{ name: "activeOverrides", type: "json" },
|
||||
// Phase-specific intensity goals (weekly minutes)
|
||||
{ name: "intensityGoalMenstrual", type: "number" },
|
||||
{ name: "intensityGoalFollicular", type: "number" },
|
||||
{ name: "intensityGoalOvulation", type: "number" },
|
||||
{ name: "intensityGoalEarlyLuteal", type: "number" },
|
||||
{ name: "intensityGoalLateLuteal", type: "number" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Adds or updates custom fields on the users collection.
|
||||
* For new fields: adds them. For existing fields: updates max constraint if different.
|
||||
* This is idempotent - safe to run multiple times.
|
||||
*/
|
||||
export async function addUserFields(pb: PocketBase): Promise<void> {
|
||||
const usersCollection = await pb.collections.getOne("users");
|
||||
|
||||
// Build a map of existing fields by name
|
||||
const existingFieldsMap = new Map<string, Record<string, unknown>>(
|
||||
(usersCollection.fields || []).map((f: Record<string, unknown>) => [
|
||||
f.name as string,
|
||||
f,
|
||||
]),
|
||||
);
|
||||
|
||||
// Separate new fields from fields that need updating
|
||||
const newFields: CollectionField[] = [];
|
||||
const fieldsToUpdate: string[] = [];
|
||||
|
||||
for (const definedField of USER_CUSTOM_FIELDS) {
|
||||
const existingField = existingFieldsMap.get(definedField.name);
|
||||
if (!existingField) {
|
||||
newFields.push(definedField);
|
||||
} else if (
|
||||
definedField.max !== undefined &&
|
||||
existingField.max !== definedField.max
|
||||
) {
|
||||
fieldsToUpdate.push(definedField.name);
|
||||
existingField.max = definedField.max;
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = newFields.length > 0 || fieldsToUpdate.length > 0;
|
||||
|
||||
if (hasChanges) {
|
||||
// Combine existing fields (with updates) and new fields
|
||||
const allFields = [...(usersCollection.fields || []), ...newFields];
|
||||
|
||||
await pb.collections.update(usersCollection.id, {
|
||||
fields: allFields,
|
||||
});
|
||||
|
||||
if (newFields.length > 0) {
|
||||
console.log(
|
||||
` Added ${newFields.length} field(s) to users:`,
|
||||
newFields.map((f) => f.name),
|
||||
);
|
||||
}
|
||||
if (fieldsToUpdate.length > 0) {
|
||||
console.log(
|
||||
` Updated max constraint for ${fieldsToUpdate.length} field(s):`,
|
||||
fieldsToUpdate,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(" All user fields already exist with correct settings.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of existing collections from PocketBase.
|
||||
*/
|
||||
@@ -205,6 +291,40 @@ export async function createCollection(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up API rules for collections to allow user access.
|
||||
* Configures row-level security so users can only access their own records.
|
||||
*/
|
||||
export async function setupApiRules(pb: PocketBase): Promise<void> {
|
||||
// Allow users to view any user record (needed for ICS calendar feed)
|
||||
// and update only their own record
|
||||
const usersCollection = await pb.collections.getOne("users");
|
||||
await pb.collections.update(usersCollection.id, {
|
||||
viewRule: "",
|
||||
updateRule: "id = @request.auth.id",
|
||||
});
|
||||
|
||||
// Allow users to read/write their own period_logs
|
||||
const periodLogs = await pb.collections.getOne("period_logs");
|
||||
await pb.collections.update(periodLogs.id, {
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
});
|
||||
|
||||
// Allow users to read/write their own dailyLogs
|
||||
const dailyLogs = await pb.collections.getOne("dailyLogs");
|
||||
await pb.collections.update(dailyLogs.id, {
|
||||
listRule: "user = @request.auth.id",
|
||||
viewRule: "user = @request.auth.id",
|
||||
createRule: "user = @request.auth.id",
|
||||
updateRule: "user = @request.auth.id",
|
||||
deleteRule: "user = @request.auth.id",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main setup function - creates missing collections.
|
||||
*/
|
||||
@@ -242,6 +362,10 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Add custom fields to users collection
|
||||
console.log("Checking users collection fields...");
|
||||
await addUserFields(pb);
|
||||
|
||||
// Get existing collections
|
||||
const existingNames = await getExistingCollectionNames(pb);
|
||||
console.log(
|
||||
@@ -253,25 +377,29 @@ async function main(): Promise<void> {
|
||||
const missing = getMissingCollections(existingNames);
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log("All required collections already exist. Nothing to do.");
|
||||
return;
|
||||
}
|
||||
console.log("All required collections already exist.");
|
||||
} else {
|
||||
console.log(
|
||||
`Creating ${missing.length} missing collection(s):`,
|
||||
missing.map((c) => c.name),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Creating ${missing.length} missing collection(s):`,
|
||||
missing.map((c) => c.name),
|
||||
);
|
||||
|
||||
for (const collection of missing) {
|
||||
try {
|
||||
await createCollection(pb, collection);
|
||||
console.log(` Created: ${collection.name}`);
|
||||
} catch (error) {
|
||||
console.error(` Failed to create ${collection.name}:`, error);
|
||||
process.exit(1);
|
||||
for (const collection of missing) {
|
||||
try {
|
||||
await createCollection(pb, collection);
|
||||
console.log(` Created: ${collection.name}`);
|
||||
} catch (error) {
|
||||
console.error(` Failed to create ${collection.name}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up API rules for all collections
|
||||
console.log("Configuring API rules...");
|
||||
await setupApiRules(pb);
|
||||
console.log(" API rules configured.");
|
||||
|
||||
console.log("Database setup complete!");
|
||||
}
|
||||
|
||||
|
||||
@@ -79,12 +79,18 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "valid-calendar-token-abc123def",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -14,11 +14,13 @@ interface RouteParams {
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { userId, token } = await params;
|
||||
const { userId, token: rawToken } = await params;
|
||||
// Strip .ics suffix if present (Next.js may include it in the param)
|
||||
const token = rawToken.endsWith(".ics") ? rawToken.slice(0, -4) : rawToken;
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
try {
|
||||
// Fetch user from database
|
||||
const pb = createPocketBaseClient();
|
||||
const user = await pb.collection("users").getOne(userId);
|
||||
|
||||
// Check if user has a calendar token set
|
||||
|
||||
@@ -41,12 +41,18 @@ describe("POST /api/calendar/regenerate-token", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "old-calendar-token-abc123",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
||||
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { User } from "@/types";
|
||||
|
||||
@@ -8,6 +8,21 @@ import type { User } from "@/types";
|
||||
let mockUsers: User[] = [];
|
||||
// Track DailyLog creations
|
||||
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||
// Track user updates
|
||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||
// Track DailyLog queries for upsert
|
||||
const mockGetFirstListItem = vi.fn();
|
||||
// Track the filter string passed to getFirstListItem
|
||||
let lastDailyLogFilter: string | null = null;
|
||||
|
||||
// Helper to parse date values - handles both Date objects and ISO strings
|
||||
function parseDate(value: unknown): Date | null {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// Mock PocketBase
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
@@ -19,16 +34,54 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
getFirstListItem: vi.fn(async (filter: string) => {
|
||||
if (name === "dailyLogs") {
|
||||
lastDailyLogFilter = filter;
|
||||
}
|
||||
return mockGetFirstListItem(filter);
|
||||
}),
|
||||
create: mockPbCreate,
|
||||
update: mockPbUpdate,
|
||||
authWithPassword: vi.fn().mockResolvedValue({ token: "admin-token" }),
|
||||
})),
|
||||
})),
|
||||
mapRecordToUser: vi.fn((record: Record<string, unknown>) => ({
|
||||
id: record.id,
|
||||
email: record.email,
|
||||
garminConnected: record.garminConnected,
|
||||
garminOauth1Token: record.garminOauth1Token,
|
||||
garminOauth2Token: record.garminOauth2Token,
|
||||
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
|
||||
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
|
||||
calendarToken: record.calendarToken,
|
||||
lastPeriodDate: parseDate(record.lastPeriodDate),
|
||||
cycleLength: record.cycleLength,
|
||||
notificationTime: record.notificationTime,
|
||||
timezone: record.timezone,
|
||||
activeOverrides: record.activeOverrides || [],
|
||||
intensityGoalMenstrual: (record.intensityGoalMenstrual as number) ?? 75,
|
||||
intensityGoalFollicular: (record.intensityGoalFollicular as number) ?? 150,
|
||||
intensityGoalOvulation: (record.intensityGoalOvulation as number) ?? 100,
|
||||
intensityGoalEarlyLuteal:
|
||||
(record.intensityGoalEarlyLuteal as number) ?? 120,
|
||||
intensityGoalLateLuteal: (record.intensityGoalLateLuteal as number) ?? 50,
|
||||
created: new Date(record.created as string),
|
||||
updated: new Date(record.updated as string),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock decryption
|
||||
const mockDecrypt = vi.fn((ciphertext: string) => {
|
||||
// Return mock OAuth2 token JSON
|
||||
if (ciphertext.includes("oauth2")) {
|
||||
return JSON.stringify({ accessToken: "mock-token-123" });
|
||||
return JSON.stringify({ access_token: "mock-token-123" });
|
||||
}
|
||||
// Return mock OAuth1 token JSON (needed for refresh flow)
|
||||
if (ciphertext.includes("oauth1")) {
|
||||
return JSON.stringify({
|
||||
oauth_token: "mock-oauth1-token",
|
||||
oauth_token_secret: "mock-oauth1-secret",
|
||||
});
|
||||
}
|
||||
return ciphertext.replace("encrypted:", "");
|
||||
});
|
||||
@@ -57,10 +110,15 @@ vi.mock("@/lib/garmin", () => ({
|
||||
|
||||
// Mock email sending
|
||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@/lib/email", () => ({
|
||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||
mockSendTokenExpirationWarning(...args),
|
||||
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
|
||||
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||
mockSendPeriodConfirmationEmail(...args),
|
||||
}));
|
||||
|
||||
// Mock logger (required for route to run without side effects)
|
||||
@@ -87,12 +145,18 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -112,9 +176,18 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mockUsers = [];
|
||||
lastDailyLogFilter = null;
|
||||
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
||||
mockSendTokenExpirationWarning.mockResolvedValue(undefined); // Reset mock implementation
|
||||
// Default: no existing dailyLog found (404)
|
||||
const notFoundError = new Error("Record not found");
|
||||
(notFoundError as { status?: number }).status = 404;
|
||||
mockGetFirstListItem.mockRejectedValue(notFoundError);
|
||||
process.env.CRON_SECRET = validSecret;
|
||||
process.env.POCKETBASE_ADMIN_EMAIL = "admin@test.com";
|
||||
process.env.POCKETBASE_ADMIN_PASSWORD = "test-password";
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
@@ -141,6 +214,26 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => {
|
||||
process.env.POCKETBASE_ADMIN_EMAIL = "";
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Server misconfiguration");
|
||||
});
|
||||
|
||||
it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => {
|
||||
process.env.POCKETBASE_ADMIN_PASSWORD = "";
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Server misconfiguration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("User fetching", () => {
|
||||
@@ -177,6 +270,37 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.usersProcessed).toBe(0);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("handles date fields as ISO strings from PocketBase", async () => {
|
||||
// PocketBase returns date fields as ISO strings, not Date objects
|
||||
// This simulates the raw response from pb.collection("users").getFullList()
|
||||
const rawPocketBaseRecord = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
garminConnected: true,
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date
|
||||
garminRefreshTokenExpiresAt: "2026-02-15T00:00:00.000Z", // ISO string, not Date
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: "2025-01-01T00:00:00.000Z", // ISO string, not Date
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
created: "2024-01-01T00:00:00.000Z",
|
||||
updated: "2025-01-10T00:00:00.000Z",
|
||||
};
|
||||
// Cast to User to simulate what getFullList<User>() returns
|
||||
mockUsers = [rawPocketBaseRecord as unknown as User];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
expect(body.usersProcessed).toBe(1);
|
||||
expect(body.errors).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token handling", () => {
|
||||
@@ -188,9 +312,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
||||
});
|
||||
|
||||
it("skips users with expired tokens", async () => {
|
||||
mockIsTokenExpired.mockReturnValue(true);
|
||||
mockUsers = [createMockUser()];
|
||||
it("skips users with expired refresh tokens", async () => {
|
||||
// Set refresh token to expired (in the past)
|
||||
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -235,12 +362,15 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches intensity minutes", async () => {
|
||||
it("fetches intensity minutes with today's date", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockFetchIntensityMinutes).toHaveBeenCalledWith("mock-token-123");
|
||||
expect(mockFetchIntensityMinutes).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
"mock-token-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -310,8 +440,10 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sets date to today's date string", async () => {
|
||||
it("sets date to YYYY-MM-DD format string", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
// Simple YYYY-MM-DD format for PocketBase date field compatibility
|
||||
// PocketBase filters don't accept ISO format with T separator
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
@@ -324,6 +456,78 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DailyLog upsert behavior", () => {
|
||||
it("uses range query to find existing dailyLog", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
// Should use range query with >= and < operators, not exact match
|
||||
expect(lastDailyLogFilter).toContain(`date>="${today}"`);
|
||||
expect(lastDailyLogFilter).toContain(`date<"${tomorrow}"`);
|
||||
expect(lastDailyLogFilter).toContain('user="user123"');
|
||||
});
|
||||
|
||||
it("updates existing dailyLog when found", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
// Existing dailyLog found
|
||||
mockGetFirstListItem.mockResolvedValue({ id: "existing-log-123" });
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
// Should update, not create
|
||||
expect(mockPbUpdate).toHaveBeenCalledWith(
|
||||
"existing-log-123",
|
||||
expect.objectContaining({
|
||||
user: "user123",
|
||||
hrvStatus: "Balanced",
|
||||
}),
|
||||
);
|
||||
expect(mockPbCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates new dailyLog only when not found (404)", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
// No existing dailyLog (404 error)
|
||||
const notFoundError = new Error("Record not found");
|
||||
(notFoundError as { status?: number }).status = 404;
|
||||
mockGetFirstListItem.mockRejectedValue(notFoundError);
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
// Should create, not update
|
||||
expect(mockPbCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
user: "user123",
|
||||
}),
|
||||
);
|
||||
expect(mockPbUpdate).not.toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ user: "user123" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates non-404 errors from getFirstListItem", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
// Database error (not 404)
|
||||
const dbError = new Error("Database connection failed");
|
||||
(dbError as { status?: number }).status = 500;
|
||||
mockGetFirstListItem.mockRejectedValue(dbError);
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
// Should not try to create a new record
|
||||
expect(mockPbCreate).not.toHaveBeenCalled();
|
||||
// Should count as error
|
||||
const body = await response.json();
|
||||
expect(body.errors).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("continues processing other users when one fails", async () => {
|
||||
mockUsers = [
|
||||
@@ -356,7 +560,10 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.errors).toBe(1);
|
||||
});
|
||||
|
||||
it("handles body battery null values", async () => {
|
||||
it("stores null when body battery is null from Garmin", async () => {
|
||||
// When Garmin API returns null for body battery values (no data available),
|
||||
// we store null and the UI displays "N/A". The decision engine skips
|
||||
// body battery rules when values are null.
|
||||
mockUsers = [createMockUser()];
|
||||
mockFetchBodyBattery.mockResolvedValue({
|
||||
current: null,
|
||||
@@ -415,9 +622,28 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
|
||||
describe("Token expiration warnings", () => {
|
||||
it("sends warning email when token expires in exactly 14 days", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(14);
|
||||
// Use fake timers to ensure consistent date calculations
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// Helper to create a date N days from now
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
it("sends warning email when refresh token expires in exactly 14 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -430,9 +656,13 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.warningsSent).toBe(1);
|
||||
});
|
||||
|
||||
it("sends warning email when token expires in exactly 7 days", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(7);
|
||||
it("sends warning email when refresh token expires in exactly 7 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||
}),
|
||||
];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -445,36 +675,40 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.warningsSent).toBe(1);
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 30 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(30);
|
||||
it("does not send warning when refresh token expires in 30 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 15 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(15);
|
||||
it("does not send warning when refresh token expires in 15 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 8 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(8);
|
||||
it("does not send warning when refresh token expires in 8 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send warning when token expires in 6 days", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockDaysUntilExpiry.mockReturnValue(6);
|
||||
it("does not send warning when refresh token expires in 6 days", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -483,11 +717,17 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
|
||||
it("sends warnings for multiple users on different thresholds", async () => {
|
||||
mockUsers = [
|
||||
createMockUser({ id: "user1", email: "user1@example.com" }),
|
||||
createMockUser({ id: "user2", email: "user2@example.com" }),
|
||||
createMockUser({
|
||||
id: "user1",
|
||||
email: "user1@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
createMockUser({
|
||||
id: "user2",
|
||||
email: "user2@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||
}),
|
||||
];
|
||||
// First user at 14 days, second user at 7 days
|
||||
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
@@ -507,8 +747,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
});
|
||||
|
||||
it("continues processing sync even if warning email fails", async () => {
|
||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
||||
mockDaysUntilExpiry.mockReturnValue(14);
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
email: "user@example.com",
|
||||
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||
}),
|
||||
];
|
||||
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
||||
new Error("Email failed"),
|
||||
);
|
||||
@@ -520,10 +764,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
||||
expect(body.usersProcessed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not send warning for expired tokens", async () => {
|
||||
mockUsers = [createMockUser()];
|
||||
mockIsTokenExpired.mockReturnValue(true);
|
||||
mockDaysUntilExpiry.mockReturnValue(-1);
|
||||
it("does not send warning for expired refresh tokens", async () => {
|
||||
// Expired refresh tokens are skipped entirely (not synced), so no warning
|
||||
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
mockUsers = [
|
||||
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||
];
|
||||
|
||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
|
||||
@@ -2,31 +2,34 @@
|
||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { getCycleDay, getPhase, getPhaseLimit } from "@/lib/cycle";
|
||||
import { getCycleDay, getPhase, getUserPhaseLimit } from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||
import { decrypt } from "@/lib/encryption";
|
||||
import { decrypt, encrypt } from "@/lib/encryption";
|
||||
import {
|
||||
daysUntilExpiry,
|
||||
fetchBodyBattery,
|
||||
fetchHrvStatus,
|
||||
fetchIntensityMinutes,
|
||||
isTokenExpired,
|
||||
} from "@/lib/garmin";
|
||||
import {
|
||||
exchangeOAuth1ForOAuth2,
|
||||
isAccessTokenExpired,
|
||||
type OAuth1TokenData,
|
||||
} from "@/lib/garmin-auth";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
activeUsersGauge,
|
||||
garminSyncDuration,
|
||||
garminSyncTotal,
|
||||
} from "@/lib/metrics";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
import type { GarminTokens, User } from "@/types";
|
||||
import { createPocketBaseClient, mapRecordToUser } from "@/lib/pocketbase";
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean;
|
||||
usersProcessed: number;
|
||||
errors: number;
|
||||
skippedExpired: number;
|
||||
tokensRefreshed: number;
|
||||
warningsSent: number;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -47,63 +50,136 @@ export async function POST(request: Request) {
|
||||
usersProcessed: 0,
|
||||
errors: 0,
|
||||
skippedExpired: 0,
|
||||
tokensRefreshed: 0,
|
||||
warningsSent: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues)
|
||||
// Also filter out users without required date fields (garminTokenExpiresAt, lastPeriodDate)
|
||||
const allUsers = await pb.collection("users").getFullList<User>();
|
||||
// Authenticate as admin to bypass API rules and list all users
|
||||
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
|
||||
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
|
||||
if (!adminEmail || !adminPassword) {
|
||||
logger.error("Missing POCKETBASE_ADMIN_EMAIL or POCKETBASE_ADMIN_PASSWORD");
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfiguration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await pb
|
||||
.collection("_superusers")
|
||||
.authWithPassword(adminEmail, adminPassword);
|
||||
} catch (authError) {
|
||||
logger.error(
|
||||
{ err: authError },
|
||||
"Failed to authenticate as PocketBase admin",
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Database authentication failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all users and map to typed User objects (PocketBase returns dates as strings)
|
||||
// Filter to users with Garmin connected and required date fields
|
||||
const rawUsers = await pb.collection("users").getFullList();
|
||||
const allUsers = rawUsers.map(mapRecordToUser);
|
||||
const users = allUsers.filter(
|
||||
(u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate,
|
||||
);
|
||||
|
||||
// YYYY-MM-DD format for both Garmin API calls and PocketBase storage
|
||||
// PocketBase date filters don't accept ISO format with T separator
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
for (const user of users) {
|
||||
const userSyncStartTime = Date.now();
|
||||
|
||||
try {
|
||||
// Check if tokens are expired
|
||||
// Check if refresh token is expired (user needs to re-auth via Python script)
|
||||
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: user.garminOauth1Token,
|
||||
oauth2: user.garminOauth2Token,
|
||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||
expires_at: user.garminTokenExpiresAt!.toISOString(),
|
||||
};
|
||||
|
||||
if (isTokenExpired(tokens)) {
|
||||
result.skippedExpired++;
|
||||
continue;
|
||||
if (user.garminRefreshTokenExpiresAt) {
|
||||
const refreshTokenExpired =
|
||||
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
|
||||
if (refreshTokenExpired) {
|
||||
logger.info(
|
||||
{ userId: user.id },
|
||||
"Refresh token expired, skipping user",
|
||||
);
|
||||
result.skippedExpired++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync start
|
||||
logger.info({ userId: user.id }, "Garmin sync start");
|
||||
|
||||
// Check for token expiration warnings (exactly 14 or 7 days)
|
||||
const daysRemaining = daysUntilExpiry(tokens);
|
||||
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||
try {
|
||||
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
|
||||
result.warningsSent++;
|
||||
} catch {
|
||||
// Continue processing even if warning email fails
|
||||
// Check for refresh token expiration warnings (exactly 14 or 7 days)
|
||||
if (user.garminRefreshTokenExpiresAt) {
|
||||
const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt);
|
||||
const now = new Date();
|
||||
const diffMs = refreshExpiry.getTime() - now.getTime();
|
||||
const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||
try {
|
||||
await sendTokenExpirationWarning(
|
||||
user.email,
|
||||
daysRemaining,
|
||||
user.id,
|
||||
);
|
||||
result.warningsSent++;
|
||||
} catch {
|
||||
// Continue processing even if warning email fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt OAuth2 token
|
||||
// Decrypt tokens
|
||||
const oauth1Json = decrypt(user.garminOauth1Token);
|
||||
const oauth1Data = JSON.parse(oauth1Json) as OAuth1TokenData;
|
||||
const oauth2Json = decrypt(user.garminOauth2Token);
|
||||
const oauth2Data = JSON.parse(oauth2Json);
|
||||
const accessToken = oauth2Data.accessToken;
|
||||
let oauth2Data = JSON.parse(oauth2Json);
|
||||
|
||||
// Check if access token needs refresh
|
||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||
const accessTokenExpiresAt = user.garminTokenExpiresAt!;
|
||||
if (isAccessTokenExpired(accessTokenExpiresAt)) {
|
||||
logger.info({ userId: user.id }, "Access token expired, refreshing");
|
||||
try {
|
||||
const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data);
|
||||
oauth2Data = refreshResult.oauth2;
|
||||
|
||||
// Update stored tokens
|
||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data));
|
||||
await pb.collection("users").update(user.id, {
|
||||
garminOauth2Token: encryptedOauth2,
|
||||
garminTokenExpiresAt: refreshResult.expires_at,
|
||||
garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at,
|
||||
});
|
||||
|
||||
result.tokensRefreshed++;
|
||||
logger.info({ userId: user.id }, "Access token refreshed");
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
{ userId: user.id, err: refreshError },
|
||||
"Failed to refresh access token",
|
||||
);
|
||||
result.errors++;
|
||||
garminSyncTotal.inc({ status: "failure" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const accessToken = oauth2Data.access_token;
|
||||
|
||||
// Fetch Garmin data
|
||||
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
||||
fetchHrvStatus(today, accessToken),
|
||||
fetchBodyBattery(today, accessToken),
|
||||
fetchIntensityMinutes(accessToken),
|
||||
fetchIntensityMinutes(today, accessToken),
|
||||
]);
|
||||
|
||||
// Calculate cycle info (lastPeriodDate guaranteed non-null by filter above)
|
||||
@@ -114,24 +190,28 @@ export async function POST(request: Request) {
|
||||
new Date(),
|
||||
);
|
||||
const phase = getPhase(cycleDay, user.cycleLength);
|
||||
const phaseLimit = getPhaseLimit(phase);
|
||||
const phaseLimit = getUserPhaseLimit(phase, user);
|
||||
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
|
||||
|
||||
// Calculate training decision
|
||||
// Pass null body battery values through - decision engine handles null gracefully
|
||||
const decision = getDecisionWithOverrides(
|
||||
{
|
||||
hrvStatus,
|
||||
bbYesterdayLow: bodyBattery.yesterdayLow ?? 100,
|
||||
bbYesterdayLow: bodyBattery.yesterdayLow,
|
||||
phase,
|
||||
weekIntensity: weekIntensityMinutes,
|
||||
phaseLimit,
|
||||
bbCurrent: bodyBattery.current ?? 100,
|
||||
bbCurrent: bodyBattery.current,
|
||||
},
|
||||
user.activeOverrides,
|
||||
);
|
||||
|
||||
// Create DailyLog entry
|
||||
await pb.collection("dailyLogs").create({
|
||||
// Upsert DailyLog entry - update existing record for today or create new one
|
||||
// Store null for body battery when Garmin returns null - the UI displays "N/A"
|
||||
// and the decision engine skips body battery rules when values are null.
|
||||
// Use YYYY-MM-DD format for PocketBase date field compatibility
|
||||
const dailyLogData = {
|
||||
user: user.id,
|
||||
date: today,
|
||||
cycleDay,
|
||||
@@ -145,7 +225,39 @@ export async function POST(request: Request) {
|
||||
trainingDecision: decision.status,
|
||||
decisionReason: decision.reason,
|
||||
notificationSentAt: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Check if record already exists for this user today
|
||||
// Use range query (>= and <) to match the today route query pattern
|
||||
// This ensures we find records regardless of how the date was stored
|
||||
const tomorrow = new Date(Date.now() + 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
try {
|
||||
const existing = await pb
|
||||
.collection("dailyLogs")
|
||||
.getFirstListItem(
|
||||
`user="${user.id}" && date>="${today}" && date<"${tomorrow}"`,
|
||||
);
|
||||
await pb.collection("dailyLogs").update(existing.id, dailyLogData);
|
||||
logger.info(
|
||||
{ userId: user.id, dailyLogId: existing.id },
|
||||
"DailyLog updated",
|
||||
);
|
||||
} catch (err) {
|
||||
// Check if it's a 404 (not found) vs other error
|
||||
if ((err as { status?: number }).status === 404) {
|
||||
const created = await pb.collection("dailyLogs").create(dailyLogData);
|
||||
logger.info(
|
||||
{ userId: user.id, dailyLogId: created.id },
|
||||
"DailyLog created",
|
||||
);
|
||||
} else {
|
||||
// Propagate non-404 errors
|
||||
logger.error({ userId: user.id, err }, "Failed to upsert dailyLog");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync complete with metrics
|
||||
const userSyncDuration = Date.now() - userSyncStartTime;
|
||||
|
||||
@@ -9,7 +9,8 @@ let mockUsers: User[] = [];
|
||||
let mockDailyLogs: DailyLog[] = [];
|
||||
const mockPbUpdate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||
|
||||
// Mock PocketBase
|
||||
// Mock PocketBase with admin auth
|
||||
const mockAuthWithPassword = vi.fn().mockResolvedValue({ id: "admin" });
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({
|
||||
collection: vi.fn((name: string) => ({
|
||||
@@ -23,15 +24,32 @@ vi.mock("@/lib/pocketbase", () => ({
|
||||
return [];
|
||||
}),
|
||||
update: mockPbUpdate,
|
||||
authWithPassword: (email: string, password: string) =>
|
||||
mockAuthWithPassword(email, password),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock email sending
|
||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@/lib/email", () => ({
|
||||
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||
mockSendTokenExpirationWarning(...args),
|
||||
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||
mockSendPeriodConfirmationEmail(...args),
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
@@ -48,12 +66,18 @@ describe("POST /api/cron/notifications", () => {
|
||||
garminOauth1Token: "encrypted:oauth1-token",
|
||||
garminOauth2Token: "encrypted:oauth2-token",
|
||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -98,6 +122,9 @@ describe("POST /api/cron/notifications", () => {
|
||||
mockUsers = [];
|
||||
mockDailyLogs = [];
|
||||
process.env.CRON_SECRET = validSecret;
|
||||
process.env.POCKETBASE_ADMIN_EMAIL = "admin@example.com";
|
||||
process.env.POCKETBASE_ADMIN_PASSWORD = "admin-password";
|
||||
mockAuthWithPassword.mockResolvedValue({ id: "admin" });
|
||||
// Mock current time to 07:00 UTC
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
||||
@@ -131,6 +158,36 @@ describe("POST /api/cron/notifications", () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 500 when POCKETBASE_ADMIN_EMAIL is not set", async () => {
|
||||
process.env.POCKETBASE_ADMIN_EMAIL = "";
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Server misconfiguration");
|
||||
});
|
||||
|
||||
it("returns 500 when POCKETBASE_ADMIN_PASSWORD is not set", async () => {
|
||||
process.env.POCKETBASE_ADMIN_PASSWORD = "";
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Server misconfiguration");
|
||||
});
|
||||
|
||||
it("returns 500 when PocketBase admin auth fails", async () => {
|
||||
mockAuthWithPassword.mockRejectedValueOnce(new Error("Auth failed"));
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error).toBe("Database authentication failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("User time matching", () => {
|
||||
@@ -193,6 +250,112 @@ describe("POST /api/cron/notifications", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Quarter-hour time matching", () => {
|
||||
it("sends notification at exact 15-minute slot (07:15)", async () => {
|
||||
// Current time is 07:15 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:15", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rounds down notification time to nearest 15-minute slot (07:10 -> 07:00)", async () => {
|
||||
// Current time is 07:00 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
||||
// User set 07:10, which rounds down to 07:00 slot
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:10", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rounds down notification time (07:29 -> 07:15)", async () => {
|
||||
// Current time is 07:15 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
|
||||
// User set 07:29, which rounds down to 07:15 slot
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:29", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send notification when minute slot does not match", async () => {
|
||||
// Current time is 07:00 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
||||
// User wants 07:15, but current slot is 07:00
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:15", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles 30-minute slot correctly", async () => {
|
||||
// Current time is 07:30 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:30:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:30", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles 45-minute slot correctly", async () => {
|
||||
// Current time is 07:45 UTC
|
||||
vi.setSystemTime(new Date("2025-01-15T07:45:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({ notificationTime: "07:45", timezone: "UTC" }),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles timezone with 15-minute matching", async () => {
|
||||
// Current time is 07:15 UTC = 02:15 America/New_York (EST is UTC-5)
|
||||
vi.setSystemTime(new Date("2025-01-15T07:15:00Z"));
|
||||
mockUsers = [
|
||||
createMockUser({
|
||||
notificationTime: "02:15",
|
||||
timezone: "America/New_York",
|
||||
}),
|
||||
];
|
||||
mockDailyLogs = [createMockDailyLog()];
|
||||
|
||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockSendDailyEmail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DailyLog handling", () => {
|
||||
it("does not send notification if no DailyLog exists for today", async () => {
|
||||
mockUsers = [
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { sendDailyEmail } from "@/lib/email";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getNutritionGuidance } from "@/lib/nutrition";
|
||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||
import type { DailyLog, DecisionStatus, User } from "@/types";
|
||||
@@ -17,19 +18,40 @@ interface NotificationResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Get the current hour in a specific timezone
|
||||
function getCurrentHourInTimezone(timezone: string): number {
|
||||
// Get current quarter-hour slot (0, 15, 30, 45) in user's timezone
|
||||
function getCurrentQuarterHourSlot(timezone: string): {
|
||||
hour: number;
|
||||
minute: number;
|
||||
} {
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: false,
|
||||
});
|
||||
return parseInt(formatter.format(new Date()), 10);
|
||||
const parts = formatter.formatToParts(new Date());
|
||||
const hour = Number.parseInt(
|
||||
parts.find((p) => p.type === "hour")?.value ?? "0",
|
||||
10,
|
||||
);
|
||||
const minute = Number.parseInt(
|
||||
parts.find((p) => p.type === "minute")?.value ?? "0",
|
||||
10,
|
||||
);
|
||||
// Round down to nearest 15-min slot
|
||||
const slot = Math.floor(minute / 15) * 15;
|
||||
return { hour, minute: slot };
|
||||
}
|
||||
|
||||
// Extract hour from "HH:MM" format
|
||||
function getNotificationHour(notificationTime: string): number {
|
||||
return parseInt(notificationTime.split(":")[0], 10);
|
||||
// Extract quarter-hour slot from "HH:MM" format
|
||||
function getNotificationSlot(notificationTime: string): {
|
||||
hour: number;
|
||||
minute: number;
|
||||
} {
|
||||
const [h, m] = notificationTime.split(":").map(Number);
|
||||
// Round down to nearest 15-min slot
|
||||
const slot = Math.floor(m / 15) * 15;
|
||||
return { hour: h, minute: slot };
|
||||
}
|
||||
|
||||
// Map decision status to icon
|
||||
@@ -69,8 +91,35 @@ export async function POST(request: Request) {
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
|
||||
// Authenticate as admin to bypass API rules and list all users
|
||||
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
|
||||
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
|
||||
if (!adminEmail || !adminPassword) {
|
||||
logger.error("Missing POCKETBASE_ADMIN_EMAIL or POCKETBASE_ADMIN_PASSWORD");
|
||||
return NextResponse.json(
|
||||
{ error: "Server misconfiguration" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await pb
|
||||
.collection("_superusers")
|
||||
.authWithPassword(adminEmail, adminPassword);
|
||||
} catch (authError) {
|
||||
logger.error(
|
||||
{ err: authError },
|
||||
"Failed to authenticate as PocketBase admin",
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Database authentication failed" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all users
|
||||
const users = await pb.collection("users").getFullList<User>();
|
||||
logger.info({ userCount: users.length }, "Fetched users for notifications");
|
||||
|
||||
// Get today's date for querying daily logs
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
@@ -95,11 +144,14 @@ export async function POST(request: Request) {
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Check if current hour in user's timezone matches their notification time
|
||||
const currentHour = getCurrentHourInTimezone(user.timezone);
|
||||
const notificationHour = getNotificationHour(user.notificationTime);
|
||||
// Check if current quarter-hour slot in user's timezone matches their notification time
|
||||
const currentSlot = getCurrentQuarterHourSlot(user.timezone);
|
||||
const notificationSlot = getNotificationSlot(user.notificationTime);
|
||||
|
||||
if (currentHour !== notificationHour) {
|
||||
if (
|
||||
currentSlot.hour !== notificationSlot.hour ||
|
||||
currentSlot.minute !== notificationSlot.minute
|
||||
) {
|
||||
result.skippedWrongTime++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,29 @@ import type { User } from "@/types";
|
||||
// Module-level variable to control mock user in tests
|
||||
let currentMockUser: User | null = null;
|
||||
|
||||
// Create mock PocketBase getOne function that returns fresh user data
|
||||
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
||||
if (!currentMockUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: currentMockUser.id,
|
||||
email: currentMockUser.email,
|
||||
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
||||
cycleLength: currentMockUser.cycleLength,
|
||||
});
|
||||
});
|
||||
|
||||
// Create mock PocketBase client
|
||||
const mockPb = {
|
||||
collection: vi.fn(() => ({
|
||||
getOne: mockPbGetOne,
|
||||
})),
|
||||
};
|
||||
|
||||
// Mock PocketBase client for database operations
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
createPocketBaseClient: vi.fn(() => ({})),
|
||||
createPocketBaseClient: vi.fn(() => mockPb),
|
||||
loadAuthFromCookies: vi.fn(),
|
||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
||||
getCurrentUser: vi.fn(() => currentMockUser),
|
||||
@@ -24,7 +44,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
||||
if (!currentMockUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
return handler(request, currentMockUser);
|
||||
return handler(request, currentMockUser, mockPb);
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -39,12 +59,18 @@ describe("GET /api/cycle/current", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -116,11 +142,11 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.phaseConfig).toBeDefined();
|
||||
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(120);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(150);
|
||||
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
|
||||
// Phase configs days are for reference; actual boundaries are calculated dynamically
|
||||
expect(body.phaseConfig.days).toEqual([4, 15]);
|
||||
expect(body.phaseConfig.dailyAvg).toBe(17);
|
||||
expect(body.phaseConfig.dailyAvg).toBe(21);
|
||||
});
|
||||
|
||||
it("calculates daysUntilNextPhase correctly", async () => {
|
||||
@@ -153,7 +179,7 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.cycleDay).toBe(3);
|
||||
expect(body.phase).toBe("MENSTRUAL");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(30);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(75);
|
||||
expect(body.daysUntilNextPhase).toBe(1); // Day 4 is FOLLICULAR
|
||||
});
|
||||
|
||||
@@ -173,7 +199,7 @@ describe("GET /api/cycle/current", () => {
|
||||
|
||||
expect(body.cycleDay).toBe(16);
|
||||
expect(body.phase).toBe("OVULATION");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(80);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(100);
|
||||
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
|
||||
});
|
||||
|
||||
|
||||
@@ -40,9 +40,18 @@ function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
|
||||
return nextPhaseStart - cycleDay;
|
||||
}
|
||||
|
||||
export const GET = withAuth(async (_request, user) => {
|
||||
export const GET = withAuth(async (_request, user, pb) => {
|
||||
// Fetch fresh user data from database to get latest values
|
||||
// The user param from withAuth is from auth store cache which may be stale
|
||||
const freshUser = await pb.collection("users").getOne(user.id);
|
||||
|
||||
// Validate user has required cycle data
|
||||
if (!user.lastPeriodDate) {
|
||||
const lastPeriodDate = freshUser.lastPeriodDate
|
||||
? new Date(freshUser.lastPeriodDate as string)
|
||||
: null;
|
||||
const cycleLength = (freshUser.cycleLength as number) || 28;
|
||||
|
||||
if (!lastPeriodDate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
@@ -53,20 +62,16 @@ export const GET = withAuth(async (_request, user) => {
|
||||
}
|
||||
|
||||
// Calculate current cycle position
|
||||
const cycleDay = getCycleDay(
|
||||
user.lastPeriodDate,
|
||||
user.cycleLength,
|
||||
new Date(),
|
||||
);
|
||||
const phase = getPhase(cycleDay, user.cycleLength);
|
||||
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||
const phase = getPhase(cycleDay, cycleLength);
|
||||
const phaseConfig = getPhaseConfig(phase);
|
||||
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength);
|
||||
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, cycleLength);
|
||||
|
||||
return NextResponse.json({
|
||||
cycleDay,
|
||||
phase,
|
||||
phaseConfig,
|
||||
daysUntilNextPhase,
|
||||
cycleLength: user.cycleLength,
|
||||
cycleLength,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,12 +43,18 @@ describe("POST /api/cycle/period", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2024-12-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -71,12 +71,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -100,12 +106,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -129,12 +141,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -156,12 +174,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -185,12 +209,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: pastDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -216,12 +246,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -245,12 +281,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -274,12 +316,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -303,12 +351,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "encrypted-token",
|
||||
garminOauth2Token: "encrypted-token",
|
||||
garminTokenExpiresAt: futureDate,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -329,12 +383,18 @@ describe("GET /api/garmin/status", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -8,29 +8,45 @@ import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
|
||||
export const GET = withAuth(async (_request, user, pb) => {
|
||||
// Fetch fresh user data from database (auth store cookie may be stale)
|
||||
const freshUser = await pb.collection("users").getOne(user.id);
|
||||
const connected = freshUser.garminConnected;
|
||||
// Use strict equality to handle undefined (field missing from schema)
|
||||
const connected = freshUser.garminConnected === true;
|
||||
|
||||
if (!connected) {
|
||||
return NextResponse.json({
|
||||
connected: false,
|
||||
daysUntilExpiry: null,
|
||||
expired: false,
|
||||
warningLevel: null,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
connected: false,
|
||||
daysUntilExpiry: null,
|
||||
expired: false,
|
||||
warningLevel: null,
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = freshUser.garminTokenExpiresAt
|
||||
// Use refresh token expiry for user-facing warnings (when they need to re-auth)
|
||||
// Fall back to access token expiry if refresh expiry not set
|
||||
const refreshTokenExpiresAt = freshUser.garminRefreshTokenExpiresAt
|
||||
? String(freshUser.garminRefreshTokenExpiresAt)
|
||||
: "";
|
||||
const accessTokenExpiresAt = freshUser.garminTokenExpiresAt
|
||||
? String(freshUser.garminTokenExpiresAt)
|
||||
: "";
|
||||
|
||||
const tokens = {
|
||||
oauth1: "",
|
||||
oauth2: "",
|
||||
expires_at: expiresAt,
|
||||
expires_at: accessTokenExpiresAt,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
|
||||
};
|
||||
|
||||
const days = daysUntilExpiry(tokens);
|
||||
const expired = isTokenExpired(tokens);
|
||||
|
||||
// Check if refresh token is expired (user needs to re-authenticate)
|
||||
const expired = refreshTokenExpiresAt
|
||||
? new Date(refreshTokenExpiresAt) <= new Date()
|
||||
: isTokenExpired(tokens);
|
||||
|
||||
let warningLevel: "warning" | "critical" | null = null;
|
||||
if (days <= 7) {
|
||||
@@ -39,10 +55,15 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
warningLevel = "warning";
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connected: true,
|
||||
daysUntilExpiry: days,
|
||||
expired,
|
||||
warningLevel,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
connected: true,
|
||||
daysUntilExpiry: days,
|
||||
expired,
|
||||
warningLevel,
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,10 +12,14 @@ let currentMockUser: User | null = null;
|
||||
// Track PocketBase update calls
|
||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||
|
||||
// Track PocketBase getOne calls - returns user with garminConnected: true after update
|
||||
const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true });
|
||||
|
||||
// Create mock PocketBase client
|
||||
const mockPb = {
|
||||
collection: vi.fn(() => ({
|
||||
update: mockPbUpdate,
|
||||
getOne: mockPbGetOne,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -49,12 +53,18 @@ describe("POST /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -137,6 +147,7 @@ describe("POST /api/garmin/tokens", () => {
|
||||
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
||||
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
||||
garminTokenExpiresAt: expiresAt,
|
||||
garminRefreshTokenExpiresAt: expect.any(String),
|
||||
garminConnected: true,
|
||||
});
|
||||
});
|
||||
@@ -263,12 +274,18 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -300,6 +317,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: null,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
garminConnected: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,11 @@ import { NextResponse } from "next/server";
|
||||
import { withAuth } from "@/lib/auth-middleware";
|
||||
import { encrypt } from "@/lib/encryption";
|
||||
import { daysUntilExpiry } from "@/lib/garmin";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const POST = withAuth(async (request, user, pb) => {
|
||||
const body = await request.json();
|
||||
const { oauth1, oauth2, expires_at } = body;
|
||||
const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!oauth1) {
|
||||
@@ -51,6 +52,23 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate refresh_token_expires_at if provided
|
||||
let refreshTokenExpiresAt = refresh_token_expires_at;
|
||||
if (refreshTokenExpiresAt) {
|
||||
const refreshExpiryDate = new Date(refreshTokenExpiresAt);
|
||||
if (Number.isNaN(refreshExpiryDate.getTime())) {
|
||||
return NextResponse.json(
|
||||
{ error: "refresh_token_expires_at must be a valid date" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If not provided, estimate refresh token expiry as ~30 days from now
|
||||
refreshTokenExpiresAt = new Date(
|
||||
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
// Encrypt tokens before storing
|
||||
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
||||
@@ -60,14 +78,36 @@ export const POST = withAuth(async (request, user, pb) => {
|
||||
garminOauth1Token: encryptedOauth1,
|
||||
garminOauth2Token: encryptedOauth2,
|
||||
garminTokenExpiresAt: expires_at,
|
||||
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||
garminConnected: true,
|
||||
});
|
||||
|
||||
// Calculate days until expiry
|
||||
// Verify the update persisted (catches schema issues where field doesn't exist)
|
||||
const updatedUser = await pb.collection("users").getOne(user.id);
|
||||
if (updatedUser.garminConnected !== true) {
|
||||
logger.error(
|
||||
{
|
||||
userId: user.id,
|
||||
expected: true,
|
||||
actual: updatedUser.garminConnected,
|
||||
},
|
||||
"garminConnected field not persisted - check PocketBase schema",
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Failed to save connection status. The garminConnected field may be missing from the database schema. Run pnpm db:setup to fix.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate days until refresh token expiry (what users care about)
|
||||
const expiryDays = daysUntilExpiry({
|
||||
oauth1: "",
|
||||
oauth2: "",
|
||||
expires_at,
|
||||
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -82,6 +122,7 @@ export const DELETE = withAuth(async (_request, user, pb) => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: null,
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
garminConnected: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,12 +41,18 @@ describe("GET /api/history", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -55,12 +55,18 @@ describe("POST /api/overrides", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: overrides,
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
});
|
||||
@@ -187,12 +193,18 @@ describe("DELETE /api/overrides", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: overrides,
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
});
|
||||
|
||||
@@ -41,12 +41,18 @@ describe("GET /api/period-history", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
@@ -50,12 +50,18 @@ describe("PATCH /api/period-logs/[id]", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -276,12 +282,18 @@ describe("DELETE /api/period-logs/[id]", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
|
||||
59
src/app/api/test/email/route.ts
Normal file
59
src/app/api/test/email/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// ABOUTME: Test endpoint for verifying email configuration.
|
||||
// ABOUTME: Sends a test email to verify Mailgun integration works.
|
||||
import { NextResponse } from "next/server";
|
||||
import type { DailyEmailData } from "@/lib/email";
|
||||
import { sendDailyEmail } from "@/lib/email";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify cron secret (reuse same auth as cron endpoints)
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const expectedSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!expectedSecret || authHeader !== `Bearer ${expectedSecret}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const to = body.to as string;
|
||||
|
||||
if (!to) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing 'to' email address in request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const testData: DailyEmailData = {
|
||||
to,
|
||||
cycleDay: 15,
|
||||
phase: "OVULATION",
|
||||
decision: {
|
||||
status: "TRAIN",
|
||||
reason: "This is a test email to verify Mailgun configuration works!",
|
||||
icon: "🧪",
|
||||
},
|
||||
bodyBatteryCurrent: 85,
|
||||
bodyBatteryYesterdayLow: 45,
|
||||
hrvStatus: "Balanced",
|
||||
weekIntensity: 60,
|
||||
phaseLimit: 80,
|
||||
remainingMinutes: 20,
|
||||
seeds: "Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||
carbRange: "100-150g",
|
||||
ketoGuidance: "No - exit keto, need carbs for ovulation",
|
||||
};
|
||||
|
||||
try {
|
||||
await sendDailyEmail(testData, "test-user");
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Test email sent to ${to}`,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json(
|
||||
{ success: false, error: message },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,64 @@ import type { DailyLog, User } from "@/types";
|
||||
// Module-level variable to control mock user in tests
|
||||
let currentMockUser: User | null = null;
|
||||
|
||||
// Module-level variable to control mock daily log in tests
|
||||
// Module-level variable to control mock daily log for today in tests
|
||||
let currentMockDailyLog: DailyLog | null = null;
|
||||
|
||||
// Module-level variable to control mock daily log for fallback (most recent)
|
||||
let fallbackMockDailyLog: DailyLog | null = null;
|
||||
|
||||
// Track the filter string passed to getFirstListItem
|
||||
let lastDailyLogFilter: string | null = null;
|
||||
|
||||
// Create mock PocketBase client
|
||||
const mockPb = {
|
||||
collection: vi.fn(() => ({
|
||||
getFirstListItem: vi.fn(async () => {
|
||||
if (!currentMockDailyLog) {
|
||||
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 (filter: string) => {
|
||||
// Capture the filter for testing
|
||||
if (collectionName === "dailyLogs") {
|
||||
lastDailyLogFilter = filter;
|
||||
|
||||
// Check if this is a query for today's log (has date range filter)
|
||||
const isTodayQuery =
|
||||
filter.includes("date>=") && filter.includes("date<");
|
||||
if (isTodayQuery) {
|
||||
if (!currentMockDailyLog) {
|
||||
const error = new Error("No DailyLog found for today");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return currentMockDailyLog;
|
||||
}
|
||||
|
||||
// This is the fallback query for most recent log
|
||||
if (fallbackMockDailyLog) {
|
||||
return fallbackMockDailyLog;
|
||||
}
|
||||
if (currentMockDailyLog) {
|
||||
return currentMockDailyLog;
|
||||
}
|
||||
const error = new Error("No DailyLog found");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
return currentMockDailyLog;
|
||||
const error = new Error("No DailyLog found");
|
||||
(error as { status?: number }).status = 404;
|
||||
throw error;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
@@ -48,12 +93,18 @@ describe("GET /api/today", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
notificationTime: "07:00",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
...overrides,
|
||||
@@ -84,6 +135,8 @@ describe("GET /api/today", () => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = null;
|
||||
lastDailyLogFilter = null;
|
||||
// Mock current date to 2025-01-10 for predictable testing
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-01-10T12:00:00Z"));
|
||||
@@ -345,7 +398,7 @@ describe("GET /api/today", () => {
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(120);
|
||||
expect(body.phaseConfig.weeklyLimit).toBe(150);
|
||||
});
|
||||
|
||||
it("returns days until next phase", async () => {
|
||||
@@ -492,6 +545,24 @@ describe("GET /api/today", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyLog query", () => {
|
||||
it("queries dailyLogs with YYYY-MM-DD date format using range operators", async () => {
|
||||
// PocketBase accepts simple YYYY-MM-DD in comparison operators
|
||||
// Use >= today and < tomorrow for exact day match
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = createMockDailyLog();
|
||||
|
||||
await GET(mockRequest);
|
||||
|
||||
// Verify filter uses YYYY-MM-DD format with range operators
|
||||
expect(lastDailyLogFilter).toBeDefined();
|
||||
expect(lastDailyLogFilter).toContain('date>="2025-01-10"');
|
||||
expect(lastDailyLogFilter).toContain('date<"2025-01-11"');
|
||||
// Should NOT contain ISO format with T separator
|
||||
expect(lastDailyLogFilter).not.toContain("T");
|
||||
});
|
||||
});
|
||||
|
||||
describe("biometrics data", () => {
|
||||
it("returns biometrics from daily log when available", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
@@ -524,10 +595,10 @@ describe("GET /api/today", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
// Defaults when no Garmin data
|
||||
// Defaults when no Garmin data - null values indicate no data available
|
||||
expect(body.biometrics.hrvStatus).toBe("Unknown");
|
||||
expect(body.biometrics.bodyBatteryCurrent).toBe(100);
|
||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100);
|
||||
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
|
||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
|
||||
expect(body.biometrics.weekIntensityMinutes).toBe(0);
|
||||
});
|
||||
|
||||
@@ -540,9 +611,95 @@ describe("GET /api/today", () => {
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
// With defaults (BB=100, HRV=Unknown), should allow training
|
||||
// unless in restrictive phase
|
||||
// With null body battery, decision engine skips BB rules
|
||||
// and allows training unless in restrictive phase
|
||||
expect(body.decision.status).toBe("TRAIN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DailyLog fallback to most recent", () => {
|
||||
it("returns lastSyncedAt as today when today's DailyLog exists", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = createMockDailyLog();
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBe("2025-01-10");
|
||||
});
|
||||
|
||||
it("uses yesterday's DailyLog when today's does not exist", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null; // No today's log
|
||||
// Yesterday's log with different biometrics
|
||||
fallbackMockDailyLog = createMockDailyLog({
|
||||
date: new Date("2025-01-09"),
|
||||
hrvStatus: "Balanced",
|
||||
bodyBatteryCurrent: 72,
|
||||
bodyBatteryYesterdayLow: 38,
|
||||
weekIntensityMinutes: 90,
|
||||
phaseLimit: 150,
|
||||
});
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
// Should use fallback data
|
||||
expect(body.biometrics.hrvStatus).toBe("Balanced");
|
||||
expect(body.biometrics.bodyBatteryCurrent).toBe(72);
|
||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(38);
|
||||
expect(body.biometrics.weekIntensityMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it("returns lastSyncedAt as yesterday's date when using fallback", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = createMockDailyLog({
|
||||
date: new Date("2025-01-09"),
|
||||
});
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBe("2025-01-09");
|
||||
});
|
||||
|
||||
it("returns null lastSyncedAt when no logs exist at all", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = null;
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBeNull();
|
||||
// Should use DEFAULT_BIOMETRICS with null for body battery
|
||||
expect(body.biometrics.hrvStatus).toBe("Unknown");
|
||||
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
|
||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
|
||||
});
|
||||
|
||||
it("handles fallback log with string date format", async () => {
|
||||
currentMockUser = createMockUser();
|
||||
currentMockDailyLog = null;
|
||||
fallbackMockDailyLog = createMockDailyLog({
|
||||
date: "2025-01-08T10:00:00Z" as unknown as Date,
|
||||
});
|
||||
|
||||
const response = await GET(mockRequest);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.lastSyncedAt).toBe("2025-01-08");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,29 +7,36 @@ import {
|
||||
getCycleDay,
|
||||
getPhase,
|
||||
getPhaseConfig,
|
||||
getPhaseLimit,
|
||||
getUserPhaseLimit,
|
||||
} from "@/lib/cycle";
|
||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
|
||||
import { mapRecordToUser } from "@/lib/pocketbase";
|
||||
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
||||
|
||||
// Default biometrics when no Garmin data is available
|
||||
const DEFAULT_BIOMETRICS: {
|
||||
hrvStatus: HrvStatus;
|
||||
bodyBatteryCurrent: number;
|
||||
bodyBatteryYesterdayLow: number;
|
||||
bodyBatteryCurrent: number | null;
|
||||
bodyBatteryYesterdayLow: number | null;
|
||||
weekIntensityMinutes: number;
|
||||
} = {
|
||||
hrvStatus: "Unknown",
|
||||
bodyBatteryCurrent: 100,
|
||||
bodyBatteryYesterdayLow: 100,
|
||||
bodyBatteryCurrent: null,
|
||||
bodyBatteryYesterdayLow: null,
|
||||
weekIntensityMinutes: 0,
|
||||
};
|
||||
|
||||
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 freshUserRecord = await pb.collection("users").getOne(user.id);
|
||||
const freshUser = mapRecordToUser(freshUserRecord);
|
||||
|
||||
// Validate required user data
|
||||
if (!user.lastPeriodDate) {
|
||||
if (!freshUser.lastPeriodDate) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
@@ -38,54 +45,112 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const lastPeriodDate = freshUser.lastPeriodDate;
|
||||
const cycleLength = freshUser.cycleLength;
|
||||
const activeOverrides = freshUser.activeOverrides || [];
|
||||
|
||||
// Calculate cycle information
|
||||
const cycleDay = getCycleDay(
|
||||
new Date(user.lastPeriodDate),
|
||||
user.cycleLength,
|
||||
new Date(),
|
||||
);
|
||||
const phase = getPhase(cycleDay, user.cycleLength);
|
||||
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||
const phase = getPhase(cycleDay, cycleLength);
|
||||
const phaseConfig = getPhaseConfig(phase);
|
||||
const phaseLimit = getPhaseLimit(phase);
|
||||
const phaseLimit = getUserPhaseLimit(phase, freshUser);
|
||||
|
||||
// Calculate days until next phase using dynamic boundaries
|
||||
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
|
||||
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
|
||||
let daysUntilNextPhase: number;
|
||||
if (phase === "LATE_LUTEAL") {
|
||||
daysUntilNextPhase = user.cycleLength - cycleDay + 1;
|
||||
daysUntilNextPhase = cycleLength - cycleDay + 1;
|
||||
} else if (phase === "MENSTRUAL") {
|
||||
daysUntilNextPhase = 4 - cycleDay;
|
||||
} else if (phase === "FOLLICULAR") {
|
||||
daysUntilNextPhase = user.cycleLength - 15 - cycleDay;
|
||||
daysUntilNextPhase = cycleLength - 15 - cycleDay;
|
||||
} else if (phase === "OVULATION") {
|
||||
daysUntilNextPhase = user.cycleLength - 13 - cycleDay;
|
||||
daysUntilNextPhase = cycleLength - 13 - cycleDay;
|
||||
} else {
|
||||
// EARLY_LUTEAL
|
||||
daysUntilNextPhase = user.cycleLength - 6 - cycleDay;
|
||||
daysUntilNextPhase = cycleLength - 6 - cycleDay;
|
||||
}
|
||||
|
||||
// Try to fetch today's DailyLog for biometrics
|
||||
// Try to fetch today's DailyLog for biometrics, fall back to most recent
|
||||
// Sort by date DESC to get the most recent record if multiple exist
|
||||
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
|
||||
let lastSyncedAt: string | null = null;
|
||||
|
||||
// Use YYYY-MM-DD format with >= and < operators for PocketBase date field
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const tomorrow = new Date(Date.now() + 86400000).toISOString().split("T")[0];
|
||||
|
||||
logger.info({ userId: user.id, today, tomorrow }, "Fetching dailyLog");
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
// First try to get today's log
|
||||
const dailyLog = await pb
|
||||
.collection("dailyLogs")
|
||||
.getFirstListItem<DailyLog>(`user="${user.id}" && date~"${today}"`);
|
||||
.getFirstListItem<DailyLog>(
|
||||
`user="${user.id}" && date>="${today}" && date<"${tomorrow}"`,
|
||||
{ sort: "-date" },
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId: user.id,
|
||||
dailyLogId: dailyLog.id,
|
||||
hrvStatus: dailyLog.hrvStatus,
|
||||
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
||||
},
|
||||
"Found dailyLog for today",
|
||||
);
|
||||
|
||||
biometrics = {
|
||||
hrvStatus: dailyLog.hrvStatus,
|
||||
bodyBatteryCurrent:
|
||||
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
|
||||
bodyBatteryYesterdayLow:
|
||||
dailyLog.bodyBatteryYesterdayLow ??
|
||||
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
|
||||
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
||||
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
|
||||
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
|
||||
phaseLimit: dailyLog.phaseLimit,
|
||||
};
|
||||
lastSyncedAt = today;
|
||||
} catch {
|
||||
// No daily log found - use defaults
|
||||
// No today's log - try to get most recent
|
||||
logger.info(
|
||||
{ userId: user.id },
|
||||
"No dailyLog for today, trying most recent",
|
||||
);
|
||||
try {
|
||||
const dailyLog = await pb
|
||||
.collection("dailyLogs")
|
||||
.getFirstListItem<DailyLog>(`user="${user.id}"`, { sort: "-date" });
|
||||
|
||||
// Extract date from the log for "last synced" indicator
|
||||
const dateValue = dailyLog.date as unknown as string | Date;
|
||||
lastSyncedAt =
|
||||
typeof dateValue === "string"
|
||||
? dateValue.split("T")[0]
|
||||
: dateValue.toISOString().split("T")[0];
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId: user.id,
|
||||
dailyLogId: dailyLog.id,
|
||||
lastSyncedAt,
|
||||
},
|
||||
"Using most recent dailyLog as fallback",
|
||||
);
|
||||
|
||||
biometrics = {
|
||||
hrvStatus: dailyLog.hrvStatus,
|
||||
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
||||
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
|
||||
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
|
||||
phaseLimit: dailyLog.phaseLimit,
|
||||
};
|
||||
} catch {
|
||||
// No logs at all - truly new user
|
||||
logger.warn(
|
||||
{ userId: user.id },
|
||||
"No dailyLog found at all, using defaults",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build DailyData for decision engine
|
||||
@@ -99,7 +164,10 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
};
|
||||
|
||||
// Get training decision with override handling
|
||||
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
|
||||
const decision = getDecisionWithOverrides(
|
||||
dailyData,
|
||||
activeOverrides as import("@/types").OverrideType[],
|
||||
);
|
||||
|
||||
// Log decision calculation per observability spec
|
||||
logger.info(
|
||||
@@ -120,8 +188,9 @@ export const GET = withAuth(async (_request, user, pb) => {
|
||||
phase,
|
||||
phaseConfig,
|
||||
daysUntilNextPhase,
|
||||
cycleLength: user.cycleLength,
|
||||
cycleLength,
|
||||
biometrics,
|
||||
nutrition,
|
||||
lastSyncedAt,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,29 @@ let currentMockUser: User | null = null;
|
||||
// Track PocketBase update calls
|
||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||
|
||||
// Track PocketBase getOne calls - returns the current mock user data
|
||||
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
||||
if (!currentMockUser) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: currentMockUser.id,
|
||||
email: currentMockUser.email,
|
||||
garminConnected: currentMockUser.garminConnected,
|
||||
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
||||
cycleLength: currentMockUser.cycleLength,
|
||||
notificationTime: currentMockUser.notificationTime,
|
||||
timezone: currentMockUser.timezone,
|
||||
activeOverrides: currentMockUser.activeOverrides,
|
||||
calendarToken: currentMockUser.calendarToken,
|
||||
});
|
||||
});
|
||||
|
||||
// Create mock PocketBase client
|
||||
const mockPb = {
|
||||
collection: vi.fn(() => ({
|
||||
update: mockPbUpdate,
|
||||
getOne: mockPbGetOne,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -41,12 +60,18 @@ describe("GET /api/user", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -55,6 +80,7 @@ describe("GET /api/user", () => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
mockPbUpdate.mockClear();
|
||||
mockPbGetOne.mockClear();
|
||||
});
|
||||
|
||||
it("returns user profile when authenticated", async () => {
|
||||
@@ -76,17 +102,27 @@ describe("GET /api/user", () => {
|
||||
expect(body.timezone).toBe("America/New_York");
|
||||
});
|
||||
|
||||
it("does not expose sensitive token fields", async () => {
|
||||
it("does not expose sensitive Garmin token fields", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await GET(mockRequest);
|
||||
const body = await response.json();
|
||||
|
||||
// Should NOT include encrypted tokens
|
||||
// Should NOT include encrypted Garmin tokens
|
||||
expect(body.garminOauth1Token).toBeUndefined();
|
||||
expect(body.garminOauth2Token).toBeUndefined();
|
||||
expect(body.calendarToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes calendarToken for calendar subscription URL", async () => {
|
||||
currentMockUser = mockUser;
|
||||
|
||||
const mockRequest = {} as NextRequest;
|
||||
const response = await GET(mockRequest);
|
||||
const body = await response.json();
|
||||
|
||||
// calendarToken is needed by the calendar page to display the subscription URL
|
||||
expect(body.calendarToken).toBe("cal-secret-token");
|
||||
});
|
||||
|
||||
it("includes activeOverrides array", async () => {
|
||||
@@ -119,12 +155,18 @@ describe("PATCH /api/user", () => {
|
||||
garminOauth1Token: "encrypted-token-1",
|
||||
garminOauth2Token: "encrypted-token-2",
|
||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-secret-token",
|
||||
lastPeriodDate: new Date("2025-01-15"),
|
||||
cycleLength: 28,
|
||||
notificationTime: "07:30",
|
||||
timezone: "America/New_York",
|
||||
activeOverrides: ["flare"],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date("2024-01-01"),
|
||||
updated: new Date("2025-01-10"),
|
||||
};
|
||||
@@ -133,6 +175,7 @@ describe("PATCH /api/user", () => {
|
||||
vi.clearAllMocks();
|
||||
currentMockUser = null;
|
||||
mockPbUpdate.mockClear();
|
||||
mockPbGetOne.mockClear();
|
||||
});
|
||||
|
||||
// Helper to create mock request with JSON body
|
||||
@@ -370,9 +413,8 @@ describe("PATCH /api/user", () => {
|
||||
expect(body.cycleLength).toBe(32);
|
||||
expect(body.notificationTime).toBe("07:30");
|
||||
expect(body.timezone).toBe("America/New_York");
|
||||
// Should not expose sensitive fields
|
||||
// Should not expose sensitive Garmin token fields
|
||||
expect(body.garminOauth1Token).toBeUndefined();
|
||||
expect(body.garminOauth2Token).toBeUndefined();
|
||||
expect(body.calendarToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,24 +13,35 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
/**
|
||||
* GET /api/user
|
||||
* Returns the authenticated user's profile.
|
||||
* Fetches fresh data from database to ensure updates are reflected.
|
||||
* Excludes sensitive fields like encrypted tokens.
|
||||
*/
|
||||
export const GET = withAuth(async (_request, user, _pb) => {
|
||||
export const GET = withAuth(async (_request, user, pb) => {
|
||||
// Fetch fresh user data from database to get latest values
|
||||
// The user param from withAuth is from auth store cache which may be stale
|
||||
const freshUser = await pb.collection("users").getOne(user.id);
|
||||
|
||||
// Format date for consistent API response
|
||||
const lastPeriodDate = user.lastPeriodDate
|
||||
? user.lastPeriodDate.toISOString().split("T")[0]
|
||||
const lastPeriodDate = freshUser.lastPeriodDate
|
||||
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
||||
: null;
|
||||
|
||||
return NextResponse.json({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
garminConnected: user.garminConnected,
|
||||
cycleLength: user.cycleLength,
|
||||
lastPeriodDate,
|
||||
notificationTime: user.notificationTime,
|
||||
timezone: user.timezone,
|
||||
activeOverrides: user.activeOverrides,
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: freshUser.id,
|
||||
email: freshUser.email,
|
||||
garminConnected: freshUser.garminConnected ?? false,
|
||||
cycleLength: freshUser.cycleLength,
|
||||
lastPeriodDate,
|
||||
notificationTime: freshUser.notificationTime,
|
||||
timezone: freshUser.timezone,
|
||||
activeOverrides: freshUser.activeOverrides ?? [],
|
||||
calendarToken: (freshUser.calendarToken as string) || null,
|
||||
},
|
||||
{
|
||||
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,7 +139,12 @@ export default function GarminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const showTokenInput = !status?.connected || status?.expired;
|
||||
// Show token input when:
|
||||
// - Not connected
|
||||
// - Token expired
|
||||
// - Warning level active (so user can proactively paste new tokens)
|
||||
const showTokenInput =
|
||||
!status?.connected || status?.expired || status?.warningLevel;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -242,7 +247,11 @@ export default function GarminSettingsPage() {
|
||||
{/* Token Input Section */}
|
||||
{showTokenInput && (
|
||||
<div className="border border-input rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
{status?.connected && status?.warningLevel
|
||||
? "Refresh Tokens"
|
||||
: "Connect Garmin"}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">
|
||||
|
||||
@@ -37,6 +37,11 @@ describe("SettingsPage", () => {
|
||||
garminConnected: false,
|
||||
activeOverrides: [],
|
||||
lastPeriodDate: "2024-01-01",
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -240,6 +245,11 @@ describe("SettingsPage", () => {
|
||||
cycleLength: 30,
|
||||
notificationTime: "08:00",
|
||||
timezone: "America/New_York",
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -639,4 +649,172 @@ describe("SettingsPage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("intensity goals section", () => {
|
||||
it("renders Weekly Intensity Goals section heading", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /weekly intensity goals/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders input for menstrual phase goal", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders input for follicular phase goal", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/follicular/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders input for ovulation phase goal", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/ovulation/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders input for early luteal phase goal", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/early luteal/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders input for late luteal phase goal", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/late luteal/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("pre-fills intensity goal inputs with current user values", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toHaveValue(75);
|
||||
expect(screen.getByLabelText(/follicular/i)).toHaveValue(150);
|
||||
expect(screen.getByLabelText(/ovulation/i)).toHaveValue(100);
|
||||
expect(screen.getByLabelText(/early luteal/i)).toHaveValue(120);
|
||||
expect(screen.getByLabelText(/late luteal/i)).toHaveValue(50);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes intensity goals in PATCH request when saving", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ ...mockUser, intensityGoalMenstrual: 80 }),
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const menstrualInput = screen.getByLabelText(/menstrual/i);
|
||||
fireEvent.change(menstrualInput, { target: { value: "80" } });
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith("/api/user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: expect.stringContaining('"intensityGoalMenstrual":80'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("has number type for all intensity goal inputs", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute(
|
||||
"type",
|
||||
"number",
|
||||
);
|
||||
expect(screen.getByLabelText(/follicular/i)).toHaveAttribute(
|
||||
"type",
|
||||
"number",
|
||||
);
|
||||
expect(screen.getByLabelText(/ovulation/i)).toHaveAttribute(
|
||||
"type",
|
||||
"number",
|
||||
);
|
||||
expect(screen.getByLabelText(/early luteal/i)).toHaveAttribute(
|
||||
"type",
|
||||
"number",
|
||||
);
|
||||
expect(screen.getByLabelText(/late luteal/i)).toHaveAttribute(
|
||||
"type",
|
||||
"number",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("validates minimum value of 0 for intensity goals", async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toHaveAttribute("min", "0");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables intensity goal inputs while saving", async () => {
|
||||
let resolveSave: (value: unknown) => void = () => {};
|
||||
const savePromise = new Promise((resolve) => {
|
||||
resolveSave = resolve;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockUser),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
ok: true,
|
||||
json: () => savePromise,
|
||||
});
|
||||
|
||||
render(<SettingsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/menstrual/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/follicular/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/ovulation/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/early luteal/i)).toBeDisabled();
|
||||
expect(screen.getByLabelText(/late luteal/i)).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveSave(mockUser);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,11 @@ interface UserData {
|
||||
garminConnected: boolean;
|
||||
activeOverrides: string[];
|
||||
lastPeriodDate: string | null;
|
||||
intensityGoalMenstrual: number;
|
||||
intensityGoalFollicular: number;
|
||||
intensityGoalOvulation: number;
|
||||
intensityGoalEarlyLuteal: number;
|
||||
intensityGoalLateLuteal: number;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -29,6 +34,11 @@ export default function SettingsPage() {
|
||||
const [cycleLength, setCycleLength] = useState(28);
|
||||
const [notificationTime, setNotificationTime] = useState("08:00");
|
||||
const [timezone, setTimezone] = useState("");
|
||||
const [intensityGoalMenstrual, setIntensityGoalMenstrual] = useState(75);
|
||||
const [intensityGoalFollicular, setIntensityGoalFollicular] = useState(150);
|
||||
const [intensityGoalOvulation, setIntensityGoalOvulation] = useState(100);
|
||||
const [intensityGoalEarlyLuteal, setIntensityGoalEarlyLuteal] = useState(120);
|
||||
const [intensityGoalLateLuteal, setIntensityGoalLateLuteal] = useState(50);
|
||||
|
||||
const fetchUserData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -46,6 +56,11 @@ export default function SettingsPage() {
|
||||
setCycleLength(data.cycleLength);
|
||||
setNotificationTime(data.notificationTime);
|
||||
setTimezone(data.timezone);
|
||||
setIntensityGoalMenstrual(data.intensityGoalMenstrual ?? 75);
|
||||
setIntensityGoalFollicular(data.intensityGoalFollicular ?? 150);
|
||||
setIntensityGoalOvulation(data.intensityGoalOvulation ?? 100);
|
||||
setIntensityGoalEarlyLuteal(data.intensityGoalEarlyLuteal ?? 120);
|
||||
setIntensityGoalLateLuteal(data.intensityGoalLateLuteal ?? 50);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "An error occurred";
|
||||
setLoadError(message);
|
||||
@@ -79,6 +94,11 @@ export default function SettingsPage() {
|
||||
cycleLength,
|
||||
notificationTime,
|
||||
timezone,
|
||||
intensityGoalMenstrual,
|
||||
intensityGoalFollicular,
|
||||
intensityGoalOvulation,
|
||||
intensityGoalEarlyLuteal,
|
||||
intensityGoalLateLuteal,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -250,6 +270,132 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">
|
||||
Weekly Intensity Goals
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Target weekly intensity minutes for each cycle phase
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="intensityGoalMenstrual"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Menstrual
|
||||
</label>
|
||||
<input
|
||||
id="intensityGoalMenstrual"
|
||||
type="number"
|
||||
min="0"
|
||||
value={intensityGoalMenstrual}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
setIntensityGoalMenstrual,
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="intensityGoalFollicular"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Follicular
|
||||
</label>
|
||||
<input
|
||||
id="intensityGoalFollicular"
|
||||
type="number"
|
||||
min="0"
|
||||
value={intensityGoalFollicular}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
setIntensityGoalFollicular,
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="intensityGoalOvulation"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Ovulation
|
||||
</label>
|
||||
<input
|
||||
id="intensityGoalOvulation"
|
||||
type="number"
|
||||
min="0"
|
||||
value={intensityGoalOvulation}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
setIntensityGoalOvulation,
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="intensityGoalEarlyLuteal"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Early Luteal
|
||||
</label>
|
||||
<input
|
||||
id="intensityGoalEarlyLuteal"
|
||||
type="number"
|
||||
min="0"
|
||||
value={intensityGoalEarlyLuteal}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
setIntensityGoalEarlyLuteal,
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 sm:col-span-1">
|
||||
<label
|
||||
htmlFor="intensityGoalLateLuteal"
|
||||
className="block text-sm font-medium text-foreground"
|
||||
>
|
||||
Late Luteal
|
||||
</label>
|
||||
<input
|
||||
id="intensityGoalLateLuteal"
|
||||
type="number"
|
||||
min="0"
|
||||
value={intensityGoalLateLuteal}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
setIntensityGoalLateLuteal,
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-muted disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -142,10 +142,10 @@ describe("DataPanel", () => {
|
||||
expect(screen.getByText(/Remaining: 0 min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays negative remaining minutes", () => {
|
||||
it("displays goal exceeded message for negative remaining minutes", () => {
|
||||
render(<DataPanel {...baseProps} remainingMinutes={-50} />);
|
||||
|
||||
expect(screen.getByText(/Remaining: -50 min/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Goal exceeded by 50 min/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,4 +222,52 @@ describe("DataPanel", () => {
|
||||
expect(progressFill).toHaveClass("bg-green-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Last synced indicator", () => {
|
||||
it("does not show indicator when lastSyncedAt is today", () => {
|
||||
// Mock today's date
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={today} />);
|
||||
|
||||
expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Waiting for first sync/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Last synced: yesterday' when data is from yesterday", () => {
|
||||
// Get yesterday's date
|
||||
const yesterday = new Date(Date.now() - 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={yesterday} />);
|
||||
|
||||
expect(screen.getByText(/Last synced: yesterday/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Last synced: X days ago' when data is older", () => {
|
||||
// Get date from 3 days ago
|
||||
const threeDaysAgo = new Date(Date.now() - 3 * 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0];
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={threeDaysAgo} />);
|
||||
|
||||
expect(screen.getByText(/Last synced: 3 days ago/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Waiting for first sync' when lastSyncedAt is null", () => {
|
||||
render(<DataPanel {...baseProps} lastSyncedAt={null} />);
|
||||
|
||||
expect(screen.getByText(/Waiting for first sync/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show indicator when lastSyncedAt is undefined (backwards compatible)", () => {
|
||||
render(<DataPanel {...baseProps} />);
|
||||
|
||||
expect(screen.queryByText(/Last synced:/)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/Waiting for first sync/),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,26 @@ interface DataPanelProps {
|
||||
weekIntensity: number;
|
||||
phaseLimit: number;
|
||||
remainingMinutes: number;
|
||||
lastSyncedAt?: string | null;
|
||||
}
|
||||
|
||||
// Calculate relative time description from a date string (YYYY-MM-DD)
|
||||
function getRelativeTimeDescription(dateStr: string): string | null {
|
||||
const today = new Date();
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
|
||||
if (dateStr === todayStr) {
|
||||
return null; // Don't show indicator for today
|
||||
}
|
||||
|
||||
const syncDate = new Date(dateStr);
|
||||
const diffMs = today.getTime() - syncDate.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
return "yesterday";
|
||||
}
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
function getHrvColorClass(status: string): string {
|
||||
@@ -37,11 +57,23 @@ export function DataPanel({
|
||||
weekIntensity,
|
||||
phaseLimit,
|
||||
remainingMinutes,
|
||||
lastSyncedAt,
|
||||
}: DataPanelProps) {
|
||||
const intensityPercentage =
|
||||
phaseLimit > 0 ? (weekIntensity / phaseLimit) * 100 : 0;
|
||||
const displayPercentage = Math.min(intensityPercentage, 100);
|
||||
|
||||
// Determine what to show for sync status
|
||||
let syncIndicator: string | null = null;
|
||||
if (lastSyncedAt === null) {
|
||||
syncIndicator = "Waiting for first sync";
|
||||
} else if (lastSyncedAt !== undefined) {
|
||||
const relativeTime = getRelativeTimeDescription(lastSyncedAt);
|
||||
if (relativeTime) {
|
||||
syncIndicator = `Last synced: ${relativeTime}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-semibold mb-4">YOUR DATA</h3>
|
||||
@@ -76,7 +108,16 @@ export function DataPanel({
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<li>Remaining: {remainingMinutes} min</li>
|
||||
<li>
|
||||
{remainingMinutes >= 0
|
||||
? `Remaining: ${remainingMinutes} min`
|
||||
: `Goal exceeded by ${Math.abs(remainingMinutes)} min`}
|
||||
</li>
|
||||
{syncIndicator && (
|
||||
<li className="text-amber-600 dark:text-amber-400 text-xs pt-1">
|
||||
{syncIndicator}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,10 @@ export function DecisionCard({ decision }: DecisionCardProps) {
|
||||
const colors = getStatusColors(decision.status);
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg p-6 ${colors.background}`}>
|
||||
<div
|
||||
data-testid="decision-card"
|
||||
className={`rounded-lg p-6 ${colors.background}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">{decision.icon}</div>
|
||||
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
||||
<p className={colors.text}>{decision.reason}</p>
|
||||
|
||||
62
src/instrumentation.ts
Normal file
62
src/instrumentation.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// ABOUTME: Next.js instrumentation file for server startup initialization.
|
||||
// ABOUTME: Schedules cron jobs for notifications and Garmin sync using node-cron.
|
||||
|
||||
export async function register() {
|
||||
// Only run on the server side
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const cron = await import("node-cron");
|
||||
|
||||
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
||||
const CRON_SECRET = process.env.CRON_SECRET;
|
||||
|
||||
// Log startup
|
||||
console.log("[cron] Scheduler starting...");
|
||||
|
||||
if (!CRON_SECRET) {
|
||||
console.warn(
|
||||
"[cron] CRON_SECRET not set - cron jobs will fail authentication",
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to call cron endpoints
|
||||
async function triggerCronEndpoint(endpoint: string, name: string) {
|
||||
try {
|
||||
const response = await fetch(`${APP_URL}/api/cron/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${CRON_SECRET}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(
|
||||
`[cron] ${name} failed: ${response.status} ${errorText}`,
|
||||
);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
console.log(`[cron] ${name} completed:`, result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[cron] ${name} error:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule notifications every 15 minutes for finer-grained delivery times
|
||||
cron.default.schedule("*/15 * * * *", () => {
|
||||
console.log("[cron] Triggering notifications...");
|
||||
triggerCronEndpoint("notifications", "Notifications");
|
||||
});
|
||||
|
||||
// Schedule Garmin sync 8 times daily (every 3 hours) to keep data fresh
|
||||
cron.default.schedule("0 */3 * * *", () => {
|
||||
console.log("[cron] Triggering Garmin sync...");
|
||||
triggerCronEndpoint("garmin-sync", "Garmin sync");
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[cron] Scheduler started - notifications every 15 min, Garmin sync every 3 hours",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -58,12 +58,18 @@ describe("withAuth", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: new Date(),
|
||||
garminRefreshTokenExpiresAt: null,
|
||||
calendarToken: "cal-token",
|
||||
lastPeriodDate: new Date("2025-01-01"),
|
||||
cycleLength: 31,
|
||||
notificationTime: "07:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
intensityGoalMenstrual: 75,
|
||||
intensityGoalFollicular: 150,
|
||||
intensityGoalOvulation: 100,
|
||||
intensityGoalEarlyLuteal: 120,
|
||||
intensityGoalLateLuteal: 50,
|
||||
created: new Date(),
|
||||
updated: new Date(),
|
||||
};
|
||||
|
||||
@@ -157,10 +157,11 @@ describe("getPhase", () => {
|
||||
|
||||
describe("getPhaseLimit", () => {
|
||||
it("returns correct weekly limits for each phase", () => {
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(80);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
|
||||
// Default intensity goals (can be overridden per user)
|
||||
expect(getPhaseLimit("MENSTRUAL")).toBe(75);
|
||||
expect(getPhaseLimit("FOLLICULAR")).toBe(150);
|
||||
expect(getPhaseLimit("OVULATION")).toBe(100);
|
||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(120);
|
||||
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,33 +5,34 @@ import type { CyclePhase, PhaseConfig } from "@/types";
|
||||
// Base phase configurations with weekly limits and training guidance.
|
||||
// Note: The 'days' field is for the default 31-day cycle; actual boundaries
|
||||
// are calculated dynamically by getPhaseBoundaries() based on cycleLength.
|
||||
// Weekly limits are defaults that can be overridden per user.
|
||||
export const PHASE_CONFIGS: PhaseConfig[] = [
|
||||
{
|
||||
name: "MENSTRUAL",
|
||||
days: [1, 3],
|
||||
weeklyLimit: 30,
|
||||
dailyAvg: 10,
|
||||
weeklyLimit: 75,
|
||||
dailyAvg: 11,
|
||||
trainingType: "Gentle rebounding only",
|
||||
},
|
||||
{
|
||||
name: "FOLLICULAR",
|
||||
days: [4, 15],
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
weeklyLimit: 150,
|
||||
dailyAvg: 21,
|
||||
trainingType: "Strength + rebounding",
|
||||
},
|
||||
{
|
||||
name: "OVULATION",
|
||||
days: [16, 17],
|
||||
weeklyLimit: 80,
|
||||
dailyAvg: 40,
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 50,
|
||||
trainingType: "Peak performance",
|
||||
},
|
||||
{
|
||||
name: "EARLY_LUTEAL",
|
||||
days: [18, 24],
|
||||
weeklyLimit: 100,
|
||||
dailyAvg: 14,
|
||||
weeklyLimit: 120,
|
||||
dailyAvg: 17,
|
||||
trainingType: "Moderate training",
|
||||
},
|
||||
{
|
||||
@@ -96,3 +97,38 @@ export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
|
||||
export function getPhaseLimit(phase: CyclePhase): number {
|
||||
return getPhaseConfig(phase).weeklyLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-specific intensity goals for phase limits.
|
||||
*/
|
||||
export interface UserIntensityGoals {
|
||||
intensityGoalMenstrual: number;
|
||||
intensityGoalFollicular: number;
|
||||
intensityGoalOvulation: number;
|
||||
intensityGoalEarlyLuteal: number;
|
||||
intensityGoalLateLuteal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the phase limit using user-specific goals if available.
|
||||
* Falls back to default phase limits if user goals are not set.
|
||||
*/
|
||||
export function getUserPhaseLimit(
|
||||
phase: CyclePhase,
|
||||
userGoals: UserIntensityGoals,
|
||||
): number {
|
||||
switch (phase) {
|
||||
case "MENSTRUAL":
|
||||
return userGoals.intensityGoalMenstrual;
|
||||
case "FOLLICULAR":
|
||||
return userGoals.intensityGoalFollicular;
|
||||
case "OVULATION":
|
||||
return userGoals.intensityGoalOvulation;
|
||||
case "EARLY_LUTEAL":
|
||||
return userGoals.intensityGoalEarlyLuteal;
|
||||
case "LATE_LUTEAL":
|
||||
return userGoals.intensityGoalLateLuteal;
|
||||
default:
|
||||
return getPhaseLimit(phase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,52 @@ describe("getTrainingDecision (algorithmic rules)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("null body battery handling", () => {
|
||||
it("skips bbYesterdayLow check when null and allows TRAIN", () => {
|
||||
const data = createHealthyData();
|
||||
data.bbYesterdayLow = null;
|
||||
const result = getTrainingDecision(data);
|
||||
expect(result.status).toBe("TRAIN");
|
||||
});
|
||||
|
||||
it("skips bbCurrent check when null and allows TRAIN", () => {
|
||||
const data = createHealthyData();
|
||||
data.bbCurrent = null;
|
||||
const result = getTrainingDecision(data);
|
||||
expect(result.status).toBe("TRAIN");
|
||||
});
|
||||
|
||||
it("applies other rules when body battery is null", () => {
|
||||
const data = createHealthyData();
|
||||
data.bbYesterdayLow = null;
|
||||
data.bbCurrent = null;
|
||||
data.hrvStatus = "Unbalanced";
|
||||
const result = getTrainingDecision(data);
|
||||
expect(result.status).toBe("REST");
|
||||
expect(result.reason).toContain("HRV");
|
||||
});
|
||||
|
||||
it("applies phase rules when body battery is null", () => {
|
||||
const data = createHealthyData();
|
||||
data.bbYesterdayLow = null;
|
||||
data.bbCurrent = null;
|
||||
data.phase = "LATE_LUTEAL";
|
||||
const result = getTrainingDecision(data);
|
||||
expect(result.status).toBe("GENTLE");
|
||||
});
|
||||
|
||||
it("applies weekly limit when body battery is null", () => {
|
||||
const data = createHealthyData();
|
||||
data.bbYesterdayLow = null;
|
||||
data.bbCurrent = null;
|
||||
data.weekIntensity = 120;
|
||||
data.phaseLimit = 120;
|
||||
const result = getTrainingDecision(data);
|
||||
expect(result.status).toBe("REST");
|
||||
expect(result.reason).toContain("LIMIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDecisionWithOverrides", () => {
|
||||
describe("override types force appropriate decisions", () => {
|
||||
it("flare override forces REST", () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function getTrainingDecision(data: DailyData): Decision {
|
||||
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
|
||||
}
|
||||
|
||||
if (bbYesterdayLow < 30) {
|
||||
if (bbYesterdayLow !== null && bbYesterdayLow < 30) {
|
||||
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export function getTrainingDecision(data: DailyData): Decision {
|
||||
};
|
||||
}
|
||||
|
||||
if (bbCurrent < 75) {
|
||||
if (bbCurrent !== null && bbCurrent < 75) {
|
||||
return {
|
||||
status: "LIGHT",
|
||||
reason: "Light activity only - BB not recovered",
|
||||
@@ -83,7 +83,7 @@ export function getTrainingDecision(data: DailyData): Decision {
|
||||
};
|
||||
}
|
||||
|
||||
if (bbCurrent < 85) {
|
||||
if (bbCurrent !== null && bbCurrent < 85) {
|
||||
return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
// ABOUTME: Unit tests for email sending utilities.
|
||||
// ABOUTME: Tests email composition, subject lines, and Resend integration.
|
||||
// ABOUTME: Tests email composition, subject lines, and Mailgun integration.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
||||
mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||
const { mockCreate, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
||||
mockCreate: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||
mockLoggerInfo: vi.fn(),
|
||||
mockLoggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the resend module before importing email utilities
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class MockResend {
|
||||
emails = { send: mockSend };
|
||||
// Mock the mailgun.js module before importing email utilities
|
||||
vi.mock("mailgun.js", () => ({
|
||||
default: class MockMailgun {
|
||||
client() {
|
||||
return {
|
||||
messages: { create: mockCreate },
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock form-data (required by mailgun.js)
|
||||
vi.mock("form-data", () => ({
|
||||
default: class MockFormData {},
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
logger: {
|
||||
@@ -57,7 +66,9 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("sends email with correct subject line per spec", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
// Mailgun create takes (domain, messageData) - check second param
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)",
|
||||
}),
|
||||
@@ -66,13 +77,13 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("includes cycle day and phase in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("📅 CYCLE DAY: 15 (OVULATION)");
|
||||
});
|
||||
|
||||
it("includes decision icon and reason", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain(
|
||||
"💪 Body battery high, HRV balanced - great day for training!",
|
||||
);
|
||||
@@ -80,7 +91,7 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("includes biometric data in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Body Battery Now: 85");
|
||||
expect(call.text).toContain("Yesterday's Low: 45");
|
||||
expect(call.text).toContain("HRV Status: Balanced");
|
||||
@@ -90,7 +101,7 @@ describe("sendDailyEmail", () => {
|
||||
|
||||
it("includes nutrition guidance in email body", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain(
|
||||
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||
);
|
||||
@@ -107,30 +118,32 @@ describe("sendDailyEmail", () => {
|
||||
bodyBatteryYesterdayLow: null,
|
||||
};
|
||||
await sendDailyEmail(dataWithNulls);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Body Battery Now: N/A");
|
||||
expect(call.text).toContain("Yesterday's Low: N/A");
|
||||
});
|
||||
|
||||
it("sends email to correct recipient", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
// Mailgun uses an array for recipients
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
to: ["user@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
|
||||
it("includes seed switch alert on day 15", async () => {
|
||||
// sampleData already has cycleDay: 15
|
||||
await sendDailyEmail(sampleData);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("🌱 SWITCH TODAY! Start Sesame + Sunflower");
|
||||
});
|
||||
|
||||
@@ -140,7 +153,7 @@ describe("sendDailyEmail", () => {
|
||||
cycleDay: 10,
|
||||
};
|
||||
await sendDailyEmail(day10Data);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).not.toContain("SWITCH TODAY");
|
||||
});
|
||||
});
|
||||
@@ -156,7 +169,8 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject: "🔵 Period Tracking Updated",
|
||||
}),
|
||||
@@ -169,7 +183,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
// Date formatting depends on locale, so check for key parts
|
||||
expect(call.text).toContain("Your cycle has been reset");
|
||||
expect(call.text).toContain("Last period:");
|
||||
@@ -181,7 +195,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
28,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Phase calendar updated for next 28 days");
|
||||
});
|
||||
|
||||
@@ -191,9 +205,10 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "test@example.com",
|
||||
to: ["test@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -204,7 +219,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
|
||||
@@ -214,7 +229,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
||||
new Date("2025-01-15"),
|
||||
31,
|
||||
);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain(
|
||||
"Your calendar will update automatically within 24 hours",
|
||||
);
|
||||
@@ -229,7 +244,8 @@ describe("sendTokenExpirationWarning", () => {
|
||||
describe("14-day warning", () => {
|
||||
it("sends email with correct subject for 14-day warning", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
||||
}),
|
||||
@@ -238,29 +254,30 @@ describe("sendTokenExpirationWarning", () => {
|
||||
|
||||
it("sends to correct recipient", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
to: ["user@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes days until expiry in body", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("14 days");
|
||||
});
|
||||
|
||||
it("includes instructions to refresh tokens", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Settings");
|
||||
expect(call.text).toContain("Garmin");
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 14);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
});
|
||||
@@ -268,7 +285,8 @@ describe("sendTokenExpirationWarning", () => {
|
||||
describe("7-day warning", () => {
|
||||
it("sends email with urgent subject for 7-day warning", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
subject:
|
||||
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
||||
@@ -278,28 +296,29 @@ describe("sendTokenExpirationWarning", () => {
|
||||
|
||||
it("sends to correct recipient", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
to: "user@example.com",
|
||||
to: ["user@example.com"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes days until expiry in body", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("7 days");
|
||||
});
|
||||
|
||||
it("uses more urgent tone than 14-day warning", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("urgent");
|
||||
});
|
||||
|
||||
it("includes auto-generated footer", async () => {
|
||||
await sendTokenExpirationWarning("user@example.com", 7);
|
||||
const call = mockSend.mock.calls[0][0];
|
||||
const call = mockCreate.mock.calls[0][1];
|
||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||
});
|
||||
});
|
||||
@@ -344,12 +363,12 @@ describe("email structured logging", () => {
|
||||
});
|
||||
|
||||
it("logs email failed with error level on failure", async () => {
|
||||
const error = new Error("Resend API failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
const error = new Error("Mailgun API failed");
|
||||
mockCreate.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
sendDailyEmail(sampleDailyEmailData, "user-123"),
|
||||
).rejects.toThrow("Resend API failed");
|
||||
).rejects.toThrow("Mailgun API failed");
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -381,8 +400,8 @@ describe("email structured logging", () => {
|
||||
});
|
||||
|
||||
it("logs email failed with error level on failure", async () => {
|
||||
const error = new Error("Resend API failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
const error = new Error("Mailgun API failed");
|
||||
mockCreate.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
sendPeriodConfirmationEmail(
|
||||
@@ -391,7 +410,7 @@ describe("email structured logging", () => {
|
||||
31,
|
||||
"user-456",
|
||||
),
|
||||
).rejects.toThrow("Resend API failed");
|
||||
).rejects.toThrow("Mailgun API failed");
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -418,12 +437,12 @@ describe("email structured logging", () => {
|
||||
});
|
||||
|
||||
it("logs email failed with error level on failure", async () => {
|
||||
const error = new Error("Resend API failed");
|
||||
mockSend.mockRejectedValueOnce(error);
|
||||
const error = new Error("Mailgun API failed");
|
||||
mockCreate.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(
|
||||
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
|
||||
).rejects.toThrow("Resend API failed");
|
||||
).rejects.toThrow("Mailgun API failed");
|
||||
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
// ABOUTME: Email sending utilities using Resend.
|
||||
// ABOUTME: Email sending utilities using Mailgun.
|
||||
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
||||
import { Resend } from "resend";
|
||||
import FormData from "form-data";
|
||||
import Mailgun from "mailgun.js";
|
||||
import type { IMailgunClient } from "mailgun.js/Interfaces";
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import { emailSentTotal } from "@/lib/metrics";
|
||||
import { getSeedSwitchAlert } from "@/lib/nutrition";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
// Lazy-initialize Mailgun client to avoid build-time errors when env vars aren't set
|
||||
let mg: IMailgunClient | null = null;
|
||||
|
||||
function getMailgunClient(): IMailgunClient {
|
||||
if (!mg) {
|
||||
const mailgun = new Mailgun(FormData);
|
||||
mg = mailgun.client({
|
||||
username: "api",
|
||||
key: process.env.MAILGUN_API_KEY || "",
|
||||
url: process.env.MAILGUN_URL || "https://api.mailgun.net",
|
||||
});
|
||||
}
|
||||
return mg;
|
||||
}
|
||||
|
||||
const MAILGUN_DOMAIN = process.env.MAILGUN_DOMAIN || "paler.net";
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
||||
|
||||
export interface DailyEmailData {
|
||||
@@ -64,9 +80,9 @@ ${data.decision.icon} ${data.decision.reason}
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||
from: EMAIL_FROM,
|
||||
to: data.to,
|
||||
to: [data.to],
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
@@ -96,9 +112,9 @@ Your calendar will update automatically within 24 hours.
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||
from: EMAIL_FROM,
|
||||
to,
|
||||
to: [to],
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
@@ -141,9 +157,9 @@ This will ensure your training recommendations continue to use fresh Garmin data
|
||||
Auto-generated by PhaseFlow`;
|
||||
|
||||
try {
|
||||
await resend.emails.send({
|
||||
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||
from: EMAIL_FROM,
|
||||
to,
|
||||
to: [to],
|
||||
subject,
|
||||
text: body,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { GarminTokens } from "@/types";
|
||||
|
||||
// Helper to create mock fetch responses with both text() and json() methods
|
||||
function mockJsonResponse(data: unknown, ok = true, status = 200) {
|
||||
const jsonStr = JSON.stringify(data);
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
text: () => Promise.resolve(jsonStr),
|
||||
json: () => Promise.resolve(data),
|
||||
};
|
||||
}
|
||||
|
||||
import {
|
||||
daysUntilExpiry,
|
||||
fetchBodyBattery,
|
||||
@@ -110,6 +121,38 @@ describe("daysUntilExpiry", () => {
|
||||
expect(days).toBeGreaterThanOrEqual(6);
|
||||
expect(days).toBeLessThanOrEqual(7);
|
||||
});
|
||||
|
||||
it("uses refresh_token_expires_at when available", () => {
|
||||
const accessExpiry = new Date();
|
||||
accessExpiry.setDate(accessExpiry.getDate() + 1); // Access token expires in 1 day
|
||||
const refreshExpiry = new Date();
|
||||
refreshExpiry.setDate(refreshExpiry.getDate() + 30); // Refresh token expires in 30 days
|
||||
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: accessExpiry.toISOString(),
|
||||
refresh_token_expires_at: refreshExpiry.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
// Should use refresh token expiry (30 days), not access token expiry (1 day)
|
||||
expect(days).toBeGreaterThanOrEqual(29);
|
||||
expect(days).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it("falls back to expires_at when refresh_token_expires_at not available", () => {
|
||||
const accessExpiry = new Date();
|
||||
accessExpiry.setDate(accessExpiry.getDate() + 5);
|
||||
|
||||
const tokens: GarminTokens = {
|
||||
oauth1: "token1",
|
||||
oauth2: "token2",
|
||||
expires_at: accessExpiry.toISOString(),
|
||||
};
|
||||
const days = daysUntilExpiry(tokens);
|
||||
expect(days).toBeGreaterThanOrEqual(4);
|
||||
expect(days).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGarminData", () => {
|
||||
@@ -130,16 +173,14 @@ describe("fetchGarminData", () => {
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
});
|
||||
|
||||
await fetchGarminData("/wellness/daily/123", {
|
||||
oauth2Token: "test-token",
|
||||
});
|
||||
await fetchGarminData("/wellness/daily/123", "test-token");
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connect.garmin.com/modern/proxy/wellness/daily/123",
|
||||
"https://connectapi.garmin.com/wellness/daily/123",
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
NK: "NT",
|
||||
"User-Agent": "GCM-iOS-5.19.1.2",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -152,9 +193,7 @@ describe("fetchGarminData", () => {
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
});
|
||||
|
||||
const result = await fetchGarminData("/wellness/daily/123", {
|
||||
oauth2Token: "test-token",
|
||||
});
|
||||
const result = await fetchGarminData("/wellness/daily/123", "test-token");
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
@@ -166,7 +205,7 @@ describe("fetchGarminData", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
fetchGarminData("/wellness/daily/123", "test-token"),
|
||||
).rejects.toThrow("Garmin API error: 401");
|
||||
});
|
||||
|
||||
@@ -177,7 +216,7 @@ describe("fetchGarminData", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
fetchGarminData("/wellness/daily/123", "test-token"),
|
||||
).rejects.toThrow("Garmin API error: 403");
|
||||
});
|
||||
|
||||
@@ -188,7 +227,7 @@ describe("fetchGarminData", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
fetchGarminData("/wellness/daily/123", "test-token"),
|
||||
).rejects.toThrow("Garmin API error: 500");
|
||||
});
|
||||
|
||||
@@ -196,7 +235,7 @@ describe("fetchGarminData", () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
await expect(
|
||||
fetchGarminData("/wellness/daily/123", { oauth2Token: "test-token" }),
|
||||
fetchGarminData("/wellness/daily/123", "test-token"),
|
||||
).rejects.toThrow("Network error");
|
||||
});
|
||||
});
|
||||
@@ -213,19 +252,17 @@ describe("fetchHrvStatus", () => {
|
||||
});
|
||||
|
||||
it("returns Balanced when API returns BALANCED status", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" },
|
||||
}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe("Balanced");
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connect.garmin.com/modern/proxy/hrv-service/hrv/2024-01-15",
|
||||
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-15",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
@@ -235,13 +272,11 @@ describe("fetchHrvStatus", () => {
|
||||
});
|
||||
|
||||
it("returns Unbalanced when API returns UNBALANCED status", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
hrvSummary: { lastNightAvg: 25, weeklyAvg: 42, status: "UNBALANCED" },
|
||||
}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse({
|
||||
hrvSummary: { lastNightAvg: 25, weeklyAvg: 42, status: "UNBALANCED" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
@@ -249,10 +284,7 @@ describe("fetchHrvStatus", () => {
|
||||
});
|
||||
|
||||
it("returns Unknown when API returns no data", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(mockJsonResponse({}));
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
@@ -260,10 +292,9 @@ describe("fetchHrvStatus", () => {
|
||||
});
|
||||
|
||||
it("returns Unknown when API returns null hrvSummary", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ hrvSummary: null }),
|
||||
});
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockJsonResponse({ hrvSummary: null }));
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
@@ -288,6 +319,55 @@ describe("fetchHrvStatus", () => {
|
||||
|
||||
expect(result).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("falls back to yesterday's HRV when today returns empty response", async () => {
|
||||
// First call (today) returns empty, second call (yesterday) returns BALANCED
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(""),
|
||||
json: () => Promise.resolve({}),
|
||||
})
|
||||
.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
hrvSummary: { lastNightAvg: 45, weeklyAvg: 42, status: "BALANCED" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe("Balanced");
|
||||
// Verify both today and yesterday were called
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-15",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://connectapi.garmin.com/hrv-service/hrv/2024-01-14",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns Unknown when both today and yesterday HRV are unavailable", async () => {
|
||||
// Both calls return empty responses
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(""),
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
const result = await fetchHrvStatus("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe("Unknown");
|
||||
// Verify both today and yesterday were tried
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBodyBattery", () => {
|
||||
@@ -302,16 +382,29 @@ describe("fetchBodyBattery", () => {
|
||||
});
|
||||
|
||||
it("returns current and yesterday low values on success", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
date: "2024-01-14",
|
||||
charged: 45,
|
||||
drained: 30,
|
||||
bodyBatteryValuesArray: [
|
||||
{ date: "2024-01-15", charged: 85, drained: 60 },
|
||||
[1705190400000, 25],
|
||||
[1705194000000, 40],
|
||||
[1705197600000, 35],
|
||||
],
|
||||
bodyBatteryStatList: [{ date: "2024-01-14", min: 25, max: 95 }],
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
date: "2024-01-15",
|
||||
charged: 85,
|
||||
drained: 60,
|
||||
bodyBatteryValuesArray: [
|
||||
[1705276800000, 65],
|
||||
[1705280400000, 85],
|
||||
],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchBodyBattery("2024-01-15", "test-token");
|
||||
|
||||
@@ -320,7 +413,7 @@ describe("fetchBodyBattery", () => {
|
||||
yesterdayLow: 25,
|
||||
});
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connect.garmin.com/modern/proxy/usersummary-service/stats/bodyBattery/dates/2024-01-15",
|
||||
"https://connectapi.garmin.com/wellness-service/wellness/bodyBattery/reports/daily?startDate=2024-01-14&endDate=2024-01-15",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
@@ -330,14 +423,12 @@ describe("fetchBodyBattery", () => {
|
||||
});
|
||||
|
||||
it("returns null values when data is missing", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
bodyBatteryValuesArray: [],
|
||||
bodyBatteryStatList: [],
|
||||
}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{ date: "2024-01-14", bodyBatteryValuesArray: [] },
|
||||
{ date: "2024-01-15", bodyBatteryValuesArray: [] },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchBodyBattery("2024-01-15", "test-token");
|
||||
|
||||
@@ -347,11 +438,8 @@ describe("fetchBodyBattery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null values when API returns empty object", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
it("returns null values when API returns empty array", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([]));
|
||||
|
||||
const result = await fetchBodyBattery("2024-01-15", "test-token");
|
||||
|
||||
@@ -387,14 +475,14 @@ describe("fetchBodyBattery", () => {
|
||||
});
|
||||
|
||||
it("handles partial data - only current available", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
bodyBatteryValuesArray: [{ date: "2024-01-15", charged: 70 }],
|
||||
bodyBatteryStatList: [],
|
||||
}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
date: "2024-01-15",
|
||||
bodyBatteryValuesArray: [[1705276800000, 70]],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchBodyBattery("2024-01-15", "test-token");
|
||||
|
||||
@@ -416,23 +504,70 @@ describe("fetchIntensityMinutes", () => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("returns 7-day intensity minutes total on success", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
weeklyTotal: {
|
||||
moderateIntensityMinutes: 45,
|
||||
vigorousIntensityMinutes: 30,
|
||||
},
|
||||
}),
|
||||
});
|
||||
it("counts vigorous minutes as 2x (Garmin algorithm)", async () => {
|
||||
// Garmin counts vigorous minutes at 2x multiplier for weekly goals
|
||||
// 45 moderate + (30 vigorous × 2) = 45 + 60 = 105
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-15",
|
||||
weeklyGoal: 150,
|
||||
moderateValue: 45,
|
||||
vigorousValue: 30,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(75);
|
||||
expect(result).toBe(105); // 45 + (30 × 2) = 105
|
||||
});
|
||||
|
||||
it("uses calendar week starting from Monday", async () => {
|
||||
// 2024-01-17 is a Wednesday, so calendar week starts Monday 2024-01-15
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-17",
|
||||
weeklyGoal: 150,
|
||||
moderateValue: 60,
|
||||
vigorousValue: 20,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await fetchIntensityMinutes("2024-01-17", "test-token");
|
||||
|
||||
// Should call with Monday of the current week as start date
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connect.garmin.com/modern/proxy/fitnessstats-service/activity",
|
||||
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-15/2024-01-17",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns intensity minutes total on success", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-15",
|
||||
weeklyGoal: 150,
|
||||
moderateValue: 45,
|
||||
vigorousValue: 30,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
// 45 moderate + (30 vigorous × 2) = 105
|
||||
expect(result).toBe(105);
|
||||
// 2024-01-15 is Monday, so start date is same day (Monday of that week)
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://connectapi.garmin.com/usersummary-service/stats/im/weekly/2024-01-15/2024-01-15",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
@@ -442,59 +577,53 @@ describe("fetchIntensityMinutes", () => {
|
||||
});
|
||||
|
||||
it("returns 0 when no intensity data available", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([]));
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when weeklyTotal is null", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ weeklyTotal: null }),
|
||||
});
|
||||
it("returns 0 when response array is empty", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(mockJsonResponse([]));
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("handles only moderate intensity minutes", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
weeklyTotal: {
|
||||
moderateIntensityMinutes: 60,
|
||||
vigorousIntensityMinutes: 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-15",
|
||||
moderateValue: 60,
|
||||
vigorousValue: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
// 60 moderate + (0 × 2) = 60
|
||||
expect(result).toBe(60);
|
||||
});
|
||||
|
||||
it("handles only vigorous intensity minutes", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
weeklyTotal: {
|
||||
moderateIntensityMinutes: 0,
|
||||
vigorousIntensityMinutes: 45,
|
||||
},
|
||||
}),
|
||||
});
|
||||
it("handles only vigorous intensity minutes with 2x multiplier", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
mockJsonResponse([
|
||||
{
|
||||
calendarDate: "2024-01-15",
|
||||
moderateValue: 0,
|
||||
vigorousValue: 45,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(45);
|
||||
// 0 moderate + (45 × 2) = 90
|
||||
expect(result).toBe(90);
|
||||
});
|
||||
|
||||
it("returns 0 when API request fails", async () => {
|
||||
@@ -503,7 +632,7 @@ describe("fetchIntensityMinutes", () => {
|
||||
status: 401,
|
||||
});
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
@@ -511,7 +640,7 @@ describe("fetchIntensityMinutes", () => {
|
||||
it("returns 0 on network error", async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const result = await fetchIntensityMinutes("test-token");
|
||||
const result = await fetchIntensityMinutes("2024-01-15", "test-token");
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// ABOUTME: Garmin Connect API client using stored OAuth tokens.
|
||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes from Garmin.
|
||||
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { GarminTokens, HrvStatus } from "@/types";
|
||||
|
||||
const GARMIN_BASE_URL = "https://connect.garmin.com/modern/proxy";
|
||||
// Use connectapi subdomain directly (same as garth library)
|
||||
const GARMIN_API_URL = "https://connectapi.garmin.com";
|
||||
|
||||
interface GarminApiOptions {
|
||||
oauth2Token: string;
|
||||
// Headers matching garth library's http.py USER_AGENT
|
||||
function getGarminHeaders(oauth2Token: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${oauth2Token}`,
|
||||
"User-Agent": "GCM-iOS-5.19.1.2",
|
||||
};
|
||||
}
|
||||
|
||||
export interface BodyBatteryData {
|
||||
@@ -15,13 +22,10 @@ export interface BodyBatteryData {
|
||||
|
||||
export async function fetchGarminData(
|
||||
endpoint: string,
|
||||
options: GarminApiOptions,
|
||||
oauth2Token: string,
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(`${GARMIN_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.oauth2Token}`,
|
||||
NK: "NT",
|
||||
},
|
||||
const response = await fetch(`${GARMIN_API_URL}${endpoint}`, {
|
||||
headers: getGarminHeaders(oauth2Token),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -36,40 +40,97 @@ export function isTokenExpired(tokens: GarminTokens): boolean {
|
||||
return expiresAt <= new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate days until refresh token expiry.
|
||||
* This is what users care about - when they need to re-authenticate.
|
||||
* Falls back to access token expiry if refresh token expiry not available.
|
||||
*/
|
||||
export function daysUntilExpiry(tokens: GarminTokens): number {
|
||||
const expiresAt = new Date(tokens.expires_at);
|
||||
const expiresAt = tokens.refresh_token_expires_at
|
||||
? new Date(tokens.refresh_token_expires_at)
|
||||
: new Date(tokens.expires_at);
|
||||
const now = new Date();
|
||||
const diffMs = expiresAt.getTime() - now.getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// Helper to fetch HRV for a specific date
|
||||
async function fetchHrvForDate(
|
||||
date: string,
|
||||
oauth2Token: string,
|
||||
): Promise<HrvStatus | null> {
|
||||
const response = await fetch(`${GARMIN_API_URL}/hrv-service/hrv/${date}`, {
|
||||
headers: getGarminHeaders(oauth2Token),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ status: response.status, endpoint: "hrv-service", date },
|
||||
"Garmin HRV API error",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.startsWith("{") && !text.startsWith("[")) {
|
||||
logger.warn(
|
||||
{ endpoint: "hrv-service", date, isEmpty: text === "" },
|
||||
"Garmin HRV returned non-JSON response",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(text);
|
||||
const status = data?.hrvSummary?.status;
|
||||
|
||||
if (status === "BALANCED") {
|
||||
logger.info({ status: "BALANCED", date }, "Garmin HRV data received");
|
||||
return "Balanced";
|
||||
}
|
||||
if (status === "UNBALANCED") {
|
||||
logger.info({ status: "UNBALANCED", date }, "Garmin HRV data received");
|
||||
return "Unbalanced";
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ rawStatus: status, hasData: !!data?.hrvSummary, date },
|
||||
"Garmin HRV returned unknown status",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchHrvStatus(
|
||||
date: string,
|
||||
oauth2Token: string,
|
||||
): Promise<HrvStatus> {
|
||||
try {
|
||||
const response = await fetch(`${GARMIN_BASE_URL}/hrv-service/hrv/${date}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${oauth2Token}`,
|
||||
NK: "NT",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return "Unknown";
|
||||
// Try fetching today's HRV
|
||||
const todayResult = await fetchHrvForDate(date, oauth2Token);
|
||||
if (todayResult) {
|
||||
return todayResult;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const status = data?.hrvSummary?.status;
|
||||
// Fallback: try yesterday's HRV (common at 6 AM before sleep data processed)
|
||||
const dateObj = new Date(date);
|
||||
dateObj.setDate(dateObj.getDate() - 1);
|
||||
const yesterday = dateObj.toISOString().split("T")[0];
|
||||
|
||||
if (status === "BALANCED") {
|
||||
return "Balanced";
|
||||
}
|
||||
if (status === "UNBALANCED") {
|
||||
return "Unbalanced";
|
||||
logger.info(
|
||||
{ today: date, yesterday },
|
||||
"HRV unavailable today, trying yesterday",
|
||||
);
|
||||
const yesterdayResult = await fetchHrvForDate(yesterday, oauth2Token);
|
||||
if (yesterdayResult) {
|
||||
logger.info({ date: yesterday }, "Using yesterday's HRV data");
|
||||
return yesterdayResult;
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, endpoint: "hrv-service" },
|
||||
"Garmin HRV fetch failed",
|
||||
);
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -79,64 +140,146 @@ export async function fetchBodyBattery(
|
||||
oauth2Token: string,
|
||||
): Promise<BodyBatteryData> {
|
||||
try {
|
||||
// Calculate yesterday's date for the API request
|
||||
const dateObj = new Date(date);
|
||||
dateObj.setDate(dateObj.getDate() - 1);
|
||||
const yesterday = dateObj.toISOString().split("T")[0];
|
||||
|
||||
const response = await fetch(
|
||||
`${GARMIN_BASE_URL}/usersummary-service/stats/bodyBattery/dates/${date}`,
|
||||
`${GARMIN_API_URL}/wellness-service/wellness/bodyBattery/reports/daily?startDate=${yesterday}&endDate=${date}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${oauth2Token}`,
|
||||
NK: "NT",
|
||||
},
|
||||
headers: getGarminHeaders(oauth2Token),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ status: response.status, endpoint: "bodyBattery" },
|
||||
"Garmin body battery API error",
|
||||
);
|
||||
return { current: null, yesterdayLow: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = await response.text();
|
||||
if (!text.startsWith("{") && !text.startsWith("[")) {
|
||||
logger.error(
|
||||
{ endpoint: "bodyBattery", responseBody: text.slice(0, 1000) },
|
||||
"Garmin returned non-JSON response",
|
||||
);
|
||||
return { current: null, yesterdayLow: null };
|
||||
}
|
||||
// Response structure: bodyBatteryValuesArray is [[timestamp, level], ...]
|
||||
// Confirmed by bodyBatteryValueDescriptorDTOList in API response
|
||||
const data = JSON.parse(text) as Array<{
|
||||
date: string;
|
||||
bodyBatteryValuesArray?: Array<[number, number]>;
|
||||
}>;
|
||||
|
||||
const currentData = data?.bodyBatteryValuesArray?.[0];
|
||||
const current = currentData?.charged ?? null;
|
||||
// Find today's and yesterday's data from the response array
|
||||
const todayData = data?.find((d) => d.date === date);
|
||||
const yesterdayData = data?.find((d) => d.date === yesterday);
|
||||
|
||||
const yesterdayStats = data?.bodyBatteryStatList?.[0];
|
||||
const yesterdayLow = yesterdayStats?.min ?? null;
|
||||
// Current = last value in today's bodyBatteryValuesArray (index 1 is the level)
|
||||
const todayValues = todayData?.bodyBatteryValuesArray ?? [];
|
||||
const current =
|
||||
todayValues.length > 0 ? todayValues[todayValues.length - 1][1] : null;
|
||||
|
||||
// Yesterday low = minimum level in yesterday's bodyBatteryValuesArray
|
||||
const yesterdayValues = yesterdayData?.bodyBatteryValuesArray ?? [];
|
||||
const yesterdayLow =
|
||||
yesterdayValues.length > 0
|
||||
? Math.min(...yesterdayValues.map((v) => v[1]))
|
||||
: null;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
current,
|
||||
yesterdayLow,
|
||||
hasCurrentData: todayValues.length > 0,
|
||||
hasYesterdayData: yesterdayValues.length > 0,
|
||||
},
|
||||
"Garmin body battery data received",
|
||||
);
|
||||
|
||||
return { current, yesterdayLow };
|
||||
} catch {
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, endpoint: "bodyBattery" },
|
||||
"Garmin body battery fetch failed",
|
||||
);
|
||||
return { current: null, yesterdayLow: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchIntensityMinutes(
|
||||
date: string,
|
||||
oauth2Token: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Calculate Monday of the current calendar week for Garmin's weekly tracking
|
||||
const endDate = date;
|
||||
const dateObj = new Date(date);
|
||||
const dayOfWeek = dateObj.getDay(); // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
// Calculate days to subtract to get to Monday (if Sunday, go back 6 days)
|
||||
const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const mondayObj = new Date(dateObj);
|
||||
mondayObj.setDate(dateObj.getDate() - daysToMonday);
|
||||
const startDate = mondayObj.toISOString().split("T")[0];
|
||||
|
||||
const response = await fetch(
|
||||
`${GARMIN_BASE_URL}/fitnessstats-service/activity`,
|
||||
`${GARMIN_API_URL}/usersummary-service/stats/im/weekly/${startDate}/${endDate}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${oauth2Token}`,
|
||||
NK: "NT",
|
||||
},
|
||||
headers: getGarminHeaders(oauth2Token),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ status: response.status, endpoint: "intensityMinutes" },
|
||||
"Garmin intensity minutes API error",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const weeklyTotal = data?.weeklyTotal;
|
||||
const text = await response.text();
|
||||
if (!text.startsWith("{") && !text.startsWith("[")) {
|
||||
logger.error(
|
||||
{ endpoint: "intensityMinutes", responseBody: text.slice(0, 1000) },
|
||||
"Garmin returned non-JSON response",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const data = JSON.parse(text) as Array<{
|
||||
calendarDate: string;
|
||||
moderateValue?: number;
|
||||
vigorousValue?: number;
|
||||
}>;
|
||||
|
||||
if (!weeklyTotal) {
|
||||
const entry = data?.[0];
|
||||
if (!entry) {
|
||||
logger.info(
|
||||
{ hasData: false },
|
||||
"Garmin intensity minutes: no weekly data",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const moderate = weeklyTotal.moderateIntensityMinutes ?? 0;
|
||||
const vigorous = weeklyTotal.vigorousIntensityMinutes ?? 0;
|
||||
const moderate = entry.moderateValue ?? 0;
|
||||
const vigorous = entry.vigorousValue ?? 0;
|
||||
// Garmin counts vigorous minutes at 2x multiplier for weekly intensity goal
|
||||
const total = moderate + vigorous * 2;
|
||||
|
||||
return moderate + vigorous;
|
||||
} catch {
|
||||
logger.info(
|
||||
{ moderate, vigorous, total, vigorousMultiplied: vigorous * 2 },
|
||||
"Garmin intensity minutes data received",
|
||||
);
|
||||
|
||||
return total;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ err: error, endpoint: "intensityMinutes" },
|
||||
"Garmin intensity minutes fetch failed",
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,19 @@ export const activeUsersGauge = new promClient.Gauge({
|
||||
help: "Number of users with activity in the last 24 hours",
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
// Build info metric: exposes version and git commit for deployment verification
|
||||
const buildInfo = new promClient.Gauge({
|
||||
name: "phaseflow_build_info",
|
||||
help: "Build information with version and git commit",
|
||||
labelNames: ["version", "commit"] as const,
|
||||
registers: [metricsRegistry],
|
||||
});
|
||||
|
||||
// Set build info at module load (value is always 1, labels contain the info)
|
||||
buildInfo
|
||||
.labels({
|
||||
version: process.env.npm_package_version || "unknown",
|
||||
commit: process.env.GIT_COMMIT || "unknown",
|
||||
})
|
||||
.set(1);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createPocketBaseClient,
|
||||
DEFAULT_INTENSITY_GOALS,
|
||||
getCurrentUser,
|
||||
isAuthenticated,
|
||||
loadAuthFromCookies,
|
||||
mapRecordToUser,
|
||||
} from "./pocketbase";
|
||||
|
||||
describe("isAuthenticated", () => {
|
||||
@@ -68,6 +70,7 @@ describe("getCurrentUser", () => {
|
||||
garminOauth1Token: "encrypted1",
|
||||
garminOauth2Token: "encrypted2",
|
||||
garminTokenExpiresAt: "2025-06-01T00:00:00Z",
|
||||
garminRefreshTokenExpiresAt: "2025-07-01T00:00:00Z",
|
||||
calendarToken: "cal-token-123",
|
||||
lastPeriodDate: "2025-01-01",
|
||||
cycleLength: 28,
|
||||
@@ -105,6 +108,7 @@ describe("getCurrentUser", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "",
|
||||
garminRefreshTokenExpiresAt: "",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "2025-01-15",
|
||||
cycleLength: 31,
|
||||
@@ -139,6 +143,7 @@ describe("getCurrentUser", () => {
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "not-a-date",
|
||||
garminRefreshTokenExpiresAt: "also-not-a-date",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "",
|
||||
cycleLength: 28,
|
||||
@@ -218,3 +223,76 @@ describe("createPocketBaseClient", () => {
|
||||
expect(client.authStore).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapRecordToUser intensity goal defaults", () => {
|
||||
const createMockRecord = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
garminConnected: false,
|
||||
garminOauth1Token: "",
|
||||
garminOauth2Token: "",
|
||||
garminTokenExpiresAt: "",
|
||||
garminRefreshTokenExpiresAt: "",
|
||||
calendarToken: "token",
|
||||
lastPeriodDate: "2025-01-01",
|
||||
cycleLength: 28,
|
||||
notificationTime: "08:00",
|
||||
timezone: "UTC",
|
||||
activeOverrides: [],
|
||||
created: "2024-01-01T00:00:00Z",
|
||||
updated: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalMenstrual is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalMenstrual: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalMenstrual).toBe(DEFAULT_INTENSITY_GOALS.menstrual);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalFollicular is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalFollicular: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalFollicular).toBe(
|
||||
DEFAULT_INTENSITY_GOALS.follicular,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalOvulation is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalOvulation: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalOvulation).toBe(DEFAULT_INTENSITY_GOALS.ovulation);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalEarlyLuteal is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalEarlyLuteal: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalEarlyLuteal).toBe(
|
||||
DEFAULT_INTENSITY_GOALS.earlyLuteal,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default when intensityGoalLateLuteal is 0", () => {
|
||||
const record = createMockRecord({ intensityGoalLateLuteal: 0 });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalLateLuteal).toBe(
|
||||
DEFAULT_INTENSITY_GOALS.lateLuteal,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves non-zero intensity goal values", () => {
|
||||
const record = createMockRecord({
|
||||
intensityGoalMenstrual: 100,
|
||||
intensityGoalFollicular: 200,
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||
const user = mapRecordToUser(record as any);
|
||||
expect(user.intensityGoalMenstrual).toBe(100);
|
||||
expect(user.intensityGoalFollicular).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,16 @@ function parseDate(value: unknown): Date | null {
|
||||
/**
|
||||
* Maps a PocketBase record to our typed User interface.
|
||||
*/
|
||||
function mapRecordToUser(record: RecordModel): User {
|
||||
// Default intensity goals for each phase (weekly minutes)
|
||||
export const DEFAULT_INTENSITY_GOALS = {
|
||||
menstrual: 75,
|
||||
follicular: 150,
|
||||
ovulation: 100,
|
||||
earlyLuteal: 120,
|
||||
lateLuteal: 50,
|
||||
};
|
||||
|
||||
export function mapRecordToUser(record: RecordModel): User {
|
||||
return {
|
||||
id: record.id,
|
||||
email: record.email as string,
|
||||
@@ -96,9 +105,27 @@ function mapRecordToUser(record: RecordModel): User {
|
||||
garminOauth1Token: record.garminOauth1Token as string,
|
||||
garminOauth2Token: record.garminOauth2Token as string,
|
||||
garminTokenExpiresAt: parseDate(record.garminTokenExpiresAt),
|
||||
garminRefreshTokenExpiresAt: parseDate(record.garminRefreshTokenExpiresAt),
|
||||
calendarToken: record.calendarToken as string,
|
||||
lastPeriodDate: parseDate(record.lastPeriodDate),
|
||||
cycleLength: record.cycleLength as number,
|
||||
// Intensity goals with defaults for existing users
|
||||
// Using || instead of ?? because PocketBase defaults number fields to 0
|
||||
intensityGoalMenstrual:
|
||||
(record.intensityGoalMenstrual as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.menstrual,
|
||||
intensityGoalFollicular:
|
||||
(record.intensityGoalFollicular as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.follicular,
|
||||
intensityGoalOvulation:
|
||||
(record.intensityGoalOvulation as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.ovulation,
|
||||
intensityGoalEarlyLuteal:
|
||||
(record.intensityGoalEarlyLuteal as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.earlyLuteal,
|
||||
intensityGoalLateLuteal:
|
||||
(record.intensityGoalLateLuteal as number) ||
|
||||
DEFAULT_INTENSITY_GOALS.lateLuteal,
|
||||
notificationTime: record.notificationTime as string,
|
||||
timezone: record.timezone as string,
|
||||
activeOverrides: (record.activeOverrides as OverrideType[]) || [],
|
||||
|
||||
@@ -22,7 +22,8 @@ export interface User {
|
||||
garminConnected: boolean;
|
||||
garminOauth1Token: string; // encrypted JSON
|
||||
garminOauth2Token: string; // encrypted JSON
|
||||
garminTokenExpiresAt: Date | null;
|
||||
garminTokenExpiresAt: Date | null; // access token expiry (~21 hours)
|
||||
garminRefreshTokenExpiresAt: Date | null; // refresh token expiry (~30 days)
|
||||
|
||||
// Calendar
|
||||
calendarToken: string; // random secret for ICS URL
|
||||
@@ -31,6 +32,13 @@ export interface User {
|
||||
lastPeriodDate: Date | null;
|
||||
cycleLength: number; // default: 31
|
||||
|
||||
// Phase-specific intensity goals (weekly minutes)
|
||||
intensityGoalMenstrual: number; // default: 75
|
||||
intensityGoalFollicular: number; // default: 150
|
||||
intensityGoalOvulation: number; // default: 100
|
||||
intensityGoalEarlyLuteal: number; // default: 120
|
||||
intensityGoalLateLuteal: number; // default: 50
|
||||
|
||||
// Preferences
|
||||
notificationTime: string; // "07:00"
|
||||
timezone: string;
|
||||
@@ -76,17 +84,18 @@ export interface Decision {
|
||||
|
||||
export interface DailyData {
|
||||
hrvStatus: HrvStatus;
|
||||
bbYesterdayLow: number;
|
||||
bbYesterdayLow: number | null;
|
||||
phase: CyclePhase;
|
||||
weekIntensity: number;
|
||||
phaseLimit: number;
|
||||
bbCurrent: number;
|
||||
bbCurrent: number | null;
|
||||
}
|
||||
|
||||
export interface GarminTokens {
|
||||
oauth1: string;
|
||||
oauth2: string;
|
||||
expires_at: string;
|
||||
refresh_token_expires_at?: string;
|
||||
}
|
||||
|
||||
export interface PhaseConfig {
|
||||
|
||||
Reference in New Issue
Block a user