Compare commits
76 Commits
0e585e6bb4
...
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 | |||
| 8c59b3bd67 | |||
| eeeece17bf | |||
| d613417e47 | |||
| 0ea8e2f2b5 | |||
| 262c28d9bd | |||
| aeb87355ed | |||
| e971fe683f | |||
| 38bea1ffd7 | |||
| cd103ac1cc | |||
| 07577dbdbb | |||
| 6e391a46be | |||
| dbf0c32588 | |||
| 6bd5eb663b | |||
| 30c5955a61 | |||
| ce80fb1ede | |||
| ca35b36efa | |||
| c8a738d0c4 | |||
| 2408839b8b | |||
| df2f52ad50 |
@@ -11,8 +11,10 @@ NODE_ENV=development
|
|||||||
POCKETBASE_URL=http://localhost:8090
|
POCKETBASE_URL=http://localhost:8090
|
||||||
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
NEXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
||||||
|
|
||||||
# Email (Resend)
|
# Email (Mailgun)
|
||||||
RESEND_API_KEY=re_xxxxxxxxxxxx
|
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
|
EMAIL_FROM=phaseflow@yourdomain.com
|
||||||
|
|
||||||
# Encryption (for Garmin tokens)
|
# Encryption (for Garmin tokens)
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -13,6 +13,11 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# playwright
|
||||||
|
/playwright-report/
|
||||||
|
/test-results/
|
||||||
|
e2e/.harness-state.json
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
@@ -54,3 +59,5 @@ result
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
|
|
||||||
|
.env.phaseflow
|
||||||
|
|||||||
8
.mcp.json
Normal file
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["@playwright/mcp@latest"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
AGENTS.md
35
AGENTS.md
@@ -18,11 +18,46 @@ Run these after implementing to get immediate feedback:
|
|||||||
|
|
||||||
## Operational Notes
|
## Operational Notes
|
||||||
|
|
||||||
|
- Production URL: https://phaseflow.v.paler.net
|
||||||
- Database: PocketBase at `NEXT_PUBLIC_POCKETBASE_URL` env var
|
- Database: PocketBase at `NEXT_PUBLIC_POCKETBASE_URL` env var
|
||||||
- Deployment config: `../alo-cluster/services/phaseflow.hcl` (Nomad job)
|
- Deployment config: `../alo-cluster/services/phaseflow.hcl` (Nomad job)
|
||||||
- Garmin tokens encrypted with AES-256 using `ENCRYPTION_KEY` (32 chars)
|
- Garmin tokens encrypted with AES-256 using `ENCRYPTION_KEY` (32 chars)
|
||||||
- Path aliases: `@/*` maps to `./src/*`
|
- Path aliases: `@/*` maps to `./src/*`
|
||||||
- Pre-commit hooks: Biome lint + Vitest tests via Lefthook
|
- 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
|
||||||
|
|
||||||
|
PocketBase requires these collections: `users`, `period_logs`, `dailyLogs`.
|
||||||
|
|
||||||
|
To create missing collections:
|
||||||
|
```bash
|
||||||
|
POCKETBASE_ADMIN_EMAIL=admin@example.com \
|
||||||
|
POCKETBASE_ADMIN_PASSWORD=yourpassword \
|
||||||
|
pnpm db:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
The script reads `NEXT_PUBLIC_POCKETBASE_URL` from your environment and creates any missing collections. It's safe to run multiple times - existing collections are skipped.
|
||||||
|
|
||||||
## Codebase Patterns
|
## Codebase Patterns
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
388
e2e/auth.spec.ts
Normal file
388
e2e/auth.spec.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// ABOUTME: E2E tests for authentication flows including login and logout.
|
||||||
|
// ABOUTME: Tests login page UI, form validation, rate limiting, and error handling.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("authentication", () => {
|
||||||
|
test.describe("login page", () => {
|
||||||
|
test("login page shows loading state initially", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
// The page should load with some content visible
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login page displays sign in option", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
// Wait for auth methods to load
|
||||||
|
// Either OIDC button or email/password form should be visible
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Look for either OIDC sign-in button or email/password form
|
||||||
|
const oidcButton = page.getByRole("button", { name: /sign in with/i });
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
|
||||||
|
// At least one should be visible
|
||||||
|
const hasOidc = await oidcButton.isVisible().catch(() => false);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
expect(hasOidc || hasEmailForm).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("email/password form validates empty fields", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Check if email/password form is shown (vs OIDC)
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasEmailForm) {
|
||||||
|
// Try to submit empty form
|
||||||
|
const submitButton = page.getByRole("button", { name: /sign in/i });
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Form should prevent submission via HTML5 validation or show error
|
||||||
|
// The form won't submit with empty required fields
|
||||||
|
await expect(emailInput).toBeFocused();
|
||||||
|
} else {
|
||||||
|
// OIDC mode - skip this test
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error for invalid credentials", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Check if email/password form is shown
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasEmailForm) {
|
||||||
|
// Fill in invalid credentials
|
||||||
|
await emailInput.fill("invalid@example.com");
|
||||||
|
await page.getByLabel(/password/i).fill("wrongpassword");
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Should show error message - use more specific selector to avoid matching Next.js route announcer
|
||||||
|
const errorMessage = page.locator('[role="alert"]').filter({
|
||||||
|
hasText: /invalid|failed|error|wrong|something went wrong/i,
|
||||||
|
});
|
||||||
|
await expect(errorMessage).toBeVisible({ timeout: 10000 });
|
||||||
|
} else {
|
||||||
|
// OIDC mode - skip this test
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears error when user types", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Check if email/password form is shown
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasEmailForm) {
|
||||||
|
// Fill in and submit invalid credentials
|
||||||
|
await emailInput.fill("invalid@example.com");
|
||||||
|
await page.getByLabel(/password/i).fill("wrongpassword");
|
||||||
|
await page.getByRole("button", { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Wait for error - use more specific selector
|
||||||
|
const errorMessage = page.locator('[role="alert"]').filter({
|
||||||
|
hasText: /invalid|failed|error|wrong|something went wrong/i,
|
||||||
|
});
|
||||||
|
await expect(errorMessage).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Type in email field
|
||||||
|
await emailInput.fill("new@example.com");
|
||||||
|
|
||||||
|
// Error should be cleared (non-rate-limit errors)
|
||||||
|
// Note: Rate limit errors persist
|
||||||
|
await expect(errorMessage)
|
||||||
|
.not.toBeVisible({ timeout: 2000 })
|
||||||
|
.catch(() => {
|
||||||
|
// If still visible, might be rate limit - that's acceptable
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows disabled state during login attempt", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Check if email/password form is shown
|
||||||
|
const emailInput = page.getByLabel(/email/i);
|
||||||
|
const hasEmailForm = await emailInput.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasEmailForm) {
|
||||||
|
// Fill in credentials
|
||||||
|
await emailInput.fill("test@example.com");
|
||||||
|
await page.getByLabel(/password/i).fill("testpassword");
|
||||||
|
|
||||||
|
// Click submit and quickly check for disabled state
|
||||||
|
const submitButton = page.getByRole("button", { name: /sign in/i });
|
||||||
|
|
||||||
|
// Start the submission
|
||||||
|
const submitPromise = submitButton.click();
|
||||||
|
|
||||||
|
// The button should become disabled during submission
|
||||||
|
// Check that the button text changes to "Signing in..."
|
||||||
|
await expect(submitButton)
|
||||||
|
.toContainText(/signing in/i, { timeout: 1000 })
|
||||||
|
.catch(() => {
|
||||||
|
// May be too fast to catch - that's okay
|
||||||
|
});
|
||||||
|
|
||||||
|
await submitPromise;
|
||||||
|
} else {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("protected routes", () => {
|
||||||
|
test("dashboard redirects unauthenticated users to login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Should either redirect to /login or show login link
|
||||||
|
const url = page.url();
|
||||||
|
const hasLoginInUrl = url.includes("/login");
|
||||||
|
const loginLink = page.getByRole("link", { name: /login|sign in/i });
|
||||||
|
|
||||||
|
if (!hasLoginInUrl) {
|
||||||
|
await expect(loginLink).toBeVisible();
|
||||||
|
} else {
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings redirects unauthenticated users to login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calendar redirects unauthenticated users to login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/calendar");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("history redirects unauthenticated users to login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/history");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan redirects unauthenticated users to login", async ({ page }) => {
|
||||||
|
await page.goto("/plan");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("period-history redirects unauthenticated users to login", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/period-history");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("public routes", () => {
|
||||||
|
test("login page is accessible without auth", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
// Should not redirect
|
||||||
|
});
|
||||||
|
|
||||||
|
test("health endpoint is accessible without auth", async ({ page }) => {
|
||||||
|
const response = await page.request.get("/api/health");
|
||||||
|
|
||||||
|
// Health endpoint returns 200 (ok) or 503 (unhealthy) - both are valid responses
|
||||||
|
expect([200, 503]).toContain(response.status());
|
||||||
|
const body = await response.json();
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
755
e2e/calendar.spec.ts
Normal file
755
e2e/calendar.spec.ts
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
// ABOUTME: E2E tests for calendar functionality including ICS feed and calendar view.
|
||||||
|
// ABOUTME: Tests calendar display, navigation, and ICS subscription features.
|
||||||
|
|
||||||
|
import { test as baseTest } from "@playwright/test";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
|
||||||
|
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/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should return 404 (user not found) or 500 (PocketBase not connected in test env)
|
||||||
|
expect([404, 500]).toContain(response.status());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should return 404 (user not found), 401 (invalid token), or 500 (PocketBase not connected)
|
||||||
|
expect([401, 404, 500]).toContain(response.status());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
baseTest.describe("calendar regenerate token API", () => {
|
||||||
|
baseTest("regenerate token requires authentication", async ({ page }) => {
|
||||||
|
const response = await page.request.post(
|
||||||
|
"/api/calendar/regenerate-token",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should return 401 Unauthorized
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1180
e2e/dashboard.spec.ts
Normal file
1180
e2e/dashboard.spec.ts
Normal file
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
e2e/global-setup.ts
Normal file
50
e2e/global-setup.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// ABOUTME: Playwright global setup - starts PocketBase and sets test environment variables.
|
||||||
|
// 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, TEST_USERS } from "./pocketbase-harness";
|
||||||
|
|
||||||
|
const STATE_FILE = path.join(__dirname, ".harness-state.json");
|
||||||
|
|
||||||
|
export default async function globalSetup(): Promise<void> {
|
||||||
|
console.log("Starting PocketBase for e2e tests...");
|
||||||
|
|
||||||
|
const state = await start(DEFAULT_CONFIG);
|
||||||
|
|
||||||
|
// Save state for teardown
|
||||||
|
fs.writeFileSync(
|
||||||
|
STATE_FILE,
|
||||||
|
JSON.stringify({
|
||||||
|
dataDir: state.dataDir,
|
||||||
|
url: state.url,
|
||||||
|
pid: state.process.pid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set environment variables for the test process
|
||||||
|
process.env.NEXT_PUBLIC_POCKETBASE_URL = state.url;
|
||||||
|
process.env.POCKETBASE_URL = state.url;
|
||||||
|
|
||||||
|
// 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 users created:");
|
||||||
|
for (const [preset, user] of Object.entries(TEST_USERS)) {
|
||||||
|
console.log(` ${preset}: ${user.email}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
e2e/global-teardown.ts
Normal file
54
e2e/global-teardown.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// ABOUTME: Playwright global teardown - stops PocketBase and cleans up temp data.
|
||||||
|
// ABOUTME: Runs after all e2e tests to ensure clean shutdown.
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
const STATE_FILE = path.join(__dirname, ".harness-state.json");
|
||||||
|
|
||||||
|
interface HarnessStateFile {
|
||||||
|
dataDir: string;
|
||||||
|
url: string;
|
||||||
|
pid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalTeardown(): Promise<void> {
|
||||||
|
console.log("Stopping PocketBase...");
|
||||||
|
|
||||||
|
// Read the saved state
|
||||||
|
if (!fs.existsSync(STATE_FILE)) {
|
||||||
|
console.log("No harness state file found, nothing to clean up.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: HarnessStateFile = JSON.parse(
|
||||||
|
fs.readFileSync(STATE_FILE, "utf-8"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kill the PocketBase process
|
||||||
|
if (state.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(state.pid, "SIGTERM");
|
||||||
|
// Wait for graceful shutdown
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
// Force kill if still running
|
||||||
|
try {
|
||||||
|
process.kill(state.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Process already dead, which is fine
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Process might already be dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the temporary data directory
|
||||||
|
if (state.dataDir && fs.existsSync(state.dataDir)) {
|
||||||
|
fs.rmSync(state.dataDir, { recursive: true, force: true });
|
||||||
|
console.log(`Cleaned up temp directory: ${state.dataDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the state file
|
||||||
|
fs.unlinkSync(STATE_FILE);
|
||||||
|
|
||||||
|
console.log("PocketBase stopped and cleaned up.");
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
487
e2e/period-logging.spec.ts
Normal file
487
e2e/period-logging.spec.ts
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
// ABOUTME: E2E tests for period logging functionality.
|
||||||
|
// ABOUTME: Tests period start logging, date selection, and period history.
|
||||||
|
|
||||||
|
import { test as baseTest } from "@playwright/test";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
|
||||||
|
baseTest.describe("period logging", () => {
|
||||||
|
baseTest.describe("unauthenticated", () => {
|
||||||
|
baseTest(
|
||||||
|
"period history page redirects to login when not authenticated",
|
||||||
|
async ({ page }) => {
|
||||||
|
await page.goto("/period-history");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
baseTest("period log API requires authentication", async ({ page }) => {
|
||||||
|
const response = await page.request.post("/api/cycle/period", {
|
||||||
|
data: { startDate: "2024-01-15" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should return 401 Unauthorized
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("period logging authenticated", () => {
|
||||||
|
test("dashboard shows period date prompt for new users", async ({
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
140
e2e/pocketbase-harness.test.ts
Normal file
140
e2e/pocketbase-harness.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// ABOUTME: Integration tests for the PocketBase e2e test harness.
|
||||||
|
// ABOUTME: Verifies the harness can start, setup, and stop PocketBase instances.
|
||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
getState,
|
||||||
|
type HarnessState,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
} from "./pocketbase-harness";
|
||||||
|
|
||||||
|
describe("pocketbase-harness", () => {
|
||||||
|
describe("start/stop lifecycle", () => {
|
||||||
|
let state: HarnessState;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
state = await start();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a valid harness state", () => {
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state.url).toBe(`http://127.0.0.1:${DEFAULT_CONFIG.port}`);
|
||||||
|
expect(state.dataDir).toContain("pocketbase-e2e-");
|
||||||
|
expect(state.process).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getState returns the current state while running", () => {
|
||||||
|
const currentState = getState();
|
||||||
|
expect(currentState).toBe(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PocketBase is accessible at the expected URL", async () => {
|
||||||
|
const response = await fetch(`${state.url}/api/health`);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin can authenticate", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
const auth = await pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.adminEmail,
|
||||||
|
DEFAULT_CONFIG.adminPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auth.token).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test user can authenticate", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
const auth = await pb
|
||||||
|
.collection("users")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.testUserEmail,
|
||||||
|
DEFAULT_CONFIG.testUserPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auth.token).toBeDefined();
|
||||||
|
expect(auth.record.email).toBe(DEFAULT_CONFIG.testUserEmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test user has period data configured", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
await pb
|
||||||
|
.collection("users")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.testUserEmail,
|
||||||
|
DEFAULT_CONFIG.testUserPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = pb.authStore.record;
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.lastPeriodDate).toBeDefined();
|
||||||
|
expect(user?.cycleLength).toBe(28);
|
||||||
|
expect(user?.timezone).toBe("UTC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("period_logs collection exists with test data", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
await pb
|
||||||
|
.collection("users")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.testUserEmail,
|
||||||
|
DEFAULT_CONFIG.testUserPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
const userId = pb.authStore.record?.id;
|
||||||
|
const logs = await pb
|
||||||
|
.collection("period_logs")
|
||||||
|
.getList(1, 10, { filter: `user="${userId}"` });
|
||||||
|
|
||||||
|
expect(logs.totalItems).toBeGreaterThan(0);
|
||||||
|
expect(logs.items[0].startDate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dailyLogs collection exists", async () => {
|
||||||
|
const pb = new PocketBase(state.url);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
await pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(
|
||||||
|
DEFAULT_CONFIG.adminEmail,
|
||||||
|
DEFAULT_CONFIG.adminPassword,
|
||||||
|
);
|
||||||
|
|
||||||
|
const collections = await pb.collections.getFullList();
|
||||||
|
const collectionNames = collections.map((c) => c.name);
|
||||||
|
|
||||||
|
expect(collectionNames).toContain("period_logs");
|
||||||
|
expect(collectionNames).toContain("dailyLogs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("after stop", () => {
|
||||||
|
it("getState returns null after stop", async () => {
|
||||||
|
// Start and immediately stop
|
||||||
|
await start({ ...DEFAULT_CONFIG, port: 8092 });
|
||||||
|
await stop();
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
expect(state).toBeNull();
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
});
|
||||||
554
e2e/pocketbase-harness.ts
Normal file
554
e2e/pocketbase-harness.ts
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
// 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.
|
||||||
|
*/
|
||||||
|
export interface HarnessConfig {
|
||||||
|
port: number;
|
||||||
|
adminEmail: string;
|
||||||
|
adminPassword: string;
|
||||||
|
testUserEmail: string;
|
||||||
|
testUserPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration for e2e tests.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CONFIG: HarnessConfig = {
|
||||||
|
port: 8091,
|
||||||
|
adminEmail: "admin@e2e-test.local",
|
||||||
|
adminPassword: "admin-password-e2e-123",
|
||||||
|
testUserEmail: "e2e-test@phaseflow.local",
|
||||||
|
testUserPassword: "e2e-test-password-123",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of a running PocketBase harness instance.
|
||||||
|
*/
|
||||||
|
export interface HarnessState {
|
||||||
|
process: ChildProcess;
|
||||||
|
dataDir: string;
|
||||||
|
url: string;
|
||||||
|
config: HarnessConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentState: HarnessState | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the URL for the PocketBase instance.
|
||||||
|
*/
|
||||||
|
function getPocketBaseUrl(port: number): string {
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a temporary directory for PocketBase data.
|
||||||
|
*/
|
||||||
|
function createTempDataDir(): string {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pocketbase-e2e-"));
|
||||||
|
return tempDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Waits for PocketBase to be ready by polling the health endpoint.
|
||||||
|
*/
|
||||||
|
async function waitForReady(url: string, timeoutMs = 30000): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const healthUrl = `${url}/api/health`;
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(healthUrl);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet, continue polling
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`PocketBase did not become ready within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleeps for the specified number of milliseconds.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
maxRetries = 5,
|
||||||
|
): Promise<void> {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
`pocketbase superuser upsert ${email} ${password} --dir=${dataDir}`,
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err as Error;
|
||||||
|
const errorMsg = String(lastError.message || lastError);
|
||||||
|
|
||||||
|
// Retry on database lock errors
|
||||||
|
if (
|
||||||
|
errorMsg.includes("database is locked") ||
|
||||||
|
errorMsg.includes("SQLITE_BUSY")
|
||||||
|
) {
|
||||||
|
await sleep(100 * (attempt + 1)); // Exponential backoff: 100ms, 200ms, 300ms...
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, throw immediately
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the database collections using the SDK.
|
||||||
|
*/
|
||||||
|
async function setupCollections(pb: PocketBase): Promise<void> {
|
||||||
|
// Add custom fields to users collection
|
||||||
|
await addUserFields(pb);
|
||||||
|
|
||||||
|
// Create period_logs and dailyLogs collections
|
||||||
|
const existingNames = await getExistingCollectionNames(pb);
|
||||||
|
const missing = getMissingCollections(existingNames);
|
||||||
|
|
||||||
|
for (const collection of missing) {
|
||||||
|
await createCollection(pb, collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up API rules
|
||||||
|
await setupApiRules(pb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries an async operation with exponential backoff.
|
||||||
|
*/
|
||||||
|
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];
|
||||||
|
|
||||||
|
const user = await retryAsync(() =>
|
||||||
|
pb.collection("users").create({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
passwordConfirm: password,
|
||||||
|
emailVisibility: true,
|
||||||
|
verified: true,
|
||||||
|
lastPeriodDate,
|
||||||
|
cycleLength: 28,
|
||||||
|
notificationTime: "07:00",
|
||||||
|
timezone: "UTC",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export async function start(
|
||||||
|
config: HarnessConfig = DEFAULT_CONFIG,
|
||||||
|
): Promise<HarnessState> {
|
||||||
|
if (currentState) {
|
||||||
|
throw new Error(
|
||||||
|
"PocketBase harness is already running. Call stop() first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = createTempDataDir();
|
||||||
|
const url = getPocketBaseUrl(config.port);
|
||||||
|
|
||||||
|
// Start PocketBase process
|
||||||
|
const pbProcess = spawn(
|
||||||
|
"pocketbase",
|
||||||
|
["serve", `--dir=${dataDir}`, `--http=127.0.0.1:${config.port}`],
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
detached: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle process errors
|
||||||
|
pbProcess.on("error", (err) => {
|
||||||
|
console.error("PocketBase process error:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for PocketBase to be ready
|
||||||
|
await waitForReady(url);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
await pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(config.adminEmail, config.adminPassword);
|
||||||
|
|
||||||
|
// Set up collections
|
||||||
|
await setupCollections(pb);
|
||||||
|
|
||||||
|
// Create all test users for different e2e scenarios
|
||||||
|
await createAllTestUsers(pb);
|
||||||
|
|
||||||
|
currentState = {
|
||||||
|
process: pbProcess,
|
||||||
|
dataDir,
|
||||||
|
url,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops the running PocketBase instance and cleans up.
|
||||||
|
*/
|
||||||
|
export async function stop(): Promise<void> {
|
||||||
|
if (!currentState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { process: pbProcess, dataDir } = currentState;
|
||||||
|
|
||||||
|
// Kill the PocketBase process
|
||||||
|
pbProcess.kill("SIGTERM");
|
||||||
|
|
||||||
|
// Wait a moment for graceful shutdown
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Force kill if still running
|
||||||
|
if (!pbProcess.killed) {
|
||||||
|
pbProcess.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the temporary data directory
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
currentState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current harness state if running.
|
||||||
|
*/
|
||||||
|
export function getState(): HarnessState | null {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
969
e2e/settings.spec.ts
Normal file
969
e2e/settings.spec.ts
Normal file
@@ -0,0 +1,969 @@
|
|||||||
|
// ABOUTME: E2E tests for settings page including preferences and logout.
|
||||||
|
// ABOUTME: Tests form rendering, validation, submission, and logout functionality.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("settings", () => {
|
||||||
|
test.describe("unauthenticated", () => {
|
||||||
|
test("redirects to login when not authenticated", async ({ page }) => {
|
||||||
|
await page.goto("/settings");
|
||||||
|
|
||||||
|
// Should redirect to /login
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("garmin settings redirects to login when not authenticated", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/settings/garmin");
|
||||||
|
|
||||||
|
// 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 settings
|
||||||
|
await page.waitForURL("/", { timeout: 10000 });
|
||||||
|
await page.goto("/settings");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays settings form with required fields", async ({ page }) => {
|
||||||
|
// Check for cycle length input
|
||||||
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||||
|
await expect(cycleLengthInput).toBeVisible();
|
||||||
|
|
||||||
|
// Check for notification time input
|
||||||
|
const notificationTimeInput = page.getByLabel(/notification time/i);
|
||||||
|
await expect(notificationTimeInput).toBeVisible();
|
||||||
|
|
||||||
|
// Check for timezone input
|
||||||
|
const timezoneInput = page.getByLabel(/timezone/i);
|
||||||
|
await expect(timezoneInput).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows save button", async ({ page }) => {
|
||||||
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
|
await expect(saveButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows logout button", async ({ page }) => {
|
||||||
|
const logoutButton = page.getByRole("button", { name: /log ?out/i });
|
||||||
|
await expect(logoutButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows link to garmin settings", async ({ page }) => {
|
||||||
|
const garminLink = page.getByRole("link", { name: /manage|garmin/i });
|
||||||
|
await expect(garminLink).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows back to dashboard link", async ({ page }) => {
|
||||||
|
const backLink = page.getByRole("link", { name: /back|dashboard/i });
|
||||||
|
await expect(backLink).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can update cycle length", async ({ page }) => {
|
||||||
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||||
|
|
||||||
|
// Clear and enter new value
|
||||||
|
await cycleLengthInput.fill("30");
|
||||||
|
|
||||||
|
// Click save
|
||||||
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Should show success message or no error
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Either success message or value persisted
|
||||||
|
const errorMessage = page.locator('[role="alert"]').filter({
|
||||||
|
hasText: /error|failed/i,
|
||||||
|
});
|
||||||
|
const hasError = await errorMessage.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
// No error means success
|
||||||
|
expect(hasError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates cycle length range", async ({ page }) => {
|
||||||
|
const cycleLengthInput = page.getByLabel(/cycle length/i);
|
||||||
|
|
||||||
|
// Enter invalid value (too low)
|
||||||
|
await cycleLengthInput.fill("10");
|
||||||
|
|
||||||
|
// Click save
|
||||||
|
const saveButton = page.getByRole("button", { name: /save/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Should show validation error or HTML5 validation
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can navigate to garmin settings", async ({ page }) => {
|
||||||
|
const garminLink = page.getByRole("link", { name: /manage|garmin/i });
|
||||||
|
await garminLink.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/settings\/garmin/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can navigate back to dashboard", async ({ page }) => {
|
||||||
|
const backLink = page.getByRole("link", { name: /back|dashboard/i });
|
||||||
|
await backLink.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout redirects to login", async ({ page }) => {
|
||||||
|
const logoutButton = page.getByRole("button", { name: /log ?out/i });
|
||||||
|
await logoutButton.click();
|
||||||
|
|
||||||
|
// Should redirect to login page
|
||||||
|
await expect(page).toHaveURL(/\/login/, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("garmin 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login and navigate to garmin settings
|
||||||
|
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/garmin");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("displays garmin connection status", async ({ page }) => {
|
||||||
|
// Look for connection status indicator
|
||||||
|
const statusText = page.getByText(/connected|not connected|status/i);
|
||||||
|
await expect(statusText.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows back navigation", async ({ page }) => {
|
||||||
|
const backLink = page.getByRole("link", { name: /back|settings/i });
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
36
e2e/smoke.spec.ts
Normal file
36
e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// ABOUTME: Smoke tests to verify basic application functionality.
|
||||||
|
// ABOUTME: Tests that the app loads and critical pages are accessible.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("smoke tests", () => {
|
||||||
|
test("app loads with correct title", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Verify the app loads by checking the page title
|
||||||
|
await expect(page).toHaveTitle("PhaseFlow");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login page is accessible", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
// Verify login page loads
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unauthenticated root redirects or shows login option", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// The app should either redirect to login or show a login link
|
||||||
|
// Check for either condition
|
||||||
|
const url = page.url();
|
||||||
|
const hasLoginInUrl = url.includes("/login");
|
||||||
|
const loginLink = page.getByRole("link", { name: /login|sign in/i });
|
||||||
|
|
||||||
|
// At least one should be true: either we're on login page or there's a login link
|
||||||
|
if (!hasLoginInUrl) {
|
||||||
|
await expect(loginLink).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
68
flake.lock
generated
68
flake.lock
generated
@@ -1,5 +1,23 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1726560853,
|
||||||
|
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767892417,
|
"lastModified": 1767892417,
|
||||||
@@ -16,9 +34,57 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 0,
|
||||||
|
"narHash": "sha256-u+rxA79a0lyhG+u+oPBRtTDtzz8kvkc9a6SWSt9ekVc=",
|
||||||
|
"path": "/nix/store/0283cbhm47kd3lr9zmc5fvdrx9qkav8s-source",
|
||||||
|
"type": "path"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"id": "nixpkgs",
|
||||||
|
"type": "indirect"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playwright-web-flake": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1764622772,
|
||||||
|
"narHash": "sha256-WCvvlB9sH6u8MQUkFnlxx7jDh7kIebTDK/JHi6pPqSA=",
|
||||||
|
"owner": "pietdevries94",
|
||||||
|
"repo": "playwright-web-flake",
|
||||||
|
"rev": "88e0e6c69b9086619b0c4d8713b2bfaf81a21c40",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "pietdevries94",
|
||||||
|
"ref": "1.56.1",
|
||||||
|
"repo": "playwright-web-flake",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs",
|
||||||
|
"playwright-web-flake": "playwright-web-flake"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
63
flake.nix
63
flake.nix
@@ -2,18 +2,43 @@
|
|||||||
# ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output.
|
# ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output.
|
||||||
{
|
{
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
|
||||||
|
|
||||||
outputs = { nixpkgs, ... }:
|
outputs = { nixpkgs, playwright-web-flake, ... }:
|
||||||
let
|
let
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
playwright-driver = playwright-web-flake.packages.${system}.playwright-driver;
|
||||||
|
|
||||||
|
# Custom Python package: garth (not in nixpkgs)
|
||||||
|
garth = pkgs.python3Packages.buildPythonPackage {
|
||||||
|
pname = "garth";
|
||||||
|
version = "0.5.21";
|
||||||
|
src = pkgs.fetchPypi {
|
||||||
|
pname = "garth";
|
||||||
|
version = "0.5.21";
|
||||||
|
sha256 = "sha256-jZeVldHU6iOhtGarSmCVXRObcfiG9GSQvhQPzuWE2rQ=";
|
||||||
|
};
|
||||||
|
format = "pyproject";
|
||||||
|
nativeBuildInputs = [ pkgs.python3Packages.hatchling ];
|
||||||
|
propagatedBuildInputs = with pkgs.python3Packages; [
|
||||||
|
pydantic
|
||||||
|
requests-oauthlib
|
||||||
|
requests
|
||||||
|
];
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Python with garth for Garmin auth scripts
|
||||||
|
pythonWithGarth = pkgs.python3.withPackages (ps: [ garth ]);
|
||||||
|
|
||||||
# Common packages for development
|
# Common packages for development
|
||||||
commonPackages = with pkgs; [
|
commonPackages = [
|
||||||
nodejs_24
|
pkgs.nodejs_24
|
||||||
pnpm
|
pkgs.pnpm
|
||||||
git
|
pkgs.git
|
||||||
pocketbase
|
pkgs.pocketbase
|
||||||
|
pythonWithGarth
|
||||||
];
|
];
|
||||||
in {
|
in {
|
||||||
# Docker image for production deployment
|
# Docker image for production deployment
|
||||||
@@ -25,32 +50,40 @@
|
|||||||
devShells.${system} = {
|
devShells.${system} = {
|
||||||
# Default development shell with all tools
|
# Default development shell with all tools
|
||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
packages = commonPackages ++ (with pkgs; [
|
packages = commonPackages ++ [
|
||||||
turbo
|
pkgs.turbo
|
||||||
lefthook
|
pkgs.lefthook
|
||||||
]);
|
playwright-driver
|
||||||
|
];
|
||||||
|
|
||||||
# For native modules (sharp, better-sqlite3, etc.)
|
# For native modules (sharp, better-sqlite3, etc.)
|
||||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
|
||||||
|
|
||||||
|
# Playwright browser configuration for NixOS (from playwright-web-flake)
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Ralph sandbox shell with minimal permissions
|
# Ralph sandbox shell with minimal permissions
|
||||||
# Used for autonomous Ralph loop execution
|
# Used for autonomous Ralph loop execution
|
||||||
ralph = pkgs.mkShell {
|
ralph = pkgs.mkShell {
|
||||||
packages = commonPackages ++ (with pkgs; [
|
packages = commonPackages ++ [
|
||||||
# Claude CLI (assumes installed globally or via npm)
|
playwright-driver
|
||||||
# Add any other tools Ralph needs here
|
];
|
||||||
]);
|
|
||||||
|
|
||||||
# Restrictive environment for sandboxed execution
|
# Restrictive environment for sandboxed execution
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "🔒 Ralph Sandbox Environment"
|
echo "🔒 Ralph Sandbox Environment"
|
||||||
echo " Limited to: nodejs, pnpm, git"
|
echo " Limited to: nodejs, pnpm, git, playwright"
|
||||||
echo ""
|
echo ""
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# For native modules
|
# For native modules
|
||||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
|
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
|
||||||
|
|
||||||
|
# Playwright browser configuration for NixOS (from playwright-web-flake)
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
env: {
|
||||||
|
GIT_COMMIT: process.env.GIT_COMMIT || getGitCommit(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
13
package.json
13
package.json
@@ -9,31 +9,40 @@
|
|||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check --write .",
|
"lint:fix": "biome check --write .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"db:setup": "npx tsx scripts/setup-db.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"form-data": "^4.0.1",
|
||||||
"ics": "^3.8.1",
|
"ics": "^3.8.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"mailgun.js": "^11.1.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"oauth-1.0a": "^2.2.6",
|
||||||
"pino": "^10.1.1",
|
"pino": "^10.1.1",
|
||||||
"pocketbase": "^0.26.5",
|
"pocketbase": "^0.26.5",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"resend": "^6.7.0",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.3.11",
|
"@biomejs/biome": "2.3.11",
|
||||||
|
"@playwright/test": "1.56.1",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.1",
|
"@testing-library/react": "^16.3.1",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|||||||
63
playwright.config.ts
Normal file
63
playwright.config.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// ABOUTME: Playwright E2E test configuration for browser-based testing.
|
||||||
|
// ABOUTME: Configures Chromium-only headless testing with automatic dev server startup.
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Test directory for E2E tests
|
||||||
|
testDir: "./e2e",
|
||||||
|
|
||||||
|
// Global setup/teardown for PocketBase harness
|
||||||
|
globalSetup: "./e2e/global-setup.ts",
|
||||||
|
globalTeardown: "./e2e/global-teardown.ts",
|
||||||
|
|
||||||
|
// Exclude vitest test files
|
||||||
|
testIgnore: ["**/pocketbase-harness.test.ts"],
|
||||||
|
|
||||||
|
// Run tests in parallel
|
||||||
|
fullyParallel: true,
|
||||||
|
|
||||||
|
// Fail the build on CI if you accidentally left test.only in the source code
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
|
// Retry failed tests on CI only
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
|
// 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"]],
|
||||||
|
|
||||||
|
// Shared settings for all projects
|
||||||
|
use: {
|
||||||
|
// Base URL for navigation actions like page.goto('/')
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
|
||||||
|
// Collect trace on first retry for debugging
|
||||||
|
trace: "on-first-retry",
|
||||||
|
|
||||||
|
// Take screenshot on failure
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Configure projects - Chromium only per requirements
|
||||||
|
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||||
|
|
||||||
|
// Run dev server before starting tests
|
||||||
|
// 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: 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
337
pnpm-lock.yaml
generated
337
pnpm-lock.yaml
generated
@@ -17,18 +17,27 @@ importers:
|
|||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.1
|
specifier: ^0.45.1
|
||||||
version: 0.45.1(@opentelemetry/api@1.9.0)
|
version: 0.45.1(@opentelemetry/api@1.9.0)
|
||||||
|
form-data:
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.5
|
||||||
ics:
|
ics:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1
|
version: 3.8.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.562.0
|
specifier: ^0.562.0
|
||||||
version: 0.562.0(react@19.2.3)
|
version: 0.562.0(react@19.2.3)
|
||||||
|
mailgun.js:
|
||||||
|
specifier: ^11.1.0
|
||||||
|
version: 11.1.0
|
||||||
next:
|
next:
|
||||||
specifier: 16.1.1
|
specifier: 16.1.1
|
||||||
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
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:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
|
oauth-1.0a:
|
||||||
|
specifier: ^2.2.6
|
||||||
|
version: 2.2.6
|
||||||
pino:
|
pino:
|
||||||
specifier: ^10.1.1
|
specifier: ^10.1.1
|
||||||
version: 10.1.1
|
version: 10.1.1
|
||||||
@@ -44,9 +53,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3(react@19.2.3)
|
version: 19.2.3(react@19.2.3)
|
||||||
resend:
|
sonner:
|
||||||
specifier: ^6.7.0
|
specifier: ^2.0.7
|
||||||
version: 6.7.0
|
version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
version: 3.4.0
|
version: 3.4.0
|
||||||
@@ -57,6 +66,9 @@ importers:
|
|||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.3.11
|
specifier: 2.3.11
|
||||||
version: 2.3.11
|
version: 2.3.11
|
||||||
|
'@playwright/test':
|
||||||
|
specifier: 1.56.1
|
||||||
|
version: 1.56.1
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.1.18
|
version: 4.1.18
|
||||||
@@ -69,6 +81,9 @@ importers:
|
|||||||
'@testing-library/react':
|
'@testing-library/react':
|
||||||
specifier: ^16.3.1
|
specifier: ^16.3.1
|
||||||
version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
|
'@testing-library/user-event':
|
||||||
|
specifier: ^14.6.1
|
||||||
|
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.27
|
version: 20.19.27
|
||||||
@@ -974,6 +989,11 @@ packages:
|
|||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
|
'@playwright/test@1.56.1':
|
||||||
|
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.53':
|
'@rolldown/pluginutils@1.0.0-beta.53':
|
||||||
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
|
||||||
|
|
||||||
@@ -1102,9 +1122,6 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@stablelib/base64@1.0.1':
|
|
||||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@@ -1222,6 +1239,12 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@testing-library/user-event@14.6.1':
|
||||||
|
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
peerDependencies:
|
||||||
|
'@testing-library/dom': '>=7.21.4'
|
||||||
|
|
||||||
'@types/aria-query@5.0.4':
|
'@types/aria-query@5.0.4':
|
||||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||||
|
|
||||||
@@ -1314,10 +1337,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
atomic-sleep@1.0.0:
|
atomic-sleep@1.0.0:
|
||||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
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:
|
baseline-browser-mapping@2.9.14:
|
||||||
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1336,6 +1368,10 @@ packages:
|
|||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
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:
|
caniuse-lite@1.0.30001763:
|
||||||
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==}
|
||||||
|
|
||||||
@@ -1353,6 +1389,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
@@ -1386,6 +1426,10 @@ packages:
|
|||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
dequal@2.0.3:
|
dequal@2.0.3:
|
||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1496,6 +1540,10 @@ packages:
|
|||||||
sqlite3:
|
sqlite3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
electron-to-chromium@1.5.267:
|
electron-to-chromium@1.5.267:
|
||||||
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
|
||||||
|
|
||||||
@@ -1507,9 +1555,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
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:
|
es-module-lexer@1.7.0:
|
||||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
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:
|
esbuild-register@3.6.0:
|
||||||
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1541,9 +1605,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
fast-sha256@1.3.0:
|
|
||||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1553,21 +1614,66 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
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}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
|
function-bind@1.1.2:
|
||||||
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2:
|
gensync@1.0.0-beta.2:
|
||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
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:
|
get-tsconfig@4.13.0:
|
||||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
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:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
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:
|
html-encoding-sniffer@6.0.0:
|
||||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -1705,9 +1811,25 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
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:
|
mdn-data@2.12.2:
|
||||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
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:
|
min-indent@1.0.1:
|
||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1748,6 +1870,9 @@ packages:
|
|||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
oauth-1.0a@2.2.6:
|
||||||
|
resolution: {integrity: sha512-6bkxv3N4Gu5lty4viIcIAnq5GbxECviMBeKR3WX/q87SPQ8E8aursPZUtsXDnxCs787af09WPRBLqYrf/lwoYQ==}
|
||||||
|
|
||||||
obug@2.1.1:
|
obug@2.1.1:
|
||||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||||
|
|
||||||
@@ -1778,6 +1903,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==}
|
resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
playwright-core@1.56.1:
|
||||||
|
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
playwright@1.56.1:
|
||||||
|
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pocketbase@0.26.5:
|
pocketbase@0.26.5:
|
||||||
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
|
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
|
||||||
|
|
||||||
@@ -1803,6 +1938,9 @@ packages:
|
|||||||
property-expr@2.0.6:
|
property-expr@2.0.6:
|
||||||
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1838,15 +1976,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
@@ -1888,6 +2017,12 @@ packages:
|
|||||||
sonic-boom@4.2.0:
|
sonic-boom@4.2.0:
|
||||||
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
||||||
|
|
||||||
|
sonner@2.0.7:
|
||||||
|
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1906,9 +2041,6 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
standardwebhooks@1.0.0:
|
|
||||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
|
||||||
|
|
||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
@@ -1929,9 +2061,6 @@ packages:
|
|||||||
babel-plugin-macros:
|
babel-plugin-macros:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
svix@1.84.1:
|
|
||||||
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
|
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
@@ -2012,9 +2141,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
uuid@10.0.0:
|
url-join@4.0.1:
|
||||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
vite@7.3.1:
|
vite@7.3.1:
|
||||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||||
@@ -2723,6 +2851,10 @@ snapshots:
|
|||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
|
'@playwright/test@1.56.1':
|
||||||
|
dependencies:
|
||||||
|
playwright: 1.56.1
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.55.1':
|
'@rollup/rollup-android-arm-eabi@4.55.1':
|
||||||
@@ -2800,8 +2932,6 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
'@rollup/rollup-win32-x64-msvc@4.55.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@stablelib/base64@1.0.1': {}
|
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
@@ -2907,6 +3037,10 @@ snapshots:
|
|||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
'@types/react-dom': 19.2.3(@types/react@19.2.7)
|
||||||
|
|
||||||
|
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
|
||||||
|
dependencies:
|
||||||
|
'@testing-library/dom': 10.4.1
|
||||||
|
|
||||||
'@types/aria-query@5.0.4': {}
|
'@types/aria-query@5.0.4': {}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
@@ -3016,8 +3150,20 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.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: {}
|
baseline-browser-mapping@2.9.14: {}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
@@ -3036,6 +3182,11 @@ snapshots:
|
|||||||
|
|
||||||
buffer-from@1.1.2: {}
|
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: {}
|
caniuse-lite@1.0.30001763: {}
|
||||||
|
|
||||||
chai@6.2.2: {}
|
chai@6.2.2: {}
|
||||||
@@ -3048,6 +3199,10 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
css-tree@3.1.0:
|
css-tree@3.1.0:
|
||||||
@@ -3077,6 +3232,8 @@ snapshots:
|
|||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
@@ -3098,6 +3255,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@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: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.4:
|
enhanced-resolve@5.18.4:
|
||||||
@@ -3107,8 +3270,23 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
es-module-lexer@1.7.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):
|
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -3207,23 +3385,66 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fast-sha256@1.3.0: {}
|
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
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:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.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:
|
get-tsconfig@4.13.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
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:
|
html-encoding-sniffer@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@exodus/bytes': 1.8.0
|
'@exodus/bytes': 1.8.0
|
||||||
@@ -3355,15 +3576,31 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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: {}
|
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: {}
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
next@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):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.1.1
|
'@next/env': 16.1.1
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
@@ -3383,6 +3620,7 @@ snapshots:
|
|||||||
'@next/swc-win32-arm64-msvc': 16.1.1
|
'@next/swc-win32-arm64-msvc': 16.1.1
|
||||||
'@next/swc-win32-x64-msvc': 16.1.1
|
'@next/swc-win32-x64-msvc': 16.1.1
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
|
'@playwright/test': 1.56.1
|
||||||
sharp: 0.34.5
|
sharp: 0.34.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
@@ -3392,6 +3630,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
oauth-1.0a@2.2.6: {}
|
||||||
|
|
||||||
obug@2.1.1: {}
|
obug@2.1.1: {}
|
||||||
|
|
||||||
on-exit-leak-free@2.1.2: {}
|
on-exit-leak-free@2.1.2: {}
|
||||||
@@ -3426,6 +3666,14 @@ snapshots:
|
|||||||
sonic-boom: 4.2.0
|
sonic-boom: 4.2.0
|
||||||
thread-stream: 4.0.0
|
thread-stream: 4.0.0
|
||||||
|
|
||||||
|
playwright-core@1.56.1: {}
|
||||||
|
|
||||||
|
playwright@1.56.1:
|
||||||
|
dependencies:
|
||||||
|
playwright-core: 1.56.1
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.2
|
||||||
|
|
||||||
pocketbase@0.26.5: {}
|
pocketbase@0.26.5: {}
|
||||||
|
|
||||||
postcss@8.4.31:
|
postcss@8.4.31:
|
||||||
@@ -3455,6 +3703,8 @@ snapshots:
|
|||||||
|
|
||||||
property-expr@2.0.6: {}
|
property-expr@2.0.6: {}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
quick-format-unescaped@4.0.4: {}
|
quick-format-unescaped@4.0.4: {}
|
||||||
@@ -3479,10 +3729,6 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resend@6.7.0:
|
|
||||||
dependencies:
|
|
||||||
svix: 1.84.1
|
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
rollup@4.55.1:
|
rollup@4.55.1:
|
||||||
@@ -3569,6 +3815,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
|
sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
react-dom: 19.2.3(react@19.2.3)
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map-support@0.5.21:
|
source-map-support@0.5.21:
|
||||||
@@ -3582,11 +3833,6 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
standardwebhooks@1.0.0:
|
|
||||||
dependencies:
|
|
||||||
'@stablelib/base64': 1.0.1
|
|
||||||
fast-sha256: 1.3.0
|
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
strip-indent@3.0.0:
|
strip-indent@3.0.0:
|
||||||
@@ -3600,11 +3846,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
|
|
||||||
svix@1.84.1:
|
|
||||||
dependencies:
|
|
||||||
standardwebhooks: 1.0.0
|
|
||||||
uuid: 10.0.0
|
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.4.0: {}
|
||||||
@@ -3666,7 +3907,7 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
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):
|
vite@7.3.1(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ Usage:
|
|||||||
python3 garmin_auth.py
|
python3 garmin_auth.py
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import garth
|
import garth
|
||||||
|
from garth.auth_tokens import OAuth1Token, OAuth2Token
|
||||||
|
from garth.exc import GarthHTTPError
|
||||||
|
from pydantic import TypeAdapter
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print("Error: garth library not installed.")
|
print("Error: garth library not installed.")
|
||||||
print("Please install it with: pip install garth")
|
print("Please install it with: pip install garth")
|
||||||
@@ -23,15 +28,33 @@ email = input("Garmin email: ")
|
|||||||
password = getpass("Garmin password: ")
|
password = getpass("Garmin password: ")
|
||||||
|
|
||||||
# MFA handled automatically - prompts if needed
|
# MFA handled automatically - prompts if needed
|
||||||
garth.login(email, password)
|
try:
|
||||||
|
garth.login(email, password)
|
||||||
|
except GarthHTTPError as e:
|
||||||
|
if "401" in str(e):
|
||||||
|
print("\nError: Invalid email or password.", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(f"\nError: Authentication failed - {e}", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError: {e}", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
# Serialize Pydantic dataclasses using TypeAdapter
|
||||||
|
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 = {
|
tokens = {
|
||||||
"oauth1": garth.client.oauth1_token.serialize(),
|
"oauth1": oauth1_adapter.dump_python(garth.client.oauth1_token, mode='json'),
|
||||||
"oauth2": garth.client.oauth2_token.serialize(),
|
"oauth2": oauth2_adapter.dump_python(garth.client.oauth2_token, mode='json'),
|
||||||
"expires_at": garth.client.oauth2_token.expires_at.isoformat()
|
"expires_at": datetime.fromtimestamp(expires_at_ts).isoformat(),
|
||||||
|
"refresh_token_expires_at": datetime.fromtimestamp(refresh_expires_at_ts).isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
print("\n--- Copy everything below this line ---")
|
print("\n--- Copy everything below this line ---")
|
||||||
print(json.dumps(tokens, indent=2))
|
print(json.dumps(tokens, indent=2))
|
||||||
print("--- Copy everything above this line ---")
|
print("--- Copy everything above this line ---")
|
||||||
print(f"\nTokens expire: {tokens['expires_at']}")
|
print(f"\nAccess token expires: {tokens['expires_at']}")
|
||||||
|
print(f"Refresh token expires: {tokens['refresh_token_expires_at']} (re-run script before this date)")
|
||||||
|
|||||||
231
scripts/setup-db.test.ts
Normal file
231
scripts/setup-db.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
// ABOUTME: Tests for the database setup script that creates PocketBase collections.
|
||||||
|
// ABOUTME: Verifies collection definitions and setup logic without hitting real PocketBase.
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DAILY_LOGS_COLLECTION,
|
||||||
|
getExistingCollectionNames,
|
||||||
|
getMissingCollections,
|
||||||
|
PERIOD_LOGS_COLLECTION,
|
||||||
|
USER_CUSTOM_FIELDS,
|
||||||
|
} from "./setup-db";
|
||||||
|
|
||||||
|
describe("PERIOD_LOGS_COLLECTION", () => {
|
||||||
|
it("has correct name", () => {
|
||||||
|
expect(PERIOD_LOGS_COLLECTION.name).toBe("period_logs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has required fields", () => {
|
||||||
|
const fieldNames = PERIOD_LOGS_COLLECTION.fields.map((f) => f.name);
|
||||||
|
expect(fieldNames).toContain("user");
|
||||||
|
expect(fieldNames).toContain("startDate");
|
||||||
|
expect(fieldNames).toContain("predictedDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has user field as relation to users", () => {
|
||||||
|
const userField = PERIOD_LOGS_COLLECTION.fields.find(
|
||||||
|
(f) => f.name === "user",
|
||||||
|
);
|
||||||
|
expect(userField?.type).toBe("relation");
|
||||||
|
expect(userField?.collectionId).toBe("users");
|
||||||
|
expect(userField?.maxSelect).toBe(1);
|
||||||
|
expect(userField?.cascadeDelete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has startDate as required date", () => {
|
||||||
|
const startDateField = PERIOD_LOGS_COLLECTION.fields.find(
|
||||||
|
(f) => f.name === "startDate",
|
||||||
|
);
|
||||||
|
expect(startDateField?.type).toBe("date");
|
||||||
|
expect(startDateField?.required).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has predictedDate as optional date", () => {
|
||||||
|
const predictedDateField = PERIOD_LOGS_COLLECTION.fields.find(
|
||||||
|
(f) => f.name === "predictedDate",
|
||||||
|
);
|
||||||
|
expect(predictedDateField?.type).toBe("date");
|
||||||
|
expect(predictedDateField?.required).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DAILY_LOGS_COLLECTION", () => {
|
||||||
|
it("has correct name", () => {
|
||||||
|
expect(DAILY_LOGS_COLLECTION.name).toBe("dailyLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has all required fields", () => {
|
||||||
|
const fieldNames = DAILY_LOGS_COLLECTION.fields.map((f) => f.name);
|
||||||
|
|
||||||
|
// Core fields
|
||||||
|
expect(fieldNames).toContain("user");
|
||||||
|
expect(fieldNames).toContain("date");
|
||||||
|
expect(fieldNames).toContain("cycleDay");
|
||||||
|
expect(fieldNames).toContain("phase");
|
||||||
|
|
||||||
|
// Garmin biometric fields
|
||||||
|
expect(fieldNames).toContain("bodyBatteryCurrent");
|
||||||
|
expect(fieldNames).toContain("bodyBatteryYesterdayLow");
|
||||||
|
expect(fieldNames).toContain("hrvStatus");
|
||||||
|
expect(fieldNames).toContain("weekIntensityMinutes");
|
||||||
|
|
||||||
|
// Decision fields
|
||||||
|
expect(fieldNames).toContain("phaseLimit");
|
||||||
|
expect(fieldNames).toContain("remainingMinutes");
|
||||||
|
expect(fieldNames).toContain("trainingDecision");
|
||||||
|
expect(fieldNames).toContain("decisionReason");
|
||||||
|
expect(fieldNames).toContain("notificationSentAt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has user field as relation to users", () => {
|
||||||
|
const userField = DAILY_LOGS_COLLECTION.fields.find(
|
||||||
|
(f) => f.name === "user",
|
||||||
|
);
|
||||||
|
expect(userField?.type).toBe("relation");
|
||||||
|
expect(userField?.collectionId).toBe("users");
|
||||||
|
expect(userField?.maxSelect).toBe(1);
|
||||||
|
expect(userField?.cascadeDelete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has trainingDecision as required text", () => {
|
||||||
|
const field = DAILY_LOGS_COLLECTION.fields.find(
|
||||||
|
(f) => f.name === "trainingDecision",
|
||||||
|
);
|
||||||
|
expect(field?.type).toBe("text");
|
||||||
|
expect(field?.required).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getExistingCollectionNames", () => {
|
||||||
|
it("extracts collection names from PocketBase response", async () => {
|
||||||
|
const mockPb = {
|
||||||
|
collections: {
|
||||||
|
getFullList: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: "users" },
|
||||||
|
{ name: "period_logs" },
|
||||||
|
{ name: "_superusers" },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||||
|
const names = await getExistingCollectionNames(mockPb as any);
|
||||||
|
|
||||||
|
expect(names).toEqual(["users", "period_logs", "_superusers"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no collections exist", async () => {
|
||||||
|
const mockPb = {
|
||||||
|
collections: {
|
||||||
|
getFullList: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: test mock
|
||||||
|
const names = await getExistingCollectionNames(mockPb as any);
|
||||||
|
|
||||||
|
expect(names).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMissingCollections", () => {
|
||||||
|
it("returns both collections when none exist", () => {
|
||||||
|
const existing = ["users"];
|
||||||
|
const missing = getMissingCollections(existing);
|
||||||
|
|
||||||
|
expect(missing).toHaveLength(2);
|
||||||
|
expect(missing.map((c) => c.name)).toContain("period_logs");
|
||||||
|
expect(missing.map((c) => c.name)).toContain("dailyLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only dailyLogs when period_logs exists", () => {
|
||||||
|
const existing = ["users", "period_logs"];
|
||||||
|
const missing = getMissingCollections(existing);
|
||||||
|
|
||||||
|
expect(missing).toHaveLength(1);
|
||||||
|
expect(missing[0].name).toBe("dailyLogs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns only period_logs when dailyLogs exists", () => {
|
||||||
|
const existing = ["users", "dailyLogs"];
|
||||||
|
const missing = getMissingCollections(existing);
|
||||||
|
|
||||||
|
expect(missing).toHaveLength(1);
|
||||||
|
expect(missing[0].name).toBe("period_logs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when all collections exist", () => {
|
||||||
|
const existing = ["users", "period_logs", "dailyLogs"];
|
||||||
|
const missing = getMissingCollections(existing);
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
415
scripts/setup-db.ts
Normal file
415
scripts/setup-db.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
// ABOUTME: Database setup script for creating PocketBase collections.
|
||||||
|
// ABOUTME: Run with: POCKETBASE_ADMIN_EMAIL=... POCKETBASE_ADMIN_PASSWORD=... pnpm db:setup
|
||||||
|
import PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection field definition for PocketBase.
|
||||||
|
* For relation fields, collectionId/maxSelect/cascadeDelete are top-level properties.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
cascadeDelete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection definition for PocketBase.
|
||||||
|
*/
|
||||||
|
export interface CollectionDefinition {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
fields: CollectionField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Period logs collection schema - tracks menstrual cycle start dates.
|
||||||
|
* Note: collectionId will be resolved at runtime to the actual users collection ID.
|
||||||
|
*/
|
||||||
|
export const PERIOD_LOGS_COLLECTION: CollectionDefinition = {
|
||||||
|
name: "period_logs",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: "users", // Will be resolved to actual ID at runtime
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "startDate",
|
||||||
|
type: "date",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "predictedDate",
|
||||||
|
type: "date",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily logs collection schema - daily training snapshots with biometrics.
|
||||||
|
* Note: collectionId will be resolved at runtime to the actual users collection ID.
|
||||||
|
*/
|
||||||
|
export const DAILY_LOGS_COLLECTION: CollectionDefinition = {
|
||||||
|
name: "dailyLogs",
|
||||||
|
type: "base",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "user",
|
||||||
|
type: "relation",
|
||||||
|
required: true,
|
||||||
|
collectionId: "users", // Will be resolved to actual ID at runtime
|
||||||
|
maxSelect: 1,
|
||||||
|
cascadeDelete: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "date",
|
||||||
|
type: "date",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycleDay",
|
||||||
|
type: "number",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phase",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bodyBatteryCurrent",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bodyBatteryYesterdayLow",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hrvStatus",
|
||||||
|
type: "text",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "weekIntensityMinutes",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phaseLimit",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remainingMinutes",
|
||||||
|
type: "number",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trainingDecision",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "decisionReason",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "notificationSentAt",
|
||||||
|
type: "date",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All collections that should exist in the database.
|
||||||
|
*/
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export async function getExistingCollectionNames(
|
||||||
|
pb: PocketBase,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const collections = await pb.collections.getFullList();
|
||||||
|
return collections.map((c) => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns collection definitions that don't exist in the database.
|
||||||
|
*/
|
||||||
|
export function getMissingCollections(
|
||||||
|
existingNames: string[],
|
||||||
|
): CollectionDefinition[] {
|
||||||
|
return REQUIRED_COLLECTIONS.filter(
|
||||||
|
(collection) => !existingNames.includes(collection.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves collection name to actual collection ID.
|
||||||
|
*/
|
||||||
|
async function resolveCollectionId(
|
||||||
|
pb: PocketBase,
|
||||||
|
nameOrId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const collection = await pb.collections.getOne(nameOrId);
|
||||||
|
return collection.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a collection in PocketBase.
|
||||||
|
*/
|
||||||
|
export async function createCollection(
|
||||||
|
pb: PocketBase,
|
||||||
|
collection: CollectionDefinition,
|
||||||
|
): Promise<void> {
|
||||||
|
// Resolve any collection names to actual IDs for relation fields
|
||||||
|
const fields = await Promise.all(
|
||||||
|
collection.fields.map(async (field) => {
|
||||||
|
const baseField: Record<string, unknown> = {
|
||||||
|
name: field.name,
|
||||||
|
type: field.type,
|
||||||
|
required: field.required ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For relation fields, resolve collectionId and add relation-specific props
|
||||||
|
if (field.type === "relation" && field.collectionId) {
|
||||||
|
const resolvedId = await resolveCollectionId(pb, field.collectionId);
|
||||||
|
baseField.collectionId = resolvedId;
|
||||||
|
baseField.maxSelect = field.maxSelect ?? 1;
|
||||||
|
baseField.cascadeDelete = field.cascadeDelete ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseField;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await pb.collections.create({
|
||||||
|
name: collection.name,
|
||||||
|
type: collection.type,
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// Validate environment
|
||||||
|
const pocketbaseUrl =
|
||||||
|
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://localhost:8090";
|
||||||
|
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
|
||||||
|
const adminPassword = process.env.POCKETBASE_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (!adminEmail || !adminPassword) {
|
||||||
|
console.error(
|
||||||
|
"Error: POCKETBASE_ADMIN_EMAIL and POCKETBASE_ADMIN_PASSWORD are required",
|
||||||
|
);
|
||||||
|
console.error("Usage:");
|
||||||
|
console.error(
|
||||||
|
" POCKETBASE_ADMIN_EMAIL=admin@example.com POCKETBASE_ADMIN_PASSWORD=secret pnpm db:setup",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Connecting to PocketBase at ${pocketbaseUrl}...`);
|
||||||
|
|
||||||
|
const pb = new PocketBase(pocketbaseUrl);
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
// Authenticate as admin
|
||||||
|
try {
|
||||||
|
await pb
|
||||||
|
.collection("_superusers")
|
||||||
|
.authWithPassword(adminEmail, adminPassword);
|
||||||
|
console.log("Authenticated as admin");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to authenticate as admin:", error);
|
||||||
|
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(
|
||||||
|
`Found ${existingNames.length} existing collections:`,
|
||||||
|
existingNames,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find and create missing collections
|
||||||
|
const missing = getMissingCollections(existingNames);
|
||||||
|
|
||||||
|
if (missing.length === 0) {
|
||||||
|
console.log("All required collections already exist.");
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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!");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run main function when executed directly
|
||||||
|
const isMainModule = typeof require !== "undefined" && require.main === module;
|
||||||
|
|
||||||
|
// For ES modules / tsx execution
|
||||||
|
if (isMainModule || process.argv[1]?.includes("setup-db")) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Setup failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
2
spec.md
2
spec.md
@@ -835,7 +835,7 @@ The following are **out of scope** for MVP:
|
|||||||
| Hormonal birth control | May disrupt natural cycle phases |
|
| Hormonal birth control | May disrupt natural cycle phases |
|
||||||
| API versioning | Single version; breaking changes via deprecation |
|
| API versioning | Single version; breaking changes via deprecation |
|
||||||
| Formal API documentation | Endpoints documented in spec only |
|
| Formal API documentation | Endpoints documented in spec only |
|
||||||
| E2E tests | Unit + integration tests only (authorized skip) |
|
| Multi-user support | Single-user design only |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
130
specs/testing.md
130
specs/testing.md
@@ -6,7 +6,7 @@ When I make changes to the codebase, I want automated tests to catch regressions
|
|||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are not required for MVP (authorized skip).
|
PhaseFlow uses a **three-tier testing approach**: unit tests, integration tests, and end-to-end tests.
|
||||||
|
|
||||||
### Test Types
|
### Test Types
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are
|
|||||||
|------|-------|-------|----------|
|
|------|-------|-------|----------|
|
||||||
| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` |
|
| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` |
|
||||||
| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` |
|
| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` |
|
||||||
|
| E2E | Full user flows, browser interactions | Playwright | `e2e/*.spec.ts` |
|
||||||
|
|
||||||
## Framework
|
## Framework
|
||||||
|
|
||||||
@@ -148,9 +149,96 @@ describe('GET /api/today', () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## End-to-End Tests
|
||||||
|
|
||||||
|
Test complete user flows through the browser using Playwright.
|
||||||
|
|
||||||
|
### Framework
|
||||||
|
|
||||||
|
**Playwright** - Cross-browser E2E testing with auto-waiting and tracing.
|
||||||
|
|
||||||
|
**Configuration (`playwright.config.ts`):**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: [["html", { open: "never" }], ["list"]],
|
||||||
|
use: {
|
||||||
|
baseURL: "http://localhost:3000",
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: "pnpm dev",
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priority Targets
|
||||||
|
|
||||||
|
| Flow | Tests |
|
||||||
|
|------|-------|
|
||||||
|
| Authentication | Login page loads, login redirects, logout works |
|
||||||
|
| Dashboard | Shows decision, displays cycle info, override toggles work |
|
||||||
|
| Settings | Garmin token paste, preferences save |
|
||||||
|
| Calendar | ICS feed accessible, calendar view renders |
|
||||||
|
| Period logging | "Period Started" updates cycle |
|
||||||
|
|
||||||
|
### Example Test
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// e2e/dashboard.spec.ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("dashboard", () => {
|
||||||
|
test("shows training decision for authenticated user", async ({ page }) => {
|
||||||
|
// Login first (or use auth state)
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Verify decision card is visible
|
||||||
|
await expect(page.getByTestId("decision-card")).toBeVisible();
|
||||||
|
|
||||||
|
// Verify cycle day is displayed
|
||||||
|
await expect(page.getByText(/Day \d+ of \d+/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("override toggles update decision", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Enable flare mode
|
||||||
|
await page.getByLabel("Flare Mode").click();
|
||||||
|
|
||||||
|
// Decision should change to gentle
|
||||||
|
await expect(page.getByText(/GENTLE/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### NixOS Setup
|
||||||
|
|
||||||
|
E2E tests require browser binaries. On NixOS, use `playwright-web-flake`:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# In flake.nix inputs
|
||||||
|
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
|
||||||
|
|
||||||
|
# In devShell
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
|
||||||
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
|
||||||
|
```
|
||||||
|
|
||||||
## File Naming
|
## File Naming
|
||||||
|
|
||||||
Tests colocated with source files:
|
Tests colocated with source files (unit/integration) or in `e2e/` directory (E2E):
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
@@ -164,22 +252,33 @@ src/
|
|||||||
today/
|
today/
|
||||||
route.ts
|
route.ts
|
||||||
route.test.ts
|
route.test.ts
|
||||||
|
e2e/
|
||||||
|
smoke.spec.ts
|
||||||
|
dashboard.spec.ts
|
||||||
|
auth.spec.ts
|
||||||
|
settings.spec.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Tests
|
## Running Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run unit/integration tests
|
||||||
npm test
|
pnpm test:run
|
||||||
|
|
||||||
# Run with coverage
|
|
||||||
npm run test:coverage
|
|
||||||
|
|
||||||
# Run in watch mode
|
# Run in watch mode
|
||||||
npm run test:watch
|
pnpm test
|
||||||
|
|
||||||
# Run specific file
|
# Run E2E tests (headless)
|
||||||
npm test -- src/lib/cycle.test.ts
|
pnpm test:e2e
|
||||||
|
|
||||||
|
# Run E2E tests (visible browser)
|
||||||
|
pnpm test:e2e:headed
|
||||||
|
|
||||||
|
# Run E2E tests with UI mode
|
||||||
|
pnpm test:e2e:ui
|
||||||
|
|
||||||
|
# Run all tests (unit + E2E)
|
||||||
|
pnpm test:run && pnpm test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coverage Expectations
|
## Coverage Expectations
|
||||||
@@ -190,15 +289,20 @@ No strict coverage thresholds for MVP, but aim for:
|
|||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
1. All tests pass in CI before merge
|
1. All tests (unit, integration, E2E) pass in CI before merge
|
||||||
2. Core decision engine logic has comprehensive tests
|
2. Core decision engine logic has comprehensive unit tests
|
||||||
3. Phase scaling tested for multiple cycle lengths
|
3. Phase scaling tested for multiple cycle lengths
|
||||||
4. API auth tested for protected routes
|
4. API auth tested for protected routes
|
||||||
|
5. Critical user flows covered by E2E tests
|
||||||
|
|
||||||
## Acceptance Tests
|
## Acceptance Tests
|
||||||
|
|
||||||
- [ ] `npm test` runs without errors
|
- [ ] `pnpm test:run` runs without errors
|
||||||
|
- [ ] `pnpm test:e2e` runs without errors
|
||||||
- [ ] Unit tests cover decision engine logic
|
- [ ] Unit tests cover decision engine logic
|
||||||
- [ ] Unit tests cover cycle phase calculations
|
- [ ] Unit tests cover cycle phase calculations
|
||||||
- [ ] Integration tests verify API authentication
|
- [ ] Integration tests verify API authentication
|
||||||
|
- [ ] E2E tests verify login flow
|
||||||
|
- [ ] E2E tests verify dashboard displays correctly
|
||||||
|
- [ ] E2E tests verify period logging works
|
||||||
- [ ] Tests run in CI pipeline
|
- [ ] Tests run in CI pipeline
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { logger } from "@/lib/logger";
|
|||||||
* Clears the user's authentication session by deleting the pb_auth cookie.
|
* Clears the user's authentication session by deleting the pb_auth cookie.
|
||||||
* Returns a success response with redirect URL.
|
* Returns a success response with redirect URL.
|
||||||
*/
|
*/
|
||||||
export async function POST(): Promise<NextResponse> {
|
export async function POST(_request: Request): Promise<NextResponse> {
|
||||||
try {
|
try {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
|||||||
@@ -79,12 +79,18 @@ describe("GET /api/calendar/[userId]/[token].ics", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "valid-calendar-token-abc123def",
|
calendarToken: "valid-calendar-token-abc123def",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ interface RouteParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||||
const { userId, token } = await params;
|
const { userId, token: rawToken } = await params;
|
||||||
|
// Strip .ics suffix if present (Next.js may include it in the param)
|
||||||
|
const token = rawToken.endsWith(".ics") ? rawToken.slice(0, -4) : rawToken;
|
||||||
|
const pb = createPocketBaseClient();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch user from database
|
// Fetch user from database
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
const user = await pb.collection("users").getOne(userId);
|
const user = await pb.collection("users").getOne(userId);
|
||||||
|
|
||||||
// Check if user has a calendar token set
|
// Check if user has a calendar token set
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ let currentMockUser: User | null = null;
|
|||||||
// Track PocketBase update calls
|
// Track PocketBase update calls
|
||||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
// Mock PocketBase
|
// Create mock PocketBase client
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
const mockPb = {
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
collection: vi.fn(() => ({
|
||||||
collection: vi.fn(() => ({
|
update: mockPbUpdate,
|
||||||
update: mockPbUpdate,
|
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
}));
|
};
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
@@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -43,12 +41,18 @@ describe("POST /api/calendar/regenerate-token", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "old-calendar-token-abc123",
|
calendarToken: "old-calendar-token-abc123",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a cryptographically secure random 32-character alphanumeric token.
|
* Generates a cryptographically secure random 32-character alphanumeric token.
|
||||||
@@ -17,12 +16,11 @@ function generateToken(): string {
|
|||||||
return randomBytes(32).toString("hex").slice(0, 32);
|
return randomBytes(32).toString("hex").slice(0, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST = withAuth(async (_request, user) => {
|
export const POST = withAuth(async (_request, user, pb) => {
|
||||||
// Generate new random token
|
// Generate new random token
|
||||||
const newToken = generateToken();
|
const newToken = generateToken();
|
||||||
|
|
||||||
// Update user record with new token
|
// Update user record with new token
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
await pb.collection("users").update(user.id, {
|
await pb.collection("users").update(user.id, {
|
||||||
calendarToken: newToken,
|
calendarToken: newToken,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
// ABOUTME: Unit tests for Garmin sync cron endpoint.
|
||||||
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
// ABOUTME: Tests daily sync of Garmin biometric data for all connected users.
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { User } from "@/types";
|
import type { User } from "@/types";
|
||||||
|
|
||||||
@@ -8,6 +8,21 @@ import type { User } from "@/types";
|
|||||||
let mockUsers: User[] = [];
|
let mockUsers: User[] = [];
|
||||||
// Track DailyLog creations
|
// Track DailyLog creations
|
||||||
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
const mockPbCreate = vi.fn().mockResolvedValue({ id: "log123" });
|
||||||
|
// Track user updates
|
||||||
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
// 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
|
// Mock PocketBase
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
@@ -19,16 +34,54 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
|
getFirstListItem: vi.fn(async (filter: string) => {
|
||||||
|
if (name === "dailyLogs") {
|
||||||
|
lastDailyLogFilter = filter;
|
||||||
|
}
|
||||||
|
return mockGetFirstListItem(filter);
|
||||||
|
}),
|
||||||
create: mockPbCreate,
|
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
|
// Mock decryption
|
||||||
const mockDecrypt = vi.fn((ciphertext: string) => {
|
const mockDecrypt = vi.fn((ciphertext: string) => {
|
||||||
// Return mock OAuth2 token JSON
|
// Return mock OAuth2 token JSON
|
||||||
if (ciphertext.includes("oauth2")) {
|
if (ciphertext.includes("oauth2")) {
|
||||||
return JSON.stringify({ accessToken: "mock-token-123" });
|
return JSON.stringify({ access_token: "mock-token-123" });
|
||||||
|
}
|
||||||
|
// Return mock OAuth1 token JSON (needed for refresh flow)
|
||||||
|
if (ciphertext.includes("oauth1")) {
|
||||||
|
return JSON.stringify({
|
||||||
|
oauth_token: "mock-oauth1-token",
|
||||||
|
oauth_token_secret: "mock-oauth1-secret",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return ciphertext.replace("encrypted:", "");
|
return ciphertext.replace("encrypted:", "");
|
||||||
});
|
});
|
||||||
@@ -57,10 +110,15 @@ vi.mock("@/lib/garmin", () => ({
|
|||||||
|
|
||||||
// Mock email sending
|
// Mock email sending
|
||||||
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("@/lib/email", () => ({
|
vi.mock("@/lib/email", () => ({
|
||||||
sendTokenExpirationWarning: (...args: unknown[]) =>
|
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||||
mockSendTokenExpirationWarning(...args),
|
mockSendTokenExpirationWarning(...args),
|
||||||
|
sendDailyEmail: (...args: unknown[]) => mockSendDailyEmail(...args),
|
||||||
|
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||||
|
mockSendPeriodConfirmationEmail(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock logger (required for route to run without side effects)
|
// Mock logger (required for route to run without side effects)
|
||||||
@@ -87,12 +145,18 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
garminOauth1Token: "encrypted:oauth1-token",
|
garminOauth1Token: "encrypted:oauth1-token",
|
||||||
garminOauth2Token: "encrypted:oauth2-token",
|
garminOauth2Token: "encrypted:oauth2-token",
|
||||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-token",
|
calendarToken: "cal-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:00",
|
notificationTime: "07:00",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -112,9 +176,18 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
mockUsers = [];
|
mockUsers = [];
|
||||||
|
lastDailyLogFilter = null;
|
||||||
mockDaysUntilExpiry.mockReturnValue(30); // Default to 30 days remaining
|
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.CRON_SECRET = validSecret;
|
||||||
|
process.env.POCKETBASE_ADMIN_EMAIL = "admin@test.com";
|
||||||
|
process.env.POCKETBASE_ADMIN_PASSWORD = "test-password";
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Authentication", () => {
|
describe("Authentication", () => {
|
||||||
@@ -141,6 +214,26 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(401);
|
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", () => {
|
describe("User fetching", () => {
|
||||||
@@ -177,6 +270,37 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.usersProcessed).toBe(0);
|
expect(body.usersProcessed).toBe(0);
|
||||||
expect(body.success).toBe(true);
|
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", () => {
|
describe("Token handling", () => {
|
||||||
@@ -188,9 +312,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
expect(mockDecrypt).toHaveBeenCalledWith("encrypted:oauth2-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips users with expired tokens", async () => {
|
it("skips users with expired refresh tokens", async () => {
|
||||||
mockIsTokenExpired.mockReturnValue(true);
|
// Set refresh token to expired (in the past)
|
||||||
mockUsers = [createMockUser()];
|
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||||
|
];
|
||||||
|
|
||||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -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()];
|
mockUsers = [createMockUser()];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
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()];
|
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];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
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", () => {
|
describe("Error handling", () => {
|
||||||
it("continues processing other users when one fails", async () => {
|
it("continues processing other users when one fails", async () => {
|
||||||
mockUsers = [
|
mockUsers = [
|
||||||
@@ -356,7 +560,10 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.errors).toBe(1);
|
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()];
|
mockUsers = [createMockUser()];
|
||||||
mockFetchBodyBattery.mockResolvedValue({
|
mockFetchBodyBattery.mockResolvedValue({
|
||||||
current: null,
|
current: null,
|
||||||
@@ -415,9 +622,28 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Token expiration warnings", () => {
|
describe("Token expiration warnings", () => {
|
||||||
it("sends warning email when token expires in exactly 14 days", async () => {
|
// Use fake timers to ensure consistent date calculations
|
||||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
beforeEach(() => {
|
||||||
mockDaysUntilExpiry.mockReturnValue(14);
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2025-01-15T12:00:00Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to create a date N days from now
|
||||||
|
function daysFromNow(days: number): Date {
|
||||||
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sends warning email when refresh token expires in exactly 14 days", async () => {
|
||||||
|
mockUsers = [
|
||||||
|
createMockUser({
|
||||||
|
email: "user@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -430,9 +656,13 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.warningsSent).toBe(1);
|
expect(body.warningsSent).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends warning email when token expires in exactly 7 days", async () => {
|
it("sends warning email when refresh token expires in exactly 7 days", async () => {
|
||||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(7);
|
createMockUser({
|
||||||
|
email: "user@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -445,36 +675,40 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.warningsSent).toBe(1);
|
expect(body.warningsSent).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 30 days", async () => {
|
it("does not send warning when refresh token expires in 30 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(30);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(30) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 15 days", async () => {
|
it("does not send warning when refresh token expires in 15 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(15);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(15) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 8 days", async () => {
|
it("does not send warning when refresh token expires in 8 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(8);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(8) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
expect(mockSendTokenExpirationWarning).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning when token expires in 6 days", async () => {
|
it("does not send warning when refresh token expires in 6 days", async () => {
|
||||||
mockUsers = [createMockUser()];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(6);
|
createMockUser({ garminRefreshTokenExpiresAt: daysFromNow(6) }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -483,11 +717,17 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
|
|
||||||
it("sends warnings for multiple users on different thresholds", async () => {
|
it("sends warnings for multiple users on different thresholds", async () => {
|
||||||
mockUsers = [
|
mockUsers = [
|
||||||
createMockUser({ id: "user1", email: "user1@example.com" }),
|
createMockUser({
|
||||||
createMockUser({ id: "user2", email: "user2@example.com" }),
|
id: "user1",
|
||||||
|
email: "user1@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||||
|
}),
|
||||||
|
createMockUser({
|
||||||
|
id: "user2",
|
||||||
|
email: "user2@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(7),
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
// First user at 14 days, second user at 7 days
|
|
||||||
mockDaysUntilExpiry.mockReturnValueOnce(14).mockReturnValueOnce(7);
|
|
||||||
|
|
||||||
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
const response = await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
@@ -507,8 +747,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("continues processing sync even if warning email fails", async () => {
|
it("continues processing sync even if warning email fails", async () => {
|
||||||
mockUsers = [createMockUser({ email: "user@example.com" })];
|
mockUsers = [
|
||||||
mockDaysUntilExpiry.mockReturnValue(14);
|
createMockUser({
|
||||||
|
email: "user@example.com",
|
||||||
|
garminRefreshTokenExpiresAt: daysFromNow(14),
|
||||||
|
}),
|
||||||
|
];
|
||||||
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
mockSendTokenExpirationWarning.mockRejectedValueOnce(
|
||||||
new Error("Email failed"),
|
new Error("Email failed"),
|
||||||
);
|
);
|
||||||
@@ -520,10 +764,12 @@ describe("POST /api/cron/garmin-sync", () => {
|
|||||||
expect(body.usersProcessed).toBe(1);
|
expect(body.usersProcessed).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not send warning for expired tokens", async () => {
|
it("does not send warning for expired refresh tokens", async () => {
|
||||||
mockUsers = [createMockUser()];
|
// Expired refresh tokens are skipped entirely (not synced), so no warning
|
||||||
mockIsTokenExpired.mockReturnValue(true);
|
const expiredDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||||
mockDaysUntilExpiry.mockReturnValue(-1);
|
mockUsers = [
|
||||||
|
createMockUser({ garminRefreshTokenExpiresAt: expiredDate }),
|
||||||
|
];
|
||||||
|
|
||||||
await POST(createMockRequest(`Bearer ${validSecret}`));
|
await POST(createMockRequest(`Bearer ${validSecret}`));
|
||||||
|
|
||||||
|
|||||||
@@ -2,31 +2,34 @@
|
|||||||
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
// ABOUTME: Fetches body battery, HRV, and intensity minutes for all users.
|
||||||
import { NextResponse } from "next/server";
|
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 { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||||
import { sendTokenExpirationWarning } from "@/lib/email";
|
import { sendTokenExpirationWarning } from "@/lib/email";
|
||||||
import { decrypt } from "@/lib/encryption";
|
import { decrypt, encrypt } from "@/lib/encryption";
|
||||||
import {
|
import {
|
||||||
daysUntilExpiry,
|
|
||||||
fetchBodyBattery,
|
fetchBodyBattery,
|
||||||
fetchHrvStatus,
|
fetchHrvStatus,
|
||||||
fetchIntensityMinutes,
|
fetchIntensityMinutes,
|
||||||
isTokenExpired,
|
|
||||||
} from "@/lib/garmin";
|
} from "@/lib/garmin";
|
||||||
|
import {
|
||||||
|
exchangeOAuth1ForOAuth2,
|
||||||
|
isAccessTokenExpired,
|
||||||
|
type OAuth1TokenData,
|
||||||
|
} from "@/lib/garmin-auth";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import {
|
import {
|
||||||
activeUsersGauge,
|
activeUsersGauge,
|
||||||
garminSyncDuration,
|
garminSyncDuration,
|
||||||
garminSyncTotal,
|
garminSyncTotal,
|
||||||
} from "@/lib/metrics";
|
} from "@/lib/metrics";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient, mapRecordToUser } from "@/lib/pocketbase";
|
||||||
import type { GarminTokens, User } from "@/types";
|
|
||||||
|
|
||||||
interface SyncResult {
|
interface SyncResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
usersProcessed: number;
|
usersProcessed: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
skippedExpired: number;
|
skippedExpired: number;
|
||||||
|
tokensRefreshed: number;
|
||||||
warningsSent: number;
|
warningsSent: number;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
@@ -47,63 +50,136 @@ export async function POST(request: Request) {
|
|||||||
usersProcessed: 0,
|
usersProcessed: 0,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
skippedExpired: 0,
|
skippedExpired: 0,
|
||||||
|
tokensRefreshed: 0,
|
||||||
warningsSent: 0,
|
warningsSent: 0,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const pb = createPocketBaseClient();
|
const pb = createPocketBaseClient();
|
||||||
|
|
||||||
// Fetch all users (we'll filter garminConnected in code to avoid PocketBase query syntax issues)
|
// Authenticate as admin to bypass API rules and list all users
|
||||||
// Also filter out users without required date fields (garminTokenExpiresAt, lastPeriodDate)
|
const adminEmail = process.env.POCKETBASE_ADMIN_EMAIL;
|
||||||
const allUsers = await pb.collection("users").getFullList<User>();
|
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(
|
const users = allUsers.filter(
|
||||||
(u) => u.garminConnected && u.garminTokenExpiresAt && u.lastPeriodDate,
|
(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];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
const userSyncStartTime = Date.now();
|
const userSyncStartTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if tokens are expired
|
// Check if refresh token is expired (user needs to re-auth via Python script)
|
||||||
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
|
// Note: garminTokenExpiresAt and lastPeriodDate are guaranteed non-null by filter above
|
||||||
const tokens: GarminTokens = {
|
if (user.garminRefreshTokenExpiresAt) {
|
||||||
oauth1: user.garminOauth1Token,
|
const refreshTokenExpired =
|
||||||
oauth2: user.garminOauth2Token,
|
new Date(user.garminRefreshTokenExpiresAt) <= new Date();
|
||||||
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
if (refreshTokenExpired) {
|
||||||
expires_at: user.garminTokenExpiresAt!.toISOString(),
|
logger.info(
|
||||||
};
|
{ userId: user.id },
|
||||||
|
"Refresh token expired, skipping user",
|
||||||
if (isTokenExpired(tokens)) {
|
);
|
||||||
result.skippedExpired++;
|
result.skippedExpired++;
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log sync start
|
// Log sync start
|
||||||
logger.info({ userId: user.id }, "Garmin sync start");
|
logger.info({ userId: user.id }, "Garmin sync start");
|
||||||
|
|
||||||
// Check for token expiration warnings (exactly 14 or 7 days)
|
// Check for refresh token expiration warnings (exactly 14 or 7 days)
|
||||||
const daysRemaining = daysUntilExpiry(tokens);
|
if (user.garminRefreshTokenExpiresAt) {
|
||||||
if (daysRemaining === 14 || daysRemaining === 7) {
|
const refreshExpiry = new Date(user.garminRefreshTokenExpiresAt);
|
||||||
try {
|
const now = new Date();
|
||||||
await sendTokenExpirationWarning(user.email, daysRemaining, user.id);
|
const diffMs = refreshExpiry.getTime() - now.getTime();
|
||||||
result.warningsSent++;
|
const daysRemaining = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
} catch {
|
if (daysRemaining === 14 || daysRemaining === 7) {
|
||||||
// Continue processing even if warning email fails
|
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 oauth2Json = decrypt(user.garminOauth2Token);
|
||||||
const oauth2Data = JSON.parse(oauth2Json);
|
let oauth2Data = JSON.parse(oauth2Json);
|
||||||
const accessToken = oauth2Data.accessToken;
|
|
||||||
|
// Check if access token needs refresh
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: filtered above
|
||||||
|
const accessTokenExpiresAt = user.garminTokenExpiresAt!;
|
||||||
|
if (isAccessTokenExpired(accessTokenExpiresAt)) {
|
||||||
|
logger.info({ userId: user.id }, "Access token expired, refreshing");
|
||||||
|
try {
|
||||||
|
const refreshResult = await exchangeOAuth1ForOAuth2(oauth1Data);
|
||||||
|
oauth2Data = refreshResult.oauth2;
|
||||||
|
|
||||||
|
// Update stored tokens
|
||||||
|
const encryptedOauth2 = encrypt(JSON.stringify(oauth2Data));
|
||||||
|
await pb.collection("users").update(user.id, {
|
||||||
|
garminOauth2Token: encryptedOauth2,
|
||||||
|
garminTokenExpiresAt: refreshResult.expires_at,
|
||||||
|
garminRefreshTokenExpiresAt: refreshResult.refresh_token_expires_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
result.tokensRefreshed++;
|
||||||
|
logger.info({ userId: user.id }, "Access token refreshed");
|
||||||
|
} catch (refreshError) {
|
||||||
|
logger.error(
|
||||||
|
{ userId: user.id, err: refreshError },
|
||||||
|
"Failed to refresh access token",
|
||||||
|
);
|
||||||
|
result.errors++;
|
||||||
|
garminSyncTotal.inc({ status: "failure" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = oauth2Data.access_token;
|
||||||
|
|
||||||
// Fetch Garmin data
|
// Fetch Garmin data
|
||||||
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
const [hrvStatus, bodyBattery, weekIntensityMinutes] = await Promise.all([
|
||||||
fetchHrvStatus(today, accessToken),
|
fetchHrvStatus(today, accessToken),
|
||||||
fetchBodyBattery(today, accessToken),
|
fetchBodyBattery(today, accessToken),
|
||||||
fetchIntensityMinutes(accessToken),
|
fetchIntensityMinutes(today, accessToken),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Calculate cycle info (lastPeriodDate guaranteed non-null by filter above)
|
// Calculate cycle info (lastPeriodDate guaranteed non-null by filter above)
|
||||||
@@ -114,24 +190,28 @@ export async function POST(request: Request) {
|
|||||||
new Date(),
|
new Date(),
|
||||||
);
|
);
|
||||||
const phase = getPhase(cycleDay, user.cycleLength);
|
const phase = getPhase(cycleDay, user.cycleLength);
|
||||||
const phaseLimit = getPhaseLimit(phase);
|
const phaseLimit = getUserPhaseLimit(phase, user);
|
||||||
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
|
const remainingMinutes = Math.max(0, phaseLimit - weekIntensityMinutes);
|
||||||
|
|
||||||
// Calculate training decision
|
// Calculate training decision
|
||||||
|
// Pass null body battery values through - decision engine handles null gracefully
|
||||||
const decision = getDecisionWithOverrides(
|
const decision = getDecisionWithOverrides(
|
||||||
{
|
{
|
||||||
hrvStatus,
|
hrvStatus,
|
||||||
bbYesterdayLow: bodyBattery.yesterdayLow ?? 100,
|
bbYesterdayLow: bodyBattery.yesterdayLow,
|
||||||
phase,
|
phase,
|
||||||
weekIntensity: weekIntensityMinutes,
|
weekIntensity: weekIntensityMinutes,
|
||||||
phaseLimit,
|
phaseLimit,
|
||||||
bbCurrent: bodyBattery.current ?? 100,
|
bbCurrent: bodyBattery.current,
|
||||||
},
|
},
|
||||||
user.activeOverrides,
|
user.activeOverrides,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create DailyLog entry
|
// Upsert DailyLog entry - update existing record for today or create new one
|
||||||
await pb.collection("dailyLogs").create({
|
// 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,
|
user: user.id,
|
||||||
date: today,
|
date: today,
|
||||||
cycleDay,
|
cycleDay,
|
||||||
@@ -145,7 +225,39 @@ export async function POST(request: Request) {
|
|||||||
trainingDecision: decision.status,
|
trainingDecision: decision.status,
|
||||||
decisionReason: decision.reason,
|
decisionReason: decision.reason,
|
||||||
notificationSentAt: null,
|
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
|
// Log sync complete with metrics
|
||||||
const userSyncDuration = Date.now() - userSyncStartTime;
|
const userSyncDuration = Date.now() - userSyncStartTime;
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ let mockUsers: User[] = [];
|
|||||||
let mockDailyLogs: DailyLog[] = [];
|
let mockDailyLogs: DailyLog[] = [];
|
||||||
const mockPbUpdate = vi.fn().mockResolvedValue({ id: "log123" });
|
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", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
createPocketBaseClient: vi.fn(() => ({
|
||||||
collection: vi.fn((name: string) => ({
|
collection: vi.fn((name: string) => ({
|
||||||
@@ -23,15 +24,32 @@ vi.mock("@/lib/pocketbase", () => ({
|
|||||||
return [];
|
return [];
|
||||||
}),
|
}),
|
||||||
update: mockPbUpdate,
|
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
|
// Mock email sending
|
||||||
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
const mockSendDailyEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendTokenExpirationWarning = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSendPeriodConfirmationEmail = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
vi.mock("@/lib/email", () => ({
|
vi.mock("@/lib/email", () => ({
|
||||||
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
sendDailyEmail: (data: unknown) => mockSendDailyEmail(data),
|
||||||
|
sendTokenExpirationWarning: (...args: unknown[]) =>
|
||||||
|
mockSendTokenExpirationWarning(...args),
|
||||||
|
sendPeriodConfirmationEmail: (...args: unknown[]) =>
|
||||||
|
mockSendPeriodConfirmationEmail(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
@@ -48,12 +66,18 @@ describe("POST /api/cron/notifications", () => {
|
|||||||
garminOauth1Token: "encrypted:oauth1-token",
|
garminOauth1Token: "encrypted:oauth1-token",
|
||||||
garminOauth2Token: "encrypted:oauth2-token",
|
garminOauth2Token: "encrypted:oauth2-token",
|
||||||
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
garminTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-token",
|
calendarToken: "cal-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:00",
|
notificationTime: "07:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -98,6 +122,9 @@ describe("POST /api/cron/notifications", () => {
|
|||||||
mockUsers = [];
|
mockUsers = [];
|
||||||
mockDailyLogs = [];
|
mockDailyLogs = [];
|
||||||
process.env.CRON_SECRET = validSecret;
|
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
|
// Mock current time to 07:00 UTC
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
vi.setSystemTime(new Date("2025-01-15T07:00:00Z"));
|
||||||
@@ -131,6 +158,36 @@ describe("POST /api/cron/notifications", () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(401);
|
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", () => {
|
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", () => {
|
describe("DailyLog handling", () => {
|
||||||
it("does not send notification if no DailyLog exists for today", async () => {
|
it("does not send notification if no DailyLog exists for today", async () => {
|
||||||
mockUsers = [
|
mockUsers = [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { sendDailyEmail } from "@/lib/email";
|
import { sendDailyEmail } from "@/lib/email";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
import { getNutritionGuidance } from "@/lib/nutrition";
|
import { getNutritionGuidance } from "@/lib/nutrition";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { createPocketBaseClient } from "@/lib/pocketbase";
|
||||||
import type { DailyLog, DecisionStatus, User } from "@/types";
|
import type { DailyLog, DecisionStatus, User } from "@/types";
|
||||||
@@ -17,19 +18,40 @@ interface NotificationResult {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current hour in a specific timezone
|
// Get current quarter-hour slot (0, 15, 30, 45) in user's timezone
|
||||||
function getCurrentHourInTimezone(timezone: string): number {
|
function getCurrentQuarterHourSlot(timezone: string): {
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
} {
|
||||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
hour12: false,
|
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
|
// Extract quarter-hour slot from "HH:MM" format
|
||||||
function getNotificationHour(notificationTime: string): number {
|
function getNotificationSlot(notificationTime: string): {
|
||||||
return parseInt(notificationTime.split(":")[0], 10);
|
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
|
// Map decision status to icon
|
||||||
@@ -69,8 +91,35 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const pb = createPocketBaseClient();
|
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
|
// Fetch all users
|
||||||
const users = await pb.collection("users").getFullList<User>();
|
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
|
// Get today's date for querying daily logs
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
@@ -95,11 +144,14 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
try {
|
try {
|
||||||
// Check if current hour in user's timezone matches their notification time
|
// Check if current quarter-hour slot in user's timezone matches their notification time
|
||||||
const currentHour = getCurrentHourInTimezone(user.timezone);
|
const currentSlot = getCurrentQuarterHourSlot(user.timezone);
|
||||||
const notificationHour = getNotificationHour(user.notificationTime);
|
const notificationSlot = getNotificationSlot(user.notificationTime);
|
||||||
|
|
||||||
if (currentHour !== notificationHour) {
|
if (
|
||||||
|
currentSlot.hour !== notificationSlot.hour ||
|
||||||
|
currentSlot.minute !== notificationSlot.minute
|
||||||
|
) {
|
||||||
result.skippedWrongTime++;
|
result.skippedWrongTime++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,29 @@ import type { User } from "@/types";
|
|||||||
// Module-level variable to control mock user in tests
|
// Module-level variable to control mock user in tests
|
||||||
let currentMockUser: User | null = null;
|
let currentMockUser: User | null = null;
|
||||||
|
|
||||||
|
// Create mock PocketBase getOne function that returns fresh user data
|
||||||
|
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
||||||
|
if (!currentMockUser) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
id: currentMockUser.id,
|
||||||
|
email: currentMockUser.email,
|
||||||
|
lastPeriodDate: currentMockUser.lastPeriodDate?.toISOString(),
|
||||||
|
cycleLength: currentMockUser.cycleLength,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mock PocketBase client
|
||||||
|
const mockPb = {
|
||||||
|
collection: vi.fn(() => ({
|
||||||
|
getOne: mockPbGetOne,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
// Mock PocketBase client for database operations
|
// Mock PocketBase client for database operations
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
createPocketBaseClient: vi.fn(() => ({})),
|
createPocketBaseClient: vi.fn(() => mockPb),
|
||||||
loadAuthFromCookies: vi.fn(),
|
loadAuthFromCookies: vi.fn(),
|
||||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
||||||
getCurrentUser: vi.fn(() => currentMockUser),
|
getCurrentUser: vi.fn(() => currentMockUser),
|
||||||
@@ -24,7 +44,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -39,12 +59,18 @@ describe("GET /api/cycle/current", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
notificationTime: "07:00",
|
notificationTime: "07:00",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -116,11 +142,11 @@ describe("GET /api/cycle/current", () => {
|
|||||||
|
|
||||||
expect(body.phaseConfig).toBeDefined();
|
expect(body.phaseConfig).toBeDefined();
|
||||||
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
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");
|
expect(body.phaseConfig.trainingType).toBe("Strength + rebounding");
|
||||||
// Phase configs days are for reference; actual boundaries are calculated dynamically
|
// Phase configs days are for reference; actual boundaries are calculated dynamically
|
||||||
expect(body.phaseConfig.days).toEqual([4, 15]);
|
expect(body.phaseConfig.days).toEqual([4, 15]);
|
||||||
expect(body.phaseConfig.dailyAvg).toBe(17);
|
expect(body.phaseConfig.dailyAvg).toBe(21);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calculates daysUntilNextPhase correctly", async () => {
|
it("calculates daysUntilNextPhase correctly", async () => {
|
||||||
@@ -153,7 +179,7 @@ describe("GET /api/cycle/current", () => {
|
|||||||
|
|
||||||
expect(body.cycleDay).toBe(3);
|
expect(body.cycleDay).toBe(3);
|
||||||
expect(body.phase).toBe("MENSTRUAL");
|
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
|
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.cycleDay).toBe(16);
|
||||||
expect(body.phase).toBe("OVULATION");
|
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
|
expect(body.daysUntilNextPhase).toBe(2); // Day 18 is EARLY_LUTEAL
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,18 @@ function getDaysUntilNextPhase(cycleDay: number, cycleLength: number): number {
|
|||||||
return nextPhaseStart - cycleDay;
|
return nextPhaseStart - cycleDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = withAuth(async (_request, user) => {
|
export const GET = withAuth(async (_request, user, pb) => {
|
||||||
|
// Fetch fresh user data from database to get latest values
|
||||||
|
// The user param from withAuth is from auth store cache which may be stale
|
||||||
|
const freshUser = await pb.collection("users").getOne(user.id);
|
||||||
|
|
||||||
// Validate user has required cycle data
|
// Validate user has required cycle data
|
||||||
if (!user.lastPeriodDate) {
|
const lastPeriodDate = freshUser.lastPeriodDate
|
||||||
|
? new Date(freshUser.lastPeriodDate as string)
|
||||||
|
: null;
|
||||||
|
const cycleLength = (freshUser.cycleLength as number) || 28;
|
||||||
|
|
||||||
|
if (!lastPeriodDate) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
@@ -53,20 +62,16 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate current cycle position
|
// Calculate current cycle position
|
||||||
const cycleDay = getCycleDay(
|
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||||
user.lastPeriodDate,
|
const phase = getPhase(cycleDay, cycleLength);
|
||||||
user.cycleLength,
|
|
||||||
new Date(),
|
|
||||||
);
|
|
||||||
const phase = getPhase(cycleDay, user.cycleLength);
|
|
||||||
const phaseConfig = getPhaseConfig(phase);
|
const phaseConfig = getPhaseConfig(phase);
|
||||||
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, user.cycleLength);
|
const daysUntilNextPhase = getDaysUntilNextPhase(cycleDay, cycleLength);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
cycleDay,
|
cycleDay,
|
||||||
phase,
|
phase,
|
||||||
phaseConfig,
|
phaseConfig,
|
||||||
daysUntilNextPhase,
|
daysUntilNextPhase,
|
||||||
cycleLength: user.cycleLength,
|
cycleLength,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,17 +13,13 @@ let currentMockUser: User | null = null;
|
|||||||
const mockPbUpdate = vi.fn();
|
const mockPbUpdate = vi.fn();
|
||||||
const mockPbCreate = vi.fn();
|
const mockPbCreate = vi.fn();
|
||||||
|
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
// Create mock PocketBase client
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
const mockPb = {
|
||||||
collection: vi.fn((_name: string) => ({
|
collection: vi.fn((_name: string) => ({
|
||||||
update: mockPbUpdate,
|
update: mockPbUpdate,
|
||||||
create: mockPbCreate,
|
create: mockPbCreate,
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
loadAuthFromCookies: vi.fn(),
|
};
|
||||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
|
||||||
getCurrentUser: vi.fn(() => currentMockUser),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
@@ -32,7 +28,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -47,12 +43,18 @@ describe("POST /api/cycle/period", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2024-12-15"),
|
lastPeriodDate: new Date("2024-12-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:00",
|
notificationTime: "07:00",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { NextResponse } from "next/server";
|
|||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { getCycleDay, getPhase } from "@/lib/cycle";
|
import { getCycleDay, getPhase } from "@/lib/cycle";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
|
||||||
|
|
||||||
interface PeriodLogRequest {
|
interface PeriodLogRequest {
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
@@ -35,7 +34,7 @@ function isFutureDate(dateStr: string): boolean {
|
|||||||
return inputDate > today;
|
return inputDate > today;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST = withAuth(async (request: NextRequest, user) => {
|
export const POST = withAuth(async (request: NextRequest, user, pb) => {
|
||||||
try {
|
try {
|
||||||
const body = (await request.json()) as PeriodLogRequest;
|
const body = (await request.json()) as PeriodLogRequest;
|
||||||
|
|
||||||
@@ -63,8 +62,6 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
|
|
||||||
// Calculate predicted date based on previous cycle (if exists)
|
// Calculate predicted date based on previous cycle (if exists)
|
||||||
let predictedDateStr: string | null = null;
|
let predictedDateStr: string | null = null;
|
||||||
if (user.lastPeriodDate) {
|
if (user.lastPeriodDate) {
|
||||||
|
|||||||
@@ -9,11 +9,26 @@ import type { User } from "@/types";
|
|||||||
// Module-level variable to control mock user in tests
|
// Module-level variable to control mock user in tests
|
||||||
let currentMockUser: User | null = null;
|
let currentMockUser: User | null = null;
|
||||||
|
|
||||||
|
// Create a mock PocketBase client that returns the current mock user
|
||||||
|
const createMockPb = () => ({
|
||||||
|
collection: vi.fn(() => ({
|
||||||
|
getOne: vi.fn(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
currentMockUser
|
||||||
|
? {
|
||||||
|
...currentMockUser,
|
||||||
|
garminTokenExpiresAt:
|
||||||
|
currentMockUser.garminTokenExpiresAt?.toISOString(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
// Mock PocketBase
|
// Mock PocketBase
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
vi.mock("@/lib/pocketbase", () => ({
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
createPocketBaseClient: vi.fn(() => createMockPb()),
|
||||||
collection: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
@@ -23,7 +38,8 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
const mockPb = createMockPb();
|
||||||
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -55,12 +71,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -84,12 +106,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -113,12 +141,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -140,12 +174,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -169,12 +209,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: pastDate,
|
garminTokenExpiresAt: pastDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -200,12 +246,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -229,12 +281,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -258,12 +316,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -287,12 +351,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "encrypted-token",
|
garminOauth1Token: "encrypted-token",
|
||||||
garminOauth2Token: "encrypted-token",
|
garminOauth2Token: "encrypted-token",
|
||||||
garminTokenExpiresAt: futureDate,
|
garminTokenExpiresAt: futureDate,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -313,12 +383,18 @@ describe("GET /api/garmin/status", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,31 +5,48 @@ import { NextResponse } from "next/server";
|
|||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
|
import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin";
|
||||||
|
|
||||||
export const GET = withAuth(async (_request, user) => {
|
export const GET = withAuth(async (_request, user, pb) => {
|
||||||
const connected = user.garminConnected;
|
// Fetch fresh user data from database (auth store cookie may be stale)
|
||||||
|
const freshUser = await pb.collection("users").getOne(user.id);
|
||||||
|
// Use strict equality to handle undefined (field missing from schema)
|
||||||
|
const connected = freshUser.garminConnected === true;
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
connected: false,
|
{
|
||||||
daysUntilExpiry: null,
|
connected: false,
|
||||||
expired: false,
|
daysUntilExpiry: null,
|
||||||
warningLevel: null,
|
expired: false,
|
||||||
});
|
warningLevel: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt =
|
// Use refresh token expiry for user-facing warnings (when they need to re-auth)
|
||||||
user.garminTokenExpiresAt instanceof Date
|
// Fall back to access token expiry if refresh expiry not set
|
||||||
? user.garminTokenExpiresAt.toISOString()
|
const refreshTokenExpiresAt = freshUser.garminRefreshTokenExpiresAt
|
||||||
: String(user.garminTokenExpiresAt);
|
? String(freshUser.garminRefreshTokenExpiresAt)
|
||||||
|
: "";
|
||||||
|
const accessTokenExpiresAt = freshUser.garminTokenExpiresAt
|
||||||
|
? String(freshUser.garminTokenExpiresAt)
|
||||||
|
: "";
|
||||||
|
|
||||||
const tokens = {
|
const tokens = {
|
||||||
oauth1: "",
|
oauth1: "",
|
||||||
oauth2: "",
|
oauth2: "",
|
||||||
expires_at: expiresAt,
|
expires_at: accessTokenExpiresAt,
|
||||||
|
refresh_token_expires_at: refreshTokenExpiresAt || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const days = daysUntilExpiry(tokens);
|
const days = daysUntilExpiry(tokens);
|
||||||
const expired = isTokenExpired(tokens);
|
|
||||||
|
// Check if refresh token is expired (user needs to re-authenticate)
|
||||||
|
const expired = refreshTokenExpiresAt
|
||||||
|
? new Date(refreshTokenExpiresAt) <= new Date()
|
||||||
|
: isTokenExpired(tokens);
|
||||||
|
|
||||||
let warningLevel: "warning" | "critical" | null = null;
|
let warningLevel: "warning" | "critical" | null = null;
|
||||||
if (days <= 7) {
|
if (days <= 7) {
|
||||||
@@ -38,10 +55,15 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
warningLevel = "warning";
|
warningLevel = "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
connected: true,
|
{
|
||||||
daysUntilExpiry: days,
|
connected: true,
|
||||||
expired,
|
daysUntilExpiry: days,
|
||||||
warningLevel,
|
expired,
|
||||||
});
|
warningLevel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ let currentMockUser: User | null = null;
|
|||||||
// Track PocketBase update calls
|
// Track PocketBase update calls
|
||||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
// Mock PocketBase
|
// Track PocketBase getOne calls - returns user with garminConnected: true after update
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true });
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
|
||||||
collection: vi.fn(() => ({
|
// Create mock PocketBase client
|
||||||
update: mockPbUpdate,
|
const mockPb = {
|
||||||
})),
|
collection: vi.fn(() => ({
|
||||||
|
update: mockPbUpdate,
|
||||||
|
getOne: mockPbGetOne,
|
||||||
})),
|
})),
|
||||||
}));
|
};
|
||||||
|
|
||||||
// Track encryption calls
|
// Track encryption calls
|
||||||
const mockEncrypt = vi.fn((plaintext: string) => `encrypted:${plaintext}`);
|
const mockEncrypt = vi.fn((plaintext: string) => `encrypted:${plaintext}`);
|
||||||
@@ -36,7 +38,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -51,12 +53,18 @@ describe("POST /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-01-01"),
|
garminTokenExpiresAt: new Date("2025-01-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -139,6 +147,7 @@ describe("POST /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
garminOauth1Token: `encrypted:${JSON.stringify(oauth1)}`,
|
||||||
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
garminOauth2Token: `encrypted:${JSON.stringify(oauth2)}`,
|
||||||
garminTokenExpiresAt: expiresAt,
|
garminTokenExpiresAt: expiresAt,
|
||||||
|
garminRefreshTokenExpiresAt: expect.any(String),
|
||||||
garminConnected: true,
|
garminConnected: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -265,12 +274,18 @@ describe("DELETE /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -302,6 +317,7 @@ describe("DELETE /api/garmin/tokens", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: null,
|
garminTokenExpiresAt: null,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
garminConnected: false,
|
garminConnected: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { encrypt } from "@/lib/encryption";
|
import { encrypt } from "@/lib/encryption";
|
||||||
import { daysUntilExpiry } from "@/lib/garmin";
|
import { daysUntilExpiry } from "@/lib/garmin";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const POST = withAuth(async (request, user) => {
|
export const POST = withAuth(async (request, user, pb) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { oauth1, oauth2, expires_at } = body;
|
const { oauth1, oauth2, expires_at, refresh_token_expires_at } = body;
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!oauth1) {
|
if (!oauth1) {
|
||||||
@@ -52,24 +52,62 @@ export const POST = withAuth(async (request, user) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate refresh_token_expires_at if provided
|
||||||
|
let refreshTokenExpiresAt = refresh_token_expires_at;
|
||||||
|
if (refreshTokenExpiresAt) {
|
||||||
|
const refreshExpiryDate = new Date(refreshTokenExpiresAt);
|
||||||
|
if (Number.isNaN(refreshExpiryDate.getTime())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "refresh_token_expires_at must be a valid date" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If not provided, estimate refresh token expiry as ~30 days from now
|
||||||
|
refreshTokenExpiresAt = new Date(
|
||||||
|
Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// Encrypt tokens before storing
|
// Encrypt tokens before storing
|
||||||
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
const encryptedOauth1 = encrypt(JSON.stringify(oauth1));
|
||||||
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
const encryptedOauth2 = encrypt(JSON.stringify(oauth2));
|
||||||
|
|
||||||
// Update user record
|
// Update user record
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
await pb.collection("users").update(user.id, {
|
await pb.collection("users").update(user.id, {
|
||||||
garminOauth1Token: encryptedOauth1,
|
garminOauth1Token: encryptedOauth1,
|
||||||
garminOauth2Token: encryptedOauth2,
|
garminOauth2Token: encryptedOauth2,
|
||||||
garminTokenExpiresAt: expires_at,
|
garminTokenExpiresAt: expires_at,
|
||||||
|
garminRefreshTokenExpiresAt: refreshTokenExpiresAt,
|
||||||
garminConnected: true,
|
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({
|
const expiryDays = daysUntilExpiry({
|
||||||
oauth1: "",
|
oauth1: "",
|
||||||
oauth2: "",
|
oauth2: "",
|
||||||
expires_at,
|
expires_at,
|
||||||
|
refresh_token_expires_at: refreshTokenExpiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -79,13 +117,12 @@ export const POST = withAuth(async (request, user) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = withAuth(async (_request, user) => {
|
export const DELETE = withAuth(async (_request, user, pb) => {
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
|
|
||||||
await pb.collection("users").update(user.id, {
|
await pb.collection("users").update(user.id, {
|
||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: null,
|
garminTokenExpiresAt: null,
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
garminConnected: false,
|
garminConnected: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ let currentMockUser: User | null = null;
|
|||||||
// Track PocketBase collection calls
|
// Track PocketBase collection calls
|
||||||
const mockGetList = vi.fn();
|
const mockGetList = vi.fn();
|
||||||
|
|
||||||
// Mock PocketBase
|
// Create mock PocketBase client
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
const mockPb = {
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
collection: vi.fn(() => ({
|
||||||
collection: vi.fn(() => ({
|
getList: mockGetList,
|
||||||
getList: mockGetList,
|
|
||||||
})),
|
|
||||||
})),
|
})),
|
||||||
}));
|
};
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
@@ -28,7 +26,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -43,12 +41,18 @@ describe("GET /api/history", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
|
||||||
import type { DailyLog } from "@/types";
|
import type { DailyLog } from "@/types";
|
||||||
|
|
||||||
// Validation constants
|
// Validation constants
|
||||||
@@ -24,7 +23,7 @@ function isValidDateFormat(dateStr: string): boolean {
|
|||||||
return !Number.isNaN(date.getTime());
|
return !Number.isNaN(date.getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET = withAuth(async (request, user) => {
|
export const GET = withAuth(async (request, user, pb) => {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
|
|
||||||
// Parse and validate page parameter
|
// Parse and validate page parameter
|
||||||
@@ -77,7 +76,6 @@ export const GET = withAuth(async (request, user) => {
|
|||||||
const filter = filters.join(" && ");
|
const filter = filters.join(" && ");
|
||||||
|
|
||||||
// Query PocketBase
|
// Query PocketBase
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
const result = await pb
|
const result = await pb
|
||||||
.collection("dailyLogs")
|
.collection("dailyLogs")
|
||||||
.getList<DailyLog>(page, limit, {
|
.getList<DailyLog>(page, limit, {
|
||||||
|
|||||||
@@ -14,6 +14,25 @@ let lastUpdateCall: {
|
|||||||
data: { activeOverrides: OverrideType[] };
|
data: { activeOverrides: OverrideType[] };
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
|
// Create mock PocketBase client
|
||||||
|
const mockPb = {
|
||||||
|
collection: vi.fn(() => ({
|
||||||
|
update: vi.fn(
|
||||||
|
async (id: string, data: { activeOverrides: OverrideType[] }) => {
|
||||||
|
lastUpdateCall = { id, data };
|
||||||
|
// Update the mock user to simulate DB update
|
||||||
|
if (currentMockUser) {
|
||||||
|
currentMockUser = {
|
||||||
|
...currentMockUser,
|
||||||
|
activeOverrides: data.activeOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...currentMockUser, ...data };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
withAuth: vi.fn((handler) => {
|
withAuth: vi.fn((handler) => {
|
||||||
@@ -21,32 +40,11 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the pocketbase module
|
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
|
||||||
collection: vi.fn(() => ({
|
|
||||||
update: vi.fn(
|
|
||||||
async (id: string, data: { activeOverrides: OverrideType[] }) => {
|
|
||||||
lastUpdateCall = { id, data };
|
|
||||||
// Update the mock user to simulate DB update
|
|
||||||
if (currentMockUser) {
|
|
||||||
currentMockUser = {
|
|
||||||
...currentMockUser,
|
|
||||||
activeOverrides: data.activeOverrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { ...currentMockUser, ...data };
|
|
||||||
},
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { DELETE, POST } from "./route";
|
import { DELETE, POST } from "./route";
|
||||||
|
|
||||||
describe("POST /api/overrides", () => {
|
describe("POST /api/overrides", () => {
|
||||||
@@ -57,12 +55,18 @@ describe("POST /api/overrides", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: overrides,
|
activeOverrides: overrides,
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
});
|
});
|
||||||
@@ -189,12 +193,18 @@ describe("DELETE /api/overrides", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: overrides,
|
activeOverrides: overrides,
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { NextResponse } from "next/server";
|
|||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
|
||||||
import type { OverrideType } from "@/types";
|
import type { OverrideType } from "@/types";
|
||||||
|
|
||||||
const VALID_OVERRIDE_TYPES: OverrideType[] = [
|
const VALID_OVERRIDE_TYPES: OverrideType[] = [
|
||||||
@@ -27,7 +26,7 @@ function isValidOverrideType(value: unknown): value is OverrideType {
|
|||||||
* Request body: { override: OverrideType }
|
* Request body: { override: OverrideType }
|
||||||
* Response: { activeOverrides: OverrideType[] }
|
* Response: { activeOverrides: OverrideType[] }
|
||||||
*/
|
*/
|
||||||
export const POST = withAuth(async (request: NextRequest, user) => {
|
export const POST = withAuth(async (request: NextRequest, user, pb) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
if (!body.override) {
|
if (!body.override) {
|
||||||
@@ -55,7 +54,6 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
|||||||
: [...currentOverrides, overrideToAdd];
|
: [...currentOverrides, overrideToAdd];
|
||||||
|
|
||||||
// Update the user record in PocketBase
|
// Update the user record in PocketBase
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
await pb
|
await pb
|
||||||
.collection("users")
|
.collection("users")
|
||||||
.update(user.id, { activeOverrides: newOverrides });
|
.update(user.id, { activeOverrides: newOverrides });
|
||||||
@@ -74,7 +72,7 @@ export const POST = withAuth(async (request: NextRequest, user) => {
|
|||||||
* Request body: { override: OverrideType }
|
* Request body: { override: OverrideType }
|
||||||
* Response: { activeOverrides: OverrideType[] }
|
* Response: { activeOverrides: OverrideType[] }
|
||||||
*/
|
*/
|
||||||
export const DELETE = withAuth(async (request: NextRequest, user) => {
|
export const DELETE = withAuth(async (request: NextRequest, user, pb) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
if (!body.override) {
|
if (!body.override) {
|
||||||
@@ -100,7 +98,6 @@ export const DELETE = withAuth(async (request: NextRequest, user) => {
|
|||||||
const newOverrides = currentOverrides.filter((o) => o !== overrideToRemove);
|
const newOverrides = currentOverrides.filter((o) => o !== overrideToRemove);
|
||||||
|
|
||||||
// Update the user record in PocketBase
|
// Update the user record in PocketBase
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
await pb
|
await pb
|
||||||
.collection("users")
|
.collection("users")
|
||||||
.update(user.id, { activeOverrides: newOverrides });
|
.update(user.id, { activeOverrides: newOverrides });
|
||||||
|
|||||||
429
src/app/api/period-history/route.test.ts
Normal file
429
src/app/api/period-history/route.test.ts
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
// ABOUTME: Unit tests for period history API route.
|
||||||
|
// ABOUTME: Tests GET /api/period-history for pagination, cycle length calculation, and auth.
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PeriodLog, User } from "@/types";
|
||||||
|
|
||||||
|
// Module-level variable to control mock user in tests
|
||||||
|
let currentMockUser: User | null = null;
|
||||||
|
|
||||||
|
// Track PocketBase collection calls
|
||||||
|
const mockGetList = vi.fn();
|
||||||
|
|
||||||
|
// Create mock PocketBase client
|
||||||
|
const mockPb = {
|
||||||
|
collection: vi.fn(() => ({
|
||||||
|
getList: mockGetList,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the auth-middleware module
|
||||||
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
|
withAuth: vi.fn((handler) => {
|
||||||
|
return async (request: NextRequest) => {
|
||||||
|
if (!currentMockUser) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return handler(request, currentMockUser, mockPb);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GET } from "./route";
|
||||||
|
|
||||||
|
describe("GET /api/period-history", () => {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: "user123",
|
||||||
|
email: "test@example.com",
|
||||||
|
garminConnected: true,
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPeriodLogs: PeriodLog[] = [
|
||||||
|
{
|
||||||
|
id: "period1",
|
||||||
|
user: "user123",
|
||||||
|
startDate: new Date("2025-01-15"),
|
||||||
|
predictedDate: new Date("2025-01-16"),
|
||||||
|
created: new Date("2025-01-15T10:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "period2",
|
||||||
|
user: "user123",
|
||||||
|
startDate: new Date("2024-12-18"),
|
||||||
|
predictedDate: new Date("2024-12-19"),
|
||||||
|
created: new Date("2024-12-18T10:00:00Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "period3",
|
||||||
|
user: "user123",
|
||||||
|
startDate: new Date("2024-11-20"),
|
||||||
|
predictedDate: null,
|
||||||
|
created: new Date("2024-11-20T10:00:00Z"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper to create mock request with query parameters
|
||||||
|
function createMockRequest(params: Record<string, string> = {}): NextRequest {
|
||||||
|
const url = new URL("http://localhost:3000/api/period-history");
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url: url.toString(),
|
||||||
|
nextUrl: url,
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentMockUser = null;
|
||||||
|
mockGetList.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
currentMockUser = null;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns paginated period logs for authenticated user", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: mockPeriodLogs,
|
||||||
|
totalItems: 3,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.items).toHaveLength(3);
|
||||||
|
expect(body.total).toBe(3);
|
||||||
|
expect(body.page).toBe(1);
|
||||||
|
expect(body.limit).toBe(20);
|
||||||
|
expect(body.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates cycle lengths between consecutive periods", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: mockPeriodLogs,
|
||||||
|
totalItems: 3,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
// Period 1 (Jan 15) - Period 2 (Dec 18) = 28 days
|
||||||
|
expect(body.items[0].cycleLength).toBe(28);
|
||||||
|
// Period 2 (Dec 18) - Period 3 (Nov 20) = 28 days
|
||||||
|
expect(body.items[1].cycleLength).toBe(28);
|
||||||
|
// Period 3 is the first log, no previous period to calculate from
|
||||||
|
expect(body.items[2].cycleLength).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates average cycle length", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: mockPeriodLogs,
|
||||||
|
totalItems: 3,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
// Average of 28 and 28 = 28
|
||||||
|
expect(body.averageCycleLength).toBe(28);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null average when only one period exists", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [mockPeriodLogs[2]], // Only one period
|
||||||
|
totalItems: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.averageCycleLength).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default pagination values (page=1, limit=20)", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
totalItems: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(mockGetList).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
expect.objectContaining({
|
||||||
|
filter: expect.stringContaining('user="user123"'),
|
||||||
|
sort: "-startDate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects page parameter", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
totalItems: 50,
|
||||||
|
totalPages: 3,
|
||||||
|
page: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ page: "2" });
|
||||||
|
await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(mockGetList).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
20,
|
||||||
|
expect.objectContaining({
|
||||||
|
filter: expect.stringContaining('user="user123"'),
|
||||||
|
sort: "-startDate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects limit parameter", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
totalItems: 50,
|
||||||
|
totalPages: 5,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ limit: "10" });
|
||||||
|
await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(mockGetList).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
expect.objectContaining({
|
||||||
|
filter: expect.stringContaining('user="user123"'),
|
||||||
|
sort: "-startDate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no logs exist", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
totalItems: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.items).toHaveLength(0);
|
||||||
|
expect(body.total).toBe(0);
|
||||||
|
expect(body.hasMore).toBe(false);
|
||||||
|
expect(body.averageCycleLength).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid page value", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ page: "0" });
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for non-numeric page value", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ page: "abc" });
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("page");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid limit value (too low)", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ limit: "0" });
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for invalid limit value (too high)", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ limit: "101" });
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("limit");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasMore=true when more pages exist", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: Array(20).fill(mockPeriodLogs[0]),
|
||||||
|
totalItems: 50,
|
||||||
|
totalPages: 3,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hasMore=false on last page", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [mockPeriodLogs[0]],
|
||||||
|
totalItems: 41,
|
||||||
|
totalPages: 3,
|
||||||
|
page: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ page: "3" });
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts by startDate descending (most recent first)", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: mockPeriodLogs,
|
||||||
|
totalItems: 3,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(mockGetList).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
expect.objectContaining({
|
||||||
|
sort: "-startDate",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only returns logs for the authenticated user", async () => {
|
||||||
|
currentMockUser = { ...mockUser, id: "different-user" };
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
totalItems: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(mockGetList).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
expect.objectContaining({
|
||||||
|
filter: expect.stringContaining('user="different-user"'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes prediction accuracy for each period", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: mockPeriodLogs,
|
||||||
|
totalItems: 3,
|
||||||
|
totalPages: 1,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
// Period 1: actual Jan 15, predicted Jan 16 -> 1 day early
|
||||||
|
expect(body.items[0].daysEarly).toBe(1);
|
||||||
|
expect(body.items[0].daysLate).toBe(0);
|
||||||
|
// Period 2: actual Dec 18, predicted Dec 19 -> 1 day early
|
||||||
|
expect(body.items[1].daysEarly).toBe(1);
|
||||||
|
expect(body.items[1].daysLate).toBe(0);
|
||||||
|
// Period 3: no prediction (first log)
|
||||||
|
expect(body.items[2].daysEarly).toBeNull();
|
||||||
|
expect(body.items[2].daysLate).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
149
src/app/api/period-history/route.ts
Normal file
149
src/app/api/period-history/route.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// ABOUTME: API route for retrieving period history with calculated cycle lengths.
|
||||||
|
// ABOUTME: GET /api/period-history returns paginated period logs with cycle statistics.
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
|
import type { PeriodLog } from "@/types";
|
||||||
|
|
||||||
|
// Pagination constants
|
||||||
|
const MIN_PAGE = 1;
|
||||||
|
const MIN_LIMIT = 1;
|
||||||
|
const MAX_LIMIT = 100;
|
||||||
|
const DEFAULT_LIMIT = 20;
|
||||||
|
|
||||||
|
interface PeriodLogWithCycleLength extends PeriodLog {
|
||||||
|
cycleLength: number | null;
|
||||||
|
daysEarly: number | null;
|
||||||
|
daysLate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodHistoryResponse {
|
||||||
|
items: PeriodLogWithCycleLength[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
averageCycleLength: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDaysBetween(date1: Date, date2: Date): number {
|
||||||
|
const d1 = new Date(date1);
|
||||||
|
const d2 = new Date(date2);
|
||||||
|
const diffTime = Math.abs(d1.getTime() - d2.getTime());
|
||||||
|
return Math.round(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = withAuth(async (request: NextRequest, user, pb) => {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Parse and validate pagination parameters
|
||||||
|
const pageParam = searchParams.get("page");
|
||||||
|
const limitParam = searchParams.get("limit");
|
||||||
|
|
||||||
|
let page = MIN_PAGE;
|
||||||
|
let limit = DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
// Validate page parameter
|
||||||
|
if (pageParam !== null) {
|
||||||
|
const parsedPage = Number.parseInt(pageParam, 10);
|
||||||
|
if (Number.isNaN(parsedPage) || parsedPage < MIN_PAGE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid page: must be a positive integer" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
page = parsedPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate limit parameter
|
||||||
|
if (limitParam !== null) {
|
||||||
|
const parsedLimit = Number.parseInt(limitParam, 10);
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsedLimit) ||
|
||||||
|
parsedLimit < MIN_LIMIT ||
|
||||||
|
parsedLimit > MAX_LIMIT
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `Invalid limit: must be between ${MIN_LIMIT} and ${MAX_LIMIT}`,
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
limit = parsedLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query period logs for user
|
||||||
|
const result = await pb
|
||||||
|
.collection("period_logs")
|
||||||
|
.getList<PeriodLog>(page, limit, {
|
||||||
|
filter: `user="${user.id}"`,
|
||||||
|
sort: "-startDate",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate cycle lengths between consecutive periods
|
||||||
|
// Periods are sorted by startDate descending (most recent first)
|
||||||
|
const itemsWithCycleLength: PeriodLogWithCycleLength[] = result.items.map(
|
||||||
|
(log, index) => {
|
||||||
|
let cycleLength: number | null = null;
|
||||||
|
|
||||||
|
// If there's a next period (earlier period), calculate cycle length
|
||||||
|
const nextPeriod = result.items[index + 1];
|
||||||
|
if (nextPeriod) {
|
||||||
|
cycleLength = calculateDaysBetween(log.startDate, nextPeriod.startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate prediction accuracy
|
||||||
|
let daysEarly: number | null = null;
|
||||||
|
let daysLate: number | null = null;
|
||||||
|
if (log.predictedDate) {
|
||||||
|
const actualDate = new Date(log.startDate);
|
||||||
|
const predictedDate = new Date(log.predictedDate);
|
||||||
|
const diffDays = Math.round(
|
||||||
|
(actualDate.getTime() - predictedDate.getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
if (diffDays < 0) {
|
||||||
|
daysEarly = Math.abs(diffDays);
|
||||||
|
daysLate = 0;
|
||||||
|
} else {
|
||||||
|
daysEarly = 0;
|
||||||
|
daysLate = diffDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...log,
|
||||||
|
cycleLength,
|
||||||
|
daysEarly,
|
||||||
|
daysLate,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate average cycle length (only if we have at least 2 periods)
|
||||||
|
const cycleLengths = itemsWithCycleLength
|
||||||
|
.map((log) => log.cycleLength)
|
||||||
|
.filter((length): length is number => length !== null);
|
||||||
|
|
||||||
|
const averageCycleLength =
|
||||||
|
cycleLengths.length > 0
|
||||||
|
? Math.round(
|
||||||
|
cycleLengths.reduce((sum, len) => sum + len, 0) / cycleLengths.length,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const response: PeriodHistoryResponse = {
|
||||||
|
items: itemsWithCycleLength,
|
||||||
|
total: result.totalItems,
|
||||||
|
page: result.page,
|
||||||
|
limit,
|
||||||
|
totalPages: result.totalPages,
|
||||||
|
hasMore: result.page < result.totalPages,
|
||||||
|
averageCycleLength,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response, { status: 200 });
|
||||||
|
});
|
||||||
440
src/app/api/period-logs/[id]/route.test.ts
Normal file
440
src/app/api/period-logs/[id]/route.test.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// ABOUTME: Unit tests for period log edit and delete API routes.
|
||||||
|
// ABOUTME: Tests PATCH and DELETE /api/period-logs/[id] for auth, validation, and ownership.
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PeriodLog, User } from "@/types";
|
||||||
|
|
||||||
|
// Module-level variable to control mock user in tests
|
||||||
|
let currentMockUser: User | null = null;
|
||||||
|
|
||||||
|
// Track PocketBase collection calls
|
||||||
|
const mockGetOne = vi.fn();
|
||||||
|
const mockUpdate = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
|
const mockGetList = vi.fn();
|
||||||
|
|
||||||
|
// Create mock PocketBase client
|
||||||
|
const mockPb = {
|
||||||
|
collection: vi.fn(() => ({
|
||||||
|
getOne: mockGetOne,
|
||||||
|
update: mockUpdate,
|
||||||
|
delete: mockDelete,
|
||||||
|
getList: mockGetList,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the auth-middleware module
|
||||||
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
|
withAuth: vi.fn((handler) => {
|
||||||
|
return async (
|
||||||
|
request: NextRequest,
|
||||||
|
context?: { params?: Promise<{ id: string }> },
|
||||||
|
) => {
|
||||||
|
if (!currentMockUser) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
return handler(request, currentMockUser, mockPb, context);
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DELETE, PATCH } from "./route";
|
||||||
|
|
||||||
|
describe("PATCH /api/period-logs/[id]", () => {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: "user123",
|
||||||
|
email: "test@example.com",
|
||||||
|
garminConnected: true,
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPeriodLog: PeriodLog = {
|
||||||
|
id: "period1",
|
||||||
|
user: "user123",
|
||||||
|
startDate: new Date("2025-01-15"),
|
||||||
|
predictedDate: new Date("2025-01-16"),
|
||||||
|
created: new Date("2025-01-15T10:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to create mock request with JSON body
|
||||||
|
function createMockRequest(body: unknown): NextRequest {
|
||||||
|
return {
|
||||||
|
json: async () => body,
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create params context
|
||||||
|
function createParamsContext(id: string) {
|
||||||
|
return {
|
||||||
|
params: Promise.resolve({ id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentMockUser = null;
|
||||||
|
mockGetOne.mockReset();
|
||||||
|
mockUpdate.mockReset();
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockGetList.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
currentMockUser = null;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when period log not found", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockRejectedValue({ status: 404 });
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
|
||||||
|
const response = await PATCH(
|
||||||
|
mockRequest,
|
||||||
|
createParamsContext("nonexistent"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Period log not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when user does not own the period log", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue({
|
||||||
|
...mockPeriodLog,
|
||||||
|
user: "different-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Access denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when startDate is missing", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({});
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("startDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when startDate format is invalid", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "not-a-date" });
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("startDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when startDate is in the future", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + 10);
|
||||||
|
const futureDateStr = futureDate.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: futureDateStr });
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toContain("future");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates period log with valid startDate", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
mockUpdate.mockResolvedValue({
|
||||||
|
...mockPeriodLog,
|
||||||
|
startDate: new Date("2025-01-14"),
|
||||||
|
});
|
||||||
|
// Mock that this is the most recent period
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [mockPeriodLog],
|
||||||
|
totalItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
"period1",
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: "2025-01-14",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates user.lastPeriodDate if editing the most recent period", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
mockUpdate.mockResolvedValue({
|
||||||
|
...mockPeriodLog,
|
||||||
|
startDate: new Date("2025-01-14"),
|
||||||
|
});
|
||||||
|
// This is the most recent period
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [mockPeriodLog],
|
||||||
|
totalItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
|
||||||
|
await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
// Check that user was updated with new lastPeriodDate
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
"user123",
|
||||||
|
expect.objectContaining({
|
||||||
|
lastPeriodDate: "2025-01-14",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not update user.lastPeriodDate if editing an older period", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
const olderPeriod = {
|
||||||
|
...mockPeriodLog,
|
||||||
|
id: "period2",
|
||||||
|
startDate: new Date("2024-12-18"),
|
||||||
|
};
|
||||||
|
mockGetOne.mockResolvedValue(olderPeriod);
|
||||||
|
mockUpdate.mockResolvedValue({
|
||||||
|
...olderPeriod,
|
||||||
|
startDate: new Date("2024-12-17"),
|
||||||
|
});
|
||||||
|
// There's a more recent period
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [mockPeriodLog, olderPeriod],
|
||||||
|
totalItems: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2024-12-17" });
|
||||||
|
await PATCH(mockRequest, createParamsContext("period2"));
|
||||||
|
|
||||||
|
// User should not be updated since this isn't the most recent period
|
||||||
|
// Only period_logs should be updated
|
||||||
|
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockPb.collection).toHaveBeenCalledWith("period_logs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns updated period log in response", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
const updatedLog = { ...mockPeriodLog, startDate: new Date("2025-01-14") };
|
||||||
|
mockUpdate.mockResolvedValue(updatedLog);
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [mockPeriodLog],
|
||||||
|
totalItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest({ startDate: "2025-01-14" });
|
||||||
|
const response = await PATCH(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.id).toBe("period1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/period-logs/[id]", () => {
|
||||||
|
const mockUser: User = {
|
||||||
|
id: "user123",
|
||||||
|
email: "test@example.com",
|
||||||
|
garminConnected: true,
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPeriodLog: PeriodLog = {
|
||||||
|
id: "period1",
|
||||||
|
user: "user123",
|
||||||
|
startDate: new Date("2025-01-15"),
|
||||||
|
predictedDate: new Date("2025-01-16"),
|
||||||
|
created: new Date("2025-01-15T10:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to create mock request
|
||||||
|
function createMockRequest(): NextRequest {
|
||||||
|
return {} as unknown as NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create params context
|
||||||
|
function createParamsContext(id: string) {
|
||||||
|
return {
|
||||||
|
params: Promise.resolve({ id }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentMockUser = null;
|
||||||
|
mockGetOne.mockReset();
|
||||||
|
mockUpdate.mockReset();
|
||||||
|
mockDelete.mockReset();
|
||||||
|
mockGetList.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
currentMockUser = null;
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await DELETE(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Unauthorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when period log not found", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockRejectedValue({ status: 404 });
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await DELETE(
|
||||||
|
mockRequest,
|
||||||
|
createParamsContext("nonexistent"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Period log not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when user does not own the period log", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue({
|
||||||
|
...mockPeriodLog,
|
||||||
|
user: "different-user",
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await DELETE(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.error).toBe("Access denied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes period log successfully", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
mockDelete.mockResolvedValue(true);
|
||||||
|
// After deletion, previous period becomes most recent
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{ ...mockPeriodLog, id: "period2", startDate: new Date("2024-12-18") },
|
||||||
|
],
|
||||||
|
totalItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
const response = await DELETE(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith("period1");
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates user.lastPeriodDate to previous period when deleting most recent", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
mockDelete.mockResolvedValue(true);
|
||||||
|
// After deletion, previous period becomes most recent
|
||||||
|
const previousPeriod = {
|
||||||
|
...mockPeriodLog,
|
||||||
|
id: "period2",
|
||||||
|
startDate: new Date("2024-12-18"),
|
||||||
|
};
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [previousPeriod],
|
||||||
|
totalItems: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
await DELETE(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
// Check that user was updated with previous period's date
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
"user123",
|
||||||
|
expect.objectContaining({
|
||||||
|
lastPeriodDate: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets user.lastPeriodDate to null when deleting the only period", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
mockGetOne.mockResolvedValue(mockPeriodLog);
|
||||||
|
mockDelete.mockResolvedValue(true);
|
||||||
|
// No periods remaining after deletion
|
||||||
|
mockGetList.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
totalItems: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRequest = createMockRequest();
|
||||||
|
await DELETE(mockRequest, createParamsContext("period1"));
|
||||||
|
|
||||||
|
// Check that user was updated with null lastPeriodDate
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith(
|
||||||
|
"user123",
|
||||||
|
expect.objectContaining({
|
||||||
|
lastPeriodDate: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
src/app/api/period-logs/[id]/route.ts
Normal file
185
src/app/api/period-logs/[id]/route.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
// ABOUTME: API route for editing and deleting individual period logs.
|
||||||
|
// ABOUTME: PATCH updates startDate, DELETE removes period entry.
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type PocketBase from "pocketbase";
|
||||||
|
|
||||||
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
|
import type { PeriodLog, User } from "@/types";
|
||||||
|
|
||||||
|
// Date format regex: YYYY-MM-DD
|
||||||
|
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params?: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format date as YYYY-MM-DD
|
||||||
|
function formatDateStr(date: Date): string {
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if a period log is the most recent for a user
|
||||||
|
async function isMostRecentPeriod(
|
||||||
|
pb: PocketBase,
|
||||||
|
userId: string,
|
||||||
|
periodLogId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await pb.collection("period_logs").getList<PeriodLog>(1, 1, {
|
||||||
|
filter: `user="${userId}"`,
|
||||||
|
sort: "-startDate",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.items.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.items[0].id === periodLogId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get the most recent period after deletion
|
||||||
|
async function getMostRecentPeriodAfterDeletion(
|
||||||
|
pb: PocketBase,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PeriodLog | null> {
|
||||||
|
const result = await pb.collection("period_logs").getList<PeriodLog>(1, 1, {
|
||||||
|
filter: `user="${userId}"`,
|
||||||
|
sort: "-startDate",
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.items[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATCH = withAuth(
|
||||||
|
async (
|
||||||
|
request: NextRequest,
|
||||||
|
user: User,
|
||||||
|
pb: PocketBase,
|
||||||
|
context?: RouteContext,
|
||||||
|
) => {
|
||||||
|
// Get ID from route params
|
||||||
|
const { id } = await (context?.params ?? Promise.resolve({ id: "" }));
|
||||||
|
|
||||||
|
// Fetch the period log
|
||||||
|
let periodLog: PeriodLog;
|
||||||
|
try {
|
||||||
|
periodLog = await pb.collection("period_logs").getOne<PeriodLog>(id);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle PocketBase 404 errors (can be Error or plain object)
|
||||||
|
const err = error as { status?: number };
|
||||||
|
if (err.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Period log not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if (periodLog.user !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Validate startDate is present
|
||||||
|
if (!body.startDate) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "startDate is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate startDate format
|
||||||
|
if (!DATE_REGEX.test(body.startDate)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "startDate must be in YYYY-MM-DD format" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate startDate is a valid date
|
||||||
|
const parsedDate = new Date(body.startDate);
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "startDate is not a valid date" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate startDate is not in the future
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(23, 59, 59, 999); // End of today
|
||||||
|
if (parsedDate > today) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "startDate cannot be in the future" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the period log
|
||||||
|
const updatedPeriodLog = await pb
|
||||||
|
.collection("period_logs")
|
||||||
|
.update<PeriodLog>(id, {
|
||||||
|
startDate: body.startDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this is the most recent period, update user.lastPeriodDate
|
||||||
|
const isLatest = await isMostRecentPeriod(pb, user.id, id);
|
||||||
|
if (isLatest) {
|
||||||
|
await pb.collection("users").update(user.id, {
|
||||||
|
lastPeriodDate: body.startDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(updatedPeriodLog, { status: 200 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DELETE = withAuth(
|
||||||
|
async (
|
||||||
|
_request: NextRequest,
|
||||||
|
user: User,
|
||||||
|
pb: PocketBase,
|
||||||
|
context?: RouteContext,
|
||||||
|
) => {
|
||||||
|
// Get ID from route params
|
||||||
|
const { id } = await (context?.params ?? Promise.resolve({ id: "" }));
|
||||||
|
|
||||||
|
// Fetch the period log
|
||||||
|
let periodLog: PeriodLog;
|
||||||
|
try {
|
||||||
|
periodLog = await pb.collection("period_logs").getOne<PeriodLog>(id);
|
||||||
|
} catch (error) {
|
||||||
|
// Handle PocketBase 404 errors (can be Error or plain object)
|
||||||
|
const err = error as { status?: number };
|
||||||
|
if (err.status === 404) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Period log not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership
|
||||||
|
if (periodLog.user !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the period log
|
||||||
|
await pb.collection("period_logs").delete(id);
|
||||||
|
|
||||||
|
// Update user.lastPeriodDate to the previous period (or null if no more periods)
|
||||||
|
const previousPeriod = await getMostRecentPeriodAfterDeletion(pb, user.id);
|
||||||
|
await pb.collection("users").update(user.id, {
|
||||||
|
lastPeriodDate: previousPeriod
|
||||||
|
? formatDateStr(new Date(previousPeriod.startDate))
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 });
|
||||||
|
},
|
||||||
|
);
|
||||||
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,27 +9,67 @@ import type { DailyLog, User } from "@/types";
|
|||||||
// Module-level variable to control mock user in tests
|
// Module-level variable to control mock user in tests
|
||||||
let currentMockUser: User | null = null;
|
let currentMockUser: User | null = null;
|
||||||
|
|
||||||
// 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;
|
let currentMockDailyLog: DailyLog | null = null;
|
||||||
|
|
||||||
// Mock PocketBase client for database operations
|
// Module-level variable to control mock daily log for fallback (most recent)
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
let fallbackMockDailyLog: DailyLog | null = null;
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
|
||||||
collection: vi.fn(() => ({
|
// Track the filter string passed to getFirstListItem
|
||||||
getFirstListItem: vi.fn(async () => {
|
let lastDailyLogFilter: string | null = null;
|
||||||
if (!currentMockDailyLog) {
|
|
||||||
const error = new Error("No DailyLog found");
|
// Create mock PocketBase client
|
||||||
(error as { status?: number }).status = 404;
|
const mockPb = {
|
||||||
throw error;
|
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;
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
const error = new Error("No DailyLog found");
|
||||||
|
(error as { status?: number }).status = 404;
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
loadAuthFromCookies: vi.fn(),
|
};
|
||||||
isAuthenticated: vi.fn(() => currentMockUser !== null),
|
|
||||||
getCurrentUser: vi.fn(() => currentMockUser),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
@@ -38,7 +78,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -53,12 +93,18 @@ describe("GET /api/today", () => {
|
|||||||
garminOauth1Token: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
notificationTime: "07:00",
|
notificationTime: "07:00",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -89,6 +135,8 @@ describe("GET /api/today", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentMockUser = null;
|
currentMockUser = null;
|
||||||
currentMockDailyLog = null;
|
currentMockDailyLog = null;
|
||||||
|
fallbackMockDailyLog = null;
|
||||||
|
lastDailyLogFilter = null;
|
||||||
// Mock current date to 2025-01-10 for predictable testing
|
// Mock current date to 2025-01-10 for predictable testing
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2025-01-10T12:00:00Z"));
|
vi.setSystemTime(new Date("2025-01-10T12:00:00Z"));
|
||||||
@@ -350,7 +398,7 @@ describe("GET /api/today", () => {
|
|||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
expect(body.phaseConfig.name).toBe("FOLLICULAR");
|
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 () => {
|
it("returns days until next phase", async () => {
|
||||||
@@ -461,6 +509,58 @@ describe("GET /api/today", () => {
|
|||||||
);
|
);
|
||||||
expect(body.nutrition.carbRange).toBe("75-125g");
|
expect(body.nutrition.carbRange).toBe("75-125g");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns seed switch alert on day 15", async () => {
|
||||||
|
// Set to cycle day 15 - the seed switch day
|
||||||
|
currentMockUser = createMockUser({
|
||||||
|
lastPeriodDate: new Date("2024-12-27"), // 14 days ago = day 15
|
||||||
|
});
|
||||||
|
currentMockDailyLog = createMockDailyLog({
|
||||||
|
cycleDay: 15,
|
||||||
|
phase: "OVULATION",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.nutrition.seedSwitchAlert).toBe(
|
||||||
|
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null seed switch alert on other days", async () => {
|
||||||
|
currentMockUser = createMockUser({
|
||||||
|
lastPeriodDate: new Date("2025-01-01"), // cycle day 10
|
||||||
|
});
|
||||||
|
currentMockDailyLog = createMockDailyLog();
|
||||||
|
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(body.nutrition.seedSwitchAlert).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("biometrics data", () => {
|
||||||
@@ -495,10 +595,10 @@ describe("GET /api/today", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const body = await response.json();
|
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.hrvStatus).toBe("Unknown");
|
||||||
expect(body.biometrics.bodyBatteryCurrent).toBe(100);
|
expect(body.biometrics.bodyBatteryCurrent).toBeNull();
|
||||||
expect(body.biometrics.bodyBatteryYesterdayLow).toBe(100);
|
expect(body.biometrics.bodyBatteryYesterdayLow).toBeNull();
|
||||||
expect(body.biometrics.weekIntensityMinutes).toBe(0);
|
expect(body.biometrics.weekIntensityMinutes).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -511,9 +611,95 @@ describe("GET /api/today", () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
// With defaults (BB=100, HRV=Unknown), should allow training
|
// With null body battery, decision engine skips BB rules
|
||||||
// unless in restrictive phase
|
// and allows training unless in restrictive phase
|
||||||
expect(body.decision.status).toBe("TRAIN");
|
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,30 +7,36 @@ import {
|
|||||||
getCycleDay,
|
getCycleDay,
|
||||||
getPhase,
|
getPhase,
|
||||||
getPhaseConfig,
|
getPhaseConfig,
|
||||||
getPhaseLimit,
|
getUserPhaseLimit,
|
||||||
} from "@/lib/cycle";
|
} from "@/lib/cycle";
|
||||||
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
import { getDecisionWithOverrides } from "@/lib/decision-engine";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { getNutritionGuidance } from "@/lib/nutrition";
|
import { getNutritionGuidance, getSeedSwitchAlert } from "@/lib/nutrition";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
import { mapRecordToUser } from "@/lib/pocketbase";
|
||||||
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
import type { DailyData, DailyLog, HrvStatus } from "@/types";
|
||||||
|
|
||||||
// Default biometrics when no Garmin data is available
|
// Default biometrics when no Garmin data is available
|
||||||
const DEFAULT_BIOMETRICS: {
|
const DEFAULT_BIOMETRICS: {
|
||||||
hrvStatus: HrvStatus;
|
hrvStatus: HrvStatus;
|
||||||
bodyBatteryCurrent: number;
|
bodyBatteryCurrent: number | null;
|
||||||
bodyBatteryYesterdayLow: number;
|
bodyBatteryYesterdayLow: number | null;
|
||||||
weekIntensityMinutes: number;
|
weekIntensityMinutes: number;
|
||||||
} = {
|
} = {
|
||||||
hrvStatus: "Unknown",
|
hrvStatus: "Unknown",
|
||||||
bodyBatteryCurrent: 100,
|
bodyBatteryCurrent: null,
|
||||||
bodyBatteryYesterdayLow: 100,
|
bodyBatteryYesterdayLow: null,
|
||||||
weekIntensityMinutes: 0,
|
weekIntensityMinutes: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
||||||
|
// (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
|
// Validate required user data
|
||||||
if (!user.lastPeriodDate) {
|
if (!freshUser.lastPeriodDate) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
@@ -39,55 +45,112 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const lastPeriodDate = freshUser.lastPeriodDate;
|
||||||
|
const cycleLength = freshUser.cycleLength;
|
||||||
|
const activeOverrides = freshUser.activeOverrides || [];
|
||||||
|
|
||||||
// Calculate cycle information
|
// Calculate cycle information
|
||||||
const cycleDay = getCycleDay(
|
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, new Date());
|
||||||
new Date(user.lastPeriodDate),
|
const phase = getPhase(cycleDay, cycleLength);
|
||||||
user.cycleLength,
|
|
||||||
new Date(),
|
|
||||||
);
|
|
||||||
const phase = getPhase(cycleDay, user.cycleLength);
|
|
||||||
const phaseConfig = getPhaseConfig(phase);
|
const phaseConfig = getPhaseConfig(phase);
|
||||||
const phaseLimit = getPhaseLimit(phase);
|
const phaseLimit = getUserPhaseLimit(phase, freshUser);
|
||||||
|
|
||||||
// Calculate days until next phase using dynamic boundaries
|
// Calculate days until next phase using dynamic boundaries
|
||||||
// Phase boundaries: MENSTRUAL 1-3, FOLLICULAR 4-(cl-16), OVULATION (cl-15)-(cl-14),
|
// 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
|
// EARLY_LUTEAL (cl-13)-(cl-7), LATE_LUTEAL (cl-6)-cl
|
||||||
let daysUntilNextPhase: number;
|
let daysUntilNextPhase: number;
|
||||||
if (phase === "LATE_LUTEAL") {
|
if (phase === "LATE_LUTEAL") {
|
||||||
daysUntilNextPhase = user.cycleLength - cycleDay + 1;
|
daysUntilNextPhase = cycleLength - cycleDay + 1;
|
||||||
} else if (phase === "MENSTRUAL") {
|
} else if (phase === "MENSTRUAL") {
|
||||||
daysUntilNextPhase = 4 - cycleDay;
|
daysUntilNextPhase = 4 - cycleDay;
|
||||||
} else if (phase === "FOLLICULAR") {
|
} else if (phase === "FOLLICULAR") {
|
||||||
daysUntilNextPhase = user.cycleLength - 15 - cycleDay;
|
daysUntilNextPhase = cycleLength - 15 - cycleDay;
|
||||||
} else if (phase === "OVULATION") {
|
} else if (phase === "OVULATION") {
|
||||||
daysUntilNextPhase = user.cycleLength - 13 - cycleDay;
|
daysUntilNextPhase = cycleLength - 13 - cycleDay;
|
||||||
} else {
|
} else {
|
||||||
// EARLY_LUTEAL
|
// EARLY_LUTEAL
|
||||||
daysUntilNextPhase = user.cycleLength - 6 - cycleDay;
|
daysUntilNextPhase = cycleLength - 6 - cycleDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to fetch today's DailyLog for biometrics
|
// Try to fetch today's DailyLog for biometrics, fall back to most recent
|
||||||
|
// Sort by date DESC to get the most recent record if multiple exist
|
||||||
let biometrics = { ...DEFAULT_BIOMETRICS, phaseLimit };
|
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 {
|
try {
|
||||||
const pb = createPocketBaseClient();
|
// First try to get today's log
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
const dailyLog = await pb
|
const dailyLog = await pb
|
||||||
.collection("dailyLogs")
|
.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 = {
|
biometrics = {
|
||||||
hrvStatus: dailyLog.hrvStatus,
|
hrvStatus: dailyLog.hrvStatus,
|
||||||
bodyBatteryCurrent:
|
bodyBatteryCurrent: dailyLog.bodyBatteryCurrent,
|
||||||
dailyLog.bodyBatteryCurrent ?? DEFAULT_BIOMETRICS.bodyBatteryCurrent,
|
bodyBatteryYesterdayLow: dailyLog.bodyBatteryYesterdayLow,
|
||||||
bodyBatteryYesterdayLow:
|
|
||||||
dailyLog.bodyBatteryYesterdayLow ??
|
|
||||||
DEFAULT_BIOMETRICS.bodyBatteryYesterdayLow,
|
|
||||||
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
|
weekIntensityMinutes: dailyLog.weekIntensityMinutes,
|
||||||
phaseLimit: dailyLog.phaseLimit,
|
phaseLimit: dailyLog.phaseLimit,
|
||||||
};
|
};
|
||||||
|
lastSyncedAt = today;
|
||||||
} catch {
|
} 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
|
// Build DailyData for decision engine
|
||||||
@@ -101,7 +164,10 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get training decision with override handling
|
// Get training decision with override handling
|
||||||
const decision = getDecisionWithOverrides(dailyData, user.activeOverrides);
|
const decision = getDecisionWithOverrides(
|
||||||
|
dailyData,
|
||||||
|
activeOverrides as import("@/types").OverrideType[],
|
||||||
|
);
|
||||||
|
|
||||||
// Log decision calculation per observability spec
|
// Log decision calculation per observability spec
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -109,8 +175,12 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
"Decision calculated",
|
"Decision calculated",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get nutrition guidance
|
// Get nutrition guidance with seed switch alert
|
||||||
const nutrition = getNutritionGuidance(cycleDay);
|
const baseNutrition = getNutritionGuidance(cycleDay);
|
||||||
|
const nutrition = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: getSeedSwitchAlert(cycleDay),
|
||||||
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
decision,
|
decision,
|
||||||
@@ -118,8 +188,9 @@ export const GET = withAuth(async (_request, user) => {
|
|||||||
phase,
|
phase,
|
||||||
phaseConfig,
|
phaseConfig,
|
||||||
daysUntilNextPhase,
|
daysUntilNextPhase,
|
||||||
cycleLength: user.cycleLength,
|
cycleLength,
|
||||||
biometrics,
|
biometrics,
|
||||||
nutrition,
|
nutrition,
|
||||||
|
lastSyncedAt,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,14 +12,31 @@ let currentMockUser: User | null = null;
|
|||||||
// Track PocketBase update calls
|
// Track PocketBase update calls
|
||||||
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
const mockPbUpdate = vi.fn().mockResolvedValue({});
|
||||||
|
|
||||||
// Mock PocketBase
|
// Track PocketBase getOne calls - returns the current mock user data
|
||||||
vi.mock("@/lib/pocketbase", () => ({
|
const mockPbGetOne = vi.fn().mockImplementation(() => {
|
||||||
createPocketBaseClient: vi.fn(() => ({
|
if (!currentMockUser) {
|
||||||
collection: vi.fn(() => ({
|
throw new Error("User not found");
|
||||||
update: mockPbUpdate,
|
}
|
||||||
})),
|
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,
|
||||||
})),
|
})),
|
||||||
}));
|
};
|
||||||
|
|
||||||
// Mock the auth-middleware module
|
// Mock the auth-middleware module
|
||||||
vi.mock("@/lib/auth-middleware", () => ({
|
vi.mock("@/lib/auth-middleware", () => ({
|
||||||
@@ -28,7 +45,7 @@ vi.mock("@/lib/auth-middleware", () => ({
|
|||||||
if (!currentMockUser) {
|
if (!currentMockUser) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
return handler(request, currentMockUser);
|
return handler(request, currentMockUser, mockPb);
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
@@ -43,12 +60,18 @@ describe("GET /api/user", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: ["flare"],
|
activeOverrides: ["flare"],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -57,6 +80,7 @@ describe("GET /api/user", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentMockUser = null;
|
currentMockUser = null;
|
||||||
mockPbUpdate.mockClear();
|
mockPbUpdate.mockClear();
|
||||||
|
mockPbGetOne.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns user profile when authenticated", async () => {
|
it("returns user profile when authenticated", async () => {
|
||||||
@@ -78,17 +102,27 @@ describe("GET /api/user", () => {
|
|||||||
expect(body.timezone).toBe("America/New_York");
|
expect(body.timezone).toBe("America/New_York");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not expose sensitive token fields", async () => {
|
it("does not expose sensitive Garmin token fields", async () => {
|
||||||
currentMockUser = mockUser;
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = {} as NextRequest;
|
||||||
const response = await GET(mockRequest);
|
const response = await GET(mockRequest);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|
||||||
// Should NOT include encrypted tokens
|
// Should NOT include encrypted Garmin tokens
|
||||||
expect(body.garminOauth1Token).toBeUndefined();
|
expect(body.garminOauth1Token).toBeUndefined();
|
||||||
expect(body.garminOauth2Token).toBeUndefined();
|
expect(body.garminOauth2Token).toBeUndefined();
|
||||||
expect(body.calendarToken).toBeUndefined();
|
});
|
||||||
|
|
||||||
|
it("includes calendarToken for calendar subscription URL", async () => {
|
||||||
|
currentMockUser = mockUser;
|
||||||
|
|
||||||
|
const mockRequest = {} as NextRequest;
|
||||||
|
const response = await GET(mockRequest);
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
// calendarToken is needed by the calendar page to display the subscription URL
|
||||||
|
expect(body.calendarToken).toBe("cal-secret-token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes activeOverrides array", async () => {
|
it("includes activeOverrides array", async () => {
|
||||||
@@ -121,12 +155,18 @@ describe("PATCH /api/user", () => {
|
|||||||
garminOauth1Token: "encrypted-token-1",
|
garminOauth1Token: "encrypted-token-1",
|
||||||
garminOauth2Token: "encrypted-token-2",
|
garminOauth2Token: "encrypted-token-2",
|
||||||
garminTokenExpiresAt: new Date("2025-06-01"),
|
garminTokenExpiresAt: new Date("2025-06-01"),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-secret-token",
|
calendarToken: "cal-secret-token",
|
||||||
lastPeriodDate: new Date("2025-01-15"),
|
lastPeriodDate: new Date("2025-01-15"),
|
||||||
cycleLength: 28,
|
cycleLength: 28,
|
||||||
notificationTime: "07:30",
|
notificationTime: "07:30",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
activeOverrides: ["flare"],
|
activeOverrides: ["flare"],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date("2024-01-01"),
|
created: new Date("2024-01-01"),
|
||||||
updated: new Date("2025-01-10"),
|
updated: new Date("2025-01-10"),
|
||||||
};
|
};
|
||||||
@@ -135,6 +175,7 @@ describe("PATCH /api/user", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
currentMockUser = null;
|
currentMockUser = null;
|
||||||
mockPbUpdate.mockClear();
|
mockPbUpdate.mockClear();
|
||||||
|
mockPbGetOne.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to create mock request with JSON body
|
// Helper to create mock request with JSON body
|
||||||
@@ -372,9 +413,8 @@ describe("PATCH /api/user", () => {
|
|||||||
expect(body.cycleLength).toBe(32);
|
expect(body.cycleLength).toBe(32);
|
||||||
expect(body.notificationTime).toBe("07:30");
|
expect(body.notificationTime).toBe("07:30");
|
||||||
expect(body.timezone).toBe("America/New_York");
|
expect(body.timezone).toBe("America/New_York");
|
||||||
// Should not expose sensitive fields
|
// Should not expose sensitive Garmin token fields
|
||||||
expect(body.garminOauth1Token).toBeUndefined();
|
expect(body.garminOauth1Token).toBeUndefined();
|
||||||
expect(body.garminOauth2Token).toBeUndefined();
|
expect(body.garminOauth2Token).toBeUndefined();
|
||||||
expect(body.calendarToken).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { NextRequest } from "next/server";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { withAuth } from "@/lib/auth-middleware";
|
import { withAuth } from "@/lib/auth-middleware";
|
||||||
import { createPocketBaseClient } from "@/lib/pocketbase";
|
|
||||||
|
|
||||||
// Validation constants
|
// Validation constants
|
||||||
const CYCLE_LENGTH_MIN = 21;
|
const CYCLE_LENGTH_MIN = 21;
|
||||||
@@ -14,24 +13,35 @@ const TIME_FORMAT_REGEX = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|||||||
/**
|
/**
|
||||||
* GET /api/user
|
* GET /api/user
|
||||||
* Returns the authenticated user's profile.
|
* Returns the authenticated user's profile.
|
||||||
|
* Fetches fresh data from database to ensure updates are reflected.
|
||||||
* Excludes sensitive fields like encrypted tokens.
|
* Excludes sensitive fields like encrypted tokens.
|
||||||
*/
|
*/
|
||||||
export const GET = withAuth(async (_request, user) => {
|
export const GET = withAuth(async (_request, user, pb) => {
|
||||||
|
// Fetch fresh user data from database to get latest values
|
||||||
|
// The user param from withAuth is from auth store cache which may be stale
|
||||||
|
const freshUser = await pb.collection("users").getOne(user.id);
|
||||||
|
|
||||||
// Format date for consistent API response
|
// Format date for consistent API response
|
||||||
const lastPeriodDate = user.lastPeriodDate
|
const lastPeriodDate = freshUser.lastPeriodDate
|
||||||
? user.lastPeriodDate.toISOString().split("T")[0]
|
? new Date(freshUser.lastPeriodDate as string).toISOString().split("T")[0]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json(
|
||||||
id: user.id,
|
{
|
||||||
email: user.email,
|
id: freshUser.id,
|
||||||
garminConnected: user.garminConnected,
|
email: freshUser.email,
|
||||||
cycleLength: user.cycleLength,
|
garminConnected: freshUser.garminConnected ?? false,
|
||||||
lastPeriodDate,
|
cycleLength: freshUser.cycleLength,
|
||||||
notificationTime: user.notificationTime,
|
lastPeriodDate,
|
||||||
timezone: user.timezone,
|
notificationTime: freshUser.notificationTime,
|
||||||
activeOverrides: user.activeOverrides,
|
timezone: freshUser.timezone,
|
||||||
});
|
activeOverrides: freshUser.activeOverrides ?? [],
|
||||||
|
calendarToken: (freshUser.calendarToken as string) || null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +91,7 @@ function validateTimezone(value: unknown): string | null {
|
|||||||
* Updates the authenticated user's profile.
|
* Updates the authenticated user's profile.
|
||||||
* Allowed fields: cycleLength, notificationTime, timezone
|
* Allowed fields: cycleLength, notificationTime, timezone
|
||||||
*/
|
*/
|
||||||
export const PATCH = withAuth(async (request: NextRequest, user) => {
|
export const PATCH = withAuth(async (request: NextRequest, user, pb) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Build update object with only valid, updatable fields
|
// Build update object with only valid, updatable fields
|
||||||
@@ -132,7 +142,6 @@ export const PATCH = withAuth(async (request: NextRequest, user) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update the user record in PocketBase
|
// Update the user record in PocketBase
|
||||||
const pb = createPocketBaseClient();
|
|
||||||
await pb.collection("users").update(user.id, updates);
|
await pb.collection("users").update(user.id, updates);
|
||||||
|
|
||||||
// Build updated user profile for response
|
// Build updated user profile for response
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock showToast utility with vi.hoisted to avoid hoisting issues
|
||||||
|
const mockShowToast = vi.hoisted(() => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/ui/toaster", () => ({
|
||||||
|
showToast: mockShowToast,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
@@ -30,6 +40,9 @@ describe("CalendarPage", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockShowToast.success.mockClear();
|
||||||
|
mockShowToast.error.mockClear();
|
||||||
|
mockShowToast.info.mockClear();
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockUser),
|
json: () => Promise.resolve(mockUser),
|
||||||
@@ -134,6 +147,21 @@ describe("CalendarPage", () => {
|
|||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows error toast when fetching fails", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: "Network error" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<CalendarPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
|
"Unable to fetch data. Retry?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("month navigation", () => {
|
describe("month navigation", () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { MonthView } from "@/components/calendar/month-view";
|
import { MonthView } from "@/components/calendar/month-view";
|
||||||
|
import { showToast } from "@/components/ui/toaster";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,12 +31,15 @@ export default function CalendarPage() {
|
|||||||
const res = await fetch("/api/user");
|
const res = await fetch("/api/user");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || "Failed to fetch user");
|
const message = data.error || "Failed to fetch user";
|
||||||
|
setError(message);
|
||||||
|
showToast.error("Unable to fetch data. Retry?");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUser(data);
|
setUser(data);
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to fetch user data");
|
setError("Failed to fetch user data");
|
||||||
|
showToast.error("Unable to fetch data. Retry?");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ vi.mock("next/font/google", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the Toaster component to avoid sonner dependencies in tests
|
||||||
|
vi.mock("@/components/ui/toaster", () => ({
|
||||||
|
Toaster: () => <div data-testid="toaster">Toast Provider</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
import RootLayout from "./layout";
|
import RootLayout from "./layout";
|
||||||
|
|
||||||
describe("RootLayout", () => {
|
describe("RootLayout", () => {
|
||||||
@@ -56,5 +61,15 @@ describe("RootLayout", () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId("child-content")).toBeInTheDocument();
|
expect(screen.getByTestId("child-content")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the Toaster component for toast notifications", () => {
|
||||||
|
render(
|
||||||
|
<RootLayout>
|
||||||
|
<main id="main-content">Test content</main>
|
||||||
|
</RootLayout>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("toaster")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// ABOUTME: Root layout for PhaseFlow application.
|
// ABOUTME: Root layout for PhaseFlow application.
|
||||||
// ABOUTME: Configures fonts, metadata, and global styles.
|
// ABOUTME: Configures fonts, metadata, Toaster provider, and global styles.
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -32,11 +33,12 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#main-content"
|
href="#main-content"
|
||||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:text-blue-600 focus:underline"
|
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-background focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:text-blue-600 dark:focus:text-blue-400 focus:underline focus:border focus:border-input"
|
||||||
>
|
>
|
||||||
Skip to main content
|
Skip to main content
|
||||||
</a>
|
</a>
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export default function LoginPage() {
|
|||||||
>
|
>
|
||||||
<div className="w-full max-w-md space-y-8 p-8">
|
<div className="w-full max-w-md space-y-8 p-8">
|
||||||
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
|
||||||
<div className="text-center text-gray-500">Loading...</div>
|
<div className="text-center text-muted-foreground">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -217,7 +217,7 @@ export default function LoginPage() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded"
|
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
@@ -241,7 +241,7 @@ export default function LoginPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="email"
|
htmlFor="email"
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
@@ -251,7 +251,7 @@ export default function LoginPage() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => handleInputChange(setEmail, e.target.value)}
|
onChange={(e) => handleInputChange(setEmail, e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
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"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +259,7 @@ export default function LoginPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
@@ -269,7 +269,7 @@ export default function LoginPage() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => handleInputChange(setPassword, e.target.value)}
|
onChange={(e) => handleInputChange(setPassword, e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
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"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,16 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock showToast utility with vi.hoisted to avoid hoisting issues
|
||||||
|
const mockShowToast = vi.hoisted(() => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/ui/toaster", () => ({
|
||||||
|
showToast: mockShowToast,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock fetch globally
|
// Mock fetch globally
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
@@ -55,6 +65,9 @@ const mockUserResponse = {
|
|||||||
describe("Dashboard", () => {
|
describe("Dashboard", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockShowToast.success.mockClear();
|
||||||
|
mockShowToast.error.mockClear();
|
||||||
|
mockShowToast.info.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("rendering", () => {
|
describe("rendering", () => {
|
||||||
@@ -231,7 +244,7 @@ describe("Dashboard", () => {
|
|||||||
render(<Dashboard />);
|
render(<Dashboard />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/hrv.*balanced/i)).toBeInTheDocument();
|
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -496,6 +509,42 @@ describe("Dashboard", () => {
|
|||||||
expect(screen.getByText(/flare mode active/i)).toBeInTheDocument();
|
expect(screen.getByText(/flare mode active/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows error toast when toggle fails", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockTodayResponse),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockUserResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<Dashboard />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Flare Mode")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear mock and set up for failed toggle
|
||||||
|
mockFetch.mockClear();
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: "Failed to update override" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const flareCheckbox = screen.getByRole("checkbox", {
|
||||||
|
name: /flare mode/i,
|
||||||
|
});
|
||||||
|
fireEvent.click(flareCheckbox);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
|
"Failed to update override",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("error handling", () => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { OnboardingBanner } from "@/components/dashboard/onboarding-banner";
|
|||||||
import { OverrideToggles } from "@/components/dashboard/override-toggles";
|
import { OverrideToggles } from "@/components/dashboard/override-toggles";
|
||||||
import { PeriodDateModal } from "@/components/dashboard/period-date-modal";
|
import { PeriodDateModal } from "@/components/dashboard/period-date-modal";
|
||||||
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
|
import { DashboardSkeleton } from "@/components/dashboard/skeletons";
|
||||||
|
import { showToast } from "@/components/ui/toaster";
|
||||||
import type {
|
import type {
|
||||||
CyclePhase,
|
CyclePhase,
|
||||||
Decision,
|
Decision,
|
||||||
@@ -173,9 +174,9 @@ export default function Dashboard() {
|
|||||||
const newTodayData = await fetchTodayData();
|
const newTodayData = await fetchTodayData();
|
||||||
setTodayData(newTodayData);
|
setTodayData(newTodayData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
const message =
|
||||||
err instanceof Error ? err.message : "Failed to toggle override",
|
err instanceof Error ? err.message : "Failed to toggle override";
|
||||||
);
|
showToast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
671
src/app/period-history/page.test.tsx
Normal file
671
src/app/period-history/page.test.tsx
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
// ABOUTME: Unit tests for the Period History page component.
|
||||||
|
// ABOUTME: Tests data loading, table rendering, edit/delete functionality, and pagination.
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock next/navigation
|
||||||
|
const mockPush = vi.fn();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: mockPush,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock showToast utility with vi.hoisted to avoid hoisting issues
|
||||||
|
const mockShowToast = vi.hoisted(() => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/ui/toaster", () => ({
|
||||||
|
showToast: mockShowToast,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
import PeriodHistoryPage from "./page";
|
||||||
|
|
||||||
|
describe("PeriodHistoryPage", () => {
|
||||||
|
const mockPeriodLog = {
|
||||||
|
id: "period1",
|
||||||
|
user: "user123",
|
||||||
|
startDate: "2025-01-15",
|
||||||
|
predictedDate: "2025-01-16",
|
||||||
|
created: "2025-01-15T10:00:00Z",
|
||||||
|
cycleLength: 28,
|
||||||
|
daysEarly: 1,
|
||||||
|
daysLate: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockHistoryResponse = {
|
||||||
|
items: [mockPeriodLog],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
totalPages: 1,
|
||||||
|
hasMore: false,
|
||||||
|
averageCycleLength: 28,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockShowToast.success.mockClear();
|
||||||
|
mockShowToast.error.mockClear();
|
||||||
|
mockShowToast.info.mockClear();
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockHistoryResponse),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the period history heading", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { name: /period history/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a back link to dashboard", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /back to dashboard/i }),
|
||||||
|
).toHaveAttribute("href", "/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the period history table with headers", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const columnHeaders = screen.getAllByRole("columnheader");
|
||||||
|
expect(columnHeaders.length).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(columnHeaders[0]).toHaveTextContent(/date/i);
|
||||||
|
expect(columnHeaders[1]).toHaveTextContent(/cycle length/i);
|
||||||
|
expect(columnHeaders[2]).toHaveTextContent(/prediction accuracy/i);
|
||||||
|
expect(columnHeaders[3]).toHaveTextContent(/actions/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("data loading", () => {
|
||||||
|
it("fetches period history data on mount", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/period-history"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state while fetching", async () => {
|
||||||
|
let resolveHistory: (value: unknown) => void = () => {};
|
||||||
|
const historyPromise = new Promise((resolve) => {
|
||||||
|
resolveHistory = resolve;
|
||||||
|
});
|
||||||
|
mockFetch.mockReturnValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => historyPromise,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
resolveHistory(mockHistoryResponse);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays period log entries in the table", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Check that the log entry data is displayed
|
||||||
|
expect(screen.getByText(/jan 15, 2025/i)).toBeInTheDocument();
|
||||||
|
// Check the table contains cycle length (use getAllByText since it appears twice)
|
||||||
|
const cycleLengthElements = screen.getAllByText(/28 days/i);
|
||||||
|
expect(cycleLengthElements.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays prediction accuracy", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// 1 day early
|
||||||
|
expect(screen.getByText(/1 day early/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays multiple period entries", async () => {
|
||||||
|
const logs = [
|
||||||
|
mockPeriodLog,
|
||||||
|
{
|
||||||
|
...mockPeriodLog,
|
||||||
|
id: "period2",
|
||||||
|
startDate: "2024-12-18",
|
||||||
|
cycleLength: 28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...mockPeriodLog,
|
||||||
|
id: "period3",
|
||||||
|
startDate: "2024-11-20",
|
||||||
|
cycleLength: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
items: logs,
|
||||||
|
total: 3,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/jan 15, 2025/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/dec 18, 2024/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/nov 20, 2024/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("average cycle length", () => {
|
||||||
|
it("displays average cycle length", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/average cycle/i)).toBeInTheDocument();
|
||||||
|
// The text "28 days" appears in both average section and table
|
||||||
|
const cycleLengthElements = screen.getAllByText(/28 days/i);
|
||||||
|
expect(cycleLengthElements.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no average when only one period exists", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
averageCycleLength: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/average cycle.*\d+ days/i),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("empty state", () => {
|
||||||
|
it("shows empty state message when no periods exist", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
hasMore: false,
|
||||||
|
averageCycleLength: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/no period history/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("shows error message on fetch failure", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ error: "Failed to fetch period history" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/failed to fetch period history/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows generic error for network failures", async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error("Network error"));
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/network error/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast on fetch failure", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ error: "Failed to fetch period history" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
|
"Unable to fetch data. Retry?",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast when delete fails", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockHistoryResponse),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: "Failed to delete period" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /delete/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
|
"Failed to delete period",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pagination", () => {
|
||||||
|
it("shows pagination info", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 50,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
hasMore: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders previous and next buttons", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 50,
|
||||||
|
page: 2,
|
||||||
|
totalPages: 3,
|
||||||
|
hasMore: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /previous/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /next/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables previous button on first page", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 50,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
hasMore: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /previous/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables next button on last page", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 50,
|
||||||
|
page: 3,
|
||||||
|
totalPages: 3,
|
||||||
|
hasMore: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /next/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches next page when next button is clicked", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 50,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 3,
|
||||||
|
hasMore: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /next/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 50,
|
||||||
|
page: 2,
|
||||||
|
totalPages: 3,
|
||||||
|
hasMore: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /next/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenLastCalledWith(
|
||||||
|
expect.stringContaining("page=2"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides pagination when there is only one page", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 5,
|
||||||
|
page: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
hasMore: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /previous/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /next/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edit functionality", () => {
|
||||||
|
it("renders edit button for each period", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /edit/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows edit modal when edit button is clicked", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /edit/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/period start date/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits edit with new date", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockHistoryResponse),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ ...mockPeriodLog, startDate: "2025-01-14" }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockHistoryResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /edit/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/period start date/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateInput = screen.getByLabelText(/period start date/i);
|
||||||
|
fireEvent.change(dateInput, { target: { value: "2025-01-14" } });
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/period-logs/period1",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ startDate: "2025-01-14" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete functionality", () => {
|
||||||
|
it("renders delete button for each period", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /delete/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows confirmation dialog when delete button is clicked", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /delete/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes period when confirmed", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockHistoryResponse),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ success: true }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({ ...mockHistoryResponse, items: [], total: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /delete/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
"/api/period-logs/period1",
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels delete when cancel button is clicked", async () => {
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /delete/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(/are you sure/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not have made a delete call
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1); // Only initial fetch
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("total entries display", () => {
|
||||||
|
it("shows total entries count", async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
...mockHistoryResponse,
|
||||||
|
total: 12,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PeriodHistoryPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/12 periods/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
438
src/app/period-history/page.tsx
Normal file
438
src/app/period-history/page.tsx
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
// ABOUTME: Period history view showing all logged periods with cycle length calculations.
|
||||||
|
// ABOUTME: Allows editing and deleting period entries with confirmation dialogs.
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { showToast } from "@/components/ui/toaster";
|
||||||
|
|
||||||
|
interface PeriodLogWithCycleLength {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
startDate: string;
|
||||||
|
predictedDate: string | null;
|
||||||
|
created: string;
|
||||||
|
cycleLength: number | null;
|
||||||
|
daysEarly: number | null;
|
||||||
|
daysLate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeriodHistoryResponse {
|
||||||
|
items: PeriodLogWithCycleLength[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
averageCycleLength: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string for display.
|
||||||
|
*/
|
||||||
|
function formatDate(dateStr: string | Date): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats prediction accuracy for display.
|
||||||
|
*/
|
||||||
|
function formatPredictionAccuracy(
|
||||||
|
daysEarly: number | null,
|
||||||
|
daysLate: number | null,
|
||||||
|
): string {
|
||||||
|
if (daysEarly === null && daysLate === null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
if (daysEarly && daysEarly > 0) {
|
||||||
|
return `${daysEarly} day${daysEarly > 1 ? "s" : ""} early`;
|
||||||
|
}
|
||||||
|
if (daysLate && daysLate > 0) {
|
||||||
|
return `${daysLate} day${daysLate > 1 ? "s" : ""} late`;
|
||||||
|
}
|
||||||
|
return "On time";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeriodHistoryPage() {
|
||||||
|
const [data, setData] = useState<PeriodHistoryResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [editingPeriod, setEditingPeriod] =
|
||||||
|
useState<PeriodLogWithCycleLength | null>(null);
|
||||||
|
const [editDate, setEditDate] = useState("");
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Delete confirmation state
|
||||||
|
const [deletingPeriod, setDeletingPeriod] =
|
||||||
|
useState<PeriodLogWithCycleLength | null>(null);
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async (pageNum: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("page", pageNum.toString());
|
||||||
|
|
||||||
|
const response = await fetch(`/api/period-history?${params.toString()}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || "Failed to fetch period history");
|
||||||
|
}
|
||||||
|
|
||||||
|
setData(result);
|
||||||
|
setPage(result.page);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
|
setError(message);
|
||||||
|
showToast.error("Unable to fetch data. Retry?");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory(1);
|
||||||
|
}, [fetchHistory]);
|
||||||
|
|
||||||
|
const handlePreviousPage = () => {
|
||||||
|
if (page > 1) {
|
||||||
|
fetchHistory(page - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextPage = () => {
|
||||||
|
if (data?.hasMore) {
|
||||||
|
fetchHistory(page + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (period: PeriodLogWithCycleLength) => {
|
||||||
|
setEditingPeriod(period);
|
||||||
|
// Extract date portion from ISO string or Date object
|
||||||
|
const dateStr = new Date(period.startDate).toISOString().split("T")[0];
|
||||||
|
setEditDate(dateStr);
|
||||||
|
setEditError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditingPeriod(null);
|
||||||
|
setEditDate("");
|
||||||
|
setEditError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editingPeriod) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/period-logs/${editingPeriod.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ startDate: editDate }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
throw new Error(result.error || "Failed to update period");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and refresh data
|
||||||
|
setEditingPeriod(null);
|
||||||
|
setEditDate("");
|
||||||
|
fetchHistory(page);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
|
setEditError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (period: PeriodLogWithCycleLength) => {
|
||||||
|
setDeletingPeriod(period);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCancel = () => {
|
||||||
|
setDeletingPeriod(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deletingPeriod) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/period-logs/${deletingPeriod.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
throw new Error(result.error || "Failed to delete period");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal and refresh data
|
||||||
|
setDeletingPeriod(null);
|
||||||
|
fetchHistory(page);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
|
setError(message);
|
||||||
|
showToast.error(message || "Failed to delete. Try again.");
|
||||||
|
setDeletingPeriod(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !data) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-8">Period History</h1>
|
||||||
|
<p className="text-gray-500">Loading...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-8">
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-2xl font-bold">Period History</h1>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-blue-600 hover:text-blue-700 hover:underline"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Average Cycle Length */}
|
||||||
|
{data && data.averageCycleLength !== null && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<p className="text-blue-700">
|
||||||
|
<span className="font-medium">Average Cycle:</span>{" "}
|
||||||
|
{data.averageCycleLength} days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Total Entries */}
|
||||||
|
{data && (
|
||||||
|
<p className="text-sm text-gray-600 mb-4">{data.total} periods</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{data && data.items.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
<p>No period history found</p>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
Log your period to start tracking your cycle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Period History Table */}
|
||||||
|
{data && data.items.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Cycle Length
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Prediction Accuracy
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{data.items.map((period) => (
|
||||||
|
<tr key={period.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatDate(period.startDate)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{period.cycleLength !== null
|
||||||
|
? `${period.cycleLength} days`
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatPredictionAccuracy(
|
||||||
|
period.daysEarly,
|
||||||
|
period.daysLate,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEditClick(period)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteClick(period)}
|
||||||
|
className="text-red-600 hover:text-red-800 hover:underline"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{data && data.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreviousPage}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
Page {page} of {data.totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={!data.hasMore}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingPeriod && (
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by form focus
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
>
|
||||||
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="edit-modal-title"
|
||||||
|
className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="edit-modal-title" className="text-lg font-semibold mb-4">
|
||||||
|
Edit Period Date
|
||||||
|
</h2>
|
||||||
|
{editError && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded mb-4 text-sm">
|
||||||
|
{editError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label
|
||||||
|
htmlFor="editDate"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Period Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="editDate"
|
||||||
|
type="date"
|
||||||
|
value={editDate}
|
||||||
|
onChange={(e) => setEditDate(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEditCancel}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEditSave}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{deletingPeriod && (
|
||||||
|
// biome-ignore lint/a11y/useKeyWithClickEvents: Keyboard navigation handled by buttons
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop click-to-close is a convenience feature
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
>
|
||||||
|
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click handler prevents event bubbling, not user interaction */}
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-modal-title"
|
||||||
|
className="bg-white rounded-lg p-6 max-w-md w-full mx-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="delete-modal-title" className="text-lg font-semibold mb-4">
|
||||||
|
Delete Period
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Are you sure you want to delete the period from{" "}
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatDate(deletingPeriod.startDate)}
|
||||||
|
</span>
|
||||||
|
? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteCancel}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
className="rounded-md px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,16 @@ vi.mock("next/link", () => ({
|
|||||||
}) => <a href={href}>{children}</a>,
|
}) => <a href={href}>{children}</a>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock showToast utility with vi.hoisted to avoid hoisting issues
|
||||||
|
const mockShowToast = vi.hoisted(() => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/ui/toaster", () => ({
|
||||||
|
showToast: mockShowToast,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
@@ -23,6 +33,9 @@ import GarminSettingsPage from "./page";
|
|||||||
describe("GarminSettingsPage", () => {
|
describe("GarminSettingsPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockShowToast.success.mockClear();
|
||||||
|
mockShowToast.error.mockClear();
|
||||||
|
mockShowToast.info.mockClear();
|
||||||
// Default mock for disconnected state
|
// Default mock for disconnected state
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -266,8 +279,7 @@ describe("GarminSettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith("Invalid JSON format");
|
||||||
expect(screen.getByText(/invalid json format/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,8 +297,7 @@ describe("GarminSettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith("oauth2 is required");
|
||||||
expect(screen.getByText(/oauth2.*required/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,7 +360,7 @@ describe("GarminSettingsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows success message after saving tokens", async () => {
|
it("shows success toast after saving tokens", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -400,7 +411,9 @@ describe("GarminSettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/tokens saved/i)).toBeInTheDocument();
|
expect(mockShowToast.success).toHaveBeenCalledWith(
|
||||||
|
"Tokens saved successfully",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -457,7 +470,7 @@ describe("GarminSettingsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error when save fails", async () => {
|
it("shows error toast when save fails", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -493,8 +506,9 @@ describe("GarminSettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
expect(screen.getByText(/failed to save tokens/i)).toBeInTheDocument();
|
"Failed to save tokens",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -561,7 +575,7 @@ describe("GarminSettingsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows disconnected message after successful disconnect", async () => {
|
it("shows success toast after successful disconnect", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -603,7 +617,9 @@ describe("GarminSettingsPage", () => {
|
|||||||
fireEvent.click(disconnectButton);
|
fireEvent.click(disconnectButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/garmin disconnected/i)).toBeInTheDocument();
|
expect(mockShowToast.success).toHaveBeenCalledWith(
|
||||||
|
"Garmin disconnected successfully",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -651,7 +667,7 @@ describe("GarminSettingsPage", () => {
|
|||||||
resolveDisconnect({ success: true, garminConnected: false });
|
resolveDisconnect({ success: true, garminConnected: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error when disconnect fails", async () => {
|
it("shows error toast when disconnect fails", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -682,8 +698,9 @@ describe("GarminSettingsPage", () => {
|
|||||||
fireEvent.click(disconnectButton);
|
fireEvent.click(disconnectButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
expect(screen.getByText(/failed to disconnect/i)).toBeInTheDocument();
|
"Failed to disconnect",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -703,26 +720,19 @@ describe("GarminSettingsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clears error when user modifies input", async () => {
|
it("shows error toast on load failure", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ error: "Network error" }),
|
||||||
|
});
|
||||||
|
|
||||||
render(<GarminSettingsPage />);
|
render(<GarminSettingsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText(/paste tokens/i)).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
|
"Unable to fetch data. Retry?",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const textarea = screen.getByLabelText(/paste tokens/i);
|
|
||||||
fireEvent.change(textarea, { target: { value: "invalid json" } });
|
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /save tokens/i });
|
|
||||||
fireEvent.click(saveButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.change(textarea, { target: { value: '{"oauth1": {}}' } });
|
|
||||||
|
|
||||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { showToast } from "@/components/ui/toaster";
|
||||||
|
|
||||||
interface GarminStatus {
|
interface GarminStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -17,13 +18,12 @@ export default function GarminSettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [disconnecting, setDisconnecting] = useState(false);
|
const [disconnecting, setDisconnecting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
const [tokenInput, setTokenInput] = useState("");
|
const [tokenInput, setTokenInput] = useState("");
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setLoadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/garmin/status");
|
const response = await fetch("/api/garmin/status");
|
||||||
@@ -36,7 +36,8 @@ export default function GarminSettingsPage() {
|
|||||||
setStatus(data);
|
setStatus(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "An error occurred";
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
setError(message);
|
setLoadError(message);
|
||||||
|
showToast.error("Unable to fetch data. Retry?");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -48,12 +49,6 @@ export default function GarminSettingsPage() {
|
|||||||
|
|
||||||
const handleTokenChange = (value: string) => {
|
const handleTokenChange = (value: string) => {
|
||||||
setTokenInput(value);
|
setTokenInput(value);
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
if (success) {
|
|
||||||
setSuccess(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateTokens = (
|
const validateTokens = (
|
||||||
@@ -90,13 +85,11 @@ export default function GarminSettingsPage() {
|
|||||||
const handleSaveTokens = async () => {
|
const handleSaveTokens = async () => {
|
||||||
const validation = validateTokens(tokenInput);
|
const validation = validateTokens(tokenInput);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
setError(validation.error);
|
showToast.error(validation.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/garmin/tokens", {
|
const response = await fetch("/api/garmin/tokens", {
|
||||||
@@ -111,12 +104,12 @@ export default function GarminSettingsPage() {
|
|||||||
throw new Error(data.error || "Failed to save tokens");
|
throw new Error(data.error || "Failed to save tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess("Tokens saved successfully");
|
showToast.success("Tokens saved successfully");
|
||||||
setTokenInput("");
|
setTokenInput("");
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "An error occurred";
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
setError(message);
|
showToast.error(message || "Failed to save. Try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -124,8 +117,6 @@ export default function GarminSettingsPage() {
|
|||||||
|
|
||||||
const handleDisconnect = async () => {
|
const handleDisconnect = async () => {
|
||||||
setDisconnecting(true);
|
setDisconnecting(true);
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/garmin/tokens", {
|
const response = await fetch("/api/garmin/tokens", {
|
||||||
@@ -138,17 +129,22 @@ export default function GarminSettingsPage() {
|
|||||||
throw new Error(data.error || "Failed to disconnect");
|
throw new Error(data.error || "Failed to disconnect");
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess("Garmin disconnected successfully");
|
showToast.success("Garmin disconnected successfully");
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "An error occurred";
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
setError(message);
|
showToast.error(message || "Failed to disconnect. Try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setDisconnecting(false);
|
setDisconnecting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showTokenInput = !status?.connected || status?.expired;
|
// Show token input when:
|
||||||
|
// - Not connected
|
||||||
|
// - Token expired
|
||||||
|
// - Warning level active (so user can proactively paste new tokens)
|
||||||
|
const showTokenInput =
|
||||||
|
!status?.connected || status?.expired || status?.warningLevel;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -156,7 +152,7 @@ export default function GarminSettingsPage() {
|
|||||||
<h1 className="text-2xl font-bold mb-8">
|
<h1 className="text-2xl font-bold mb-8">
|
||||||
Settings > Garmin Connection
|
Settings > Garmin Connection
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-500">Loading...</p>
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,31 +169,27 @@ export default function GarminSettingsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{loadError && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
|
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
|
||||||
>
|
>
|
||||||
{error}
|
{loadError}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6">
|
|
||||||
{success}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-lg space-y-6">
|
<div className="max-w-lg space-y-6">
|
||||||
{/* Connection Status Section */}
|
{/* Connection Status Section */}
|
||||||
<div className="border border-gray-200 rounded-lg p-6">
|
<div className="border border-input rounded-lg p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Connection Status</h2>
|
<h2 className="text-lg font-semibold mb-4">Connection Status</h2>
|
||||||
|
|
||||||
{status?.connected && !status.expired ? (
|
{status?.connected && !status.expired ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="w-3 h-3 bg-green-500 rounded-full" />
|
<span className="w-3 h-3 bg-green-500 rounded-full" />
|
||||||
<span className="text-green-700 font-medium">Connected</span>
|
<span className="text-green-700 dark:text-green-400 font-medium">
|
||||||
|
Connected
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status.warningLevel && (
|
{status.warningLevel && (
|
||||||
@@ -205,8 +197,8 @@ export default function GarminSettingsPage() {
|
|||||||
data-testid="expiry-warning"
|
data-testid="expiry-warning"
|
||||||
className={`px-4 py-3 rounded ${
|
className={`px-4 py-3 rounded ${
|
||||||
status.warningLevel === "critical"
|
status.warningLevel === "critical"
|
||||||
? "bg-red-50 border border-red-200 text-red-700"
|
? "bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400"
|
||||||
: "bg-yellow-50 border border-yellow-200 text-yellow-700"
|
: "bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{status.warningLevel === "critical"
|
{status.warningLevel === "critical"
|
||||||
@@ -215,7 +207,7 @@ export default function GarminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
Token expires in{" "}
|
Token expires in{" "}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{status.daysUntilExpiry} days
|
{status.daysUntilExpiry} days
|
||||||
@@ -235,33 +227,39 @@ export default function GarminSettingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="w-3 h-3 bg-red-500 rounded-full" />
|
<span className="w-3 h-3 bg-red-500 rounded-full" />
|
||||||
<span className="text-red-700 font-medium">Token Expired</span>
|
<span className="text-red-700 dark:text-red-400 font-medium">
|
||||||
|
Token Expired
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
Your Garmin tokens have expired. Please generate new tokens and
|
Your Garmin tokens have expired. Please generate new tokens and
|
||||||
paste them below.
|
paste them below.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="w-3 h-3 bg-gray-400 rounded-full" />
|
<span className="w-3 h-3 bg-muted-foreground rounded-full" />
|
||||||
<span className="text-gray-600">Not Connected</span>
|
<span className="text-muted-foreground">Not Connected</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token Input Section */}
|
{/* Token Input Section */}
|
||||||
{showTokenInput && (
|
{showTokenInput && (
|
||||||
<div className="border border-gray-200 rounded-lg p-6">
|
<div className="border border-input rounded-lg p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">Connect Garmin</h2>
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
|
{status?.connected && status?.warningLevel
|
||||||
|
? "Refresh Tokens"
|
||||||
|
: "Connect Garmin"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm">
|
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-400 px-4 py-3 rounded text-sm">
|
||||||
<p className="font-medium mb-2">Instructions:</p>
|
<p className="font-medium mb-2">Instructions:</p>
|
||||||
<ol className="list-decimal list-inside space-y-1">
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
<li>
|
<li>
|
||||||
Run{" "}
|
Run{" "}
|
||||||
<code className="bg-blue-100 px-1 rounded">
|
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">
|
||||||
python3 scripts/garmin_auth.py
|
python3 scripts/garmin_auth.py
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
locally
|
locally
|
||||||
@@ -274,7 +272,7 @@ export default function GarminSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="tokenInput"
|
htmlFor="tokenInput"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
className="block text-sm font-medium text-foreground mb-1"
|
||||||
>
|
>
|
||||||
Paste Tokens (JSON)
|
Paste Tokens (JSON)
|
||||||
</label>
|
</label>
|
||||||
@@ -285,7 +283,7 @@ export default function GarminSettingsPage() {
|
|||||||
onChange={(e) => handleTokenChange(e.target.value)}
|
onChange={(e) => handleTokenChange(e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
placeholder='{"oauth1": {...}, "oauth2": {...}, "expires_at": "..."}'
|
placeholder='{"oauth1": {...}, "oauth2": {...}, "expires_at": "..."}'
|
||||||
className="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed font-mono text-sm"
|
className="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 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ vi.mock("next/navigation", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock showToast utility with vi.hoisted to avoid hoisting issues
|
||||||
|
const mockShowToast = vi.hoisted(() => ({
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/ui/toaster", () => ({
|
||||||
|
showToast: mockShowToast,
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
global.fetch = mockFetch;
|
global.fetch = mockFetch;
|
||||||
@@ -27,10 +37,18 @@ describe("SettingsPage", () => {
|
|||||||
garminConnected: false,
|
garminConnected: false,
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
lastPeriodDate: "2024-01-01",
|
lastPeriodDate: "2024-01-01",
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockShowToast.success.mockClear();
|
||||||
|
mockShowToast.error.mockClear();
|
||||||
|
mockShowToast.info.mockClear();
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve(mockUser),
|
json: () => Promise.resolve(mockUser),
|
||||||
@@ -227,6 +245,11 @@ describe("SettingsPage", () => {
|
|||||||
cycleLength: 30,
|
cycleLength: 30,
|
||||||
notificationTime: "08:00",
|
notificationTime: "08:00",
|
||||||
timezone: "America/New_York",
|
timezone: "America/New_York",
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -302,7 +325,7 @@ describe("SettingsPage", () => {
|
|||||||
resolveSave(mockUser);
|
resolveSave(mockUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows success message on save", async () => {
|
it("shows success toast on save", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -323,11 +346,13 @@ describe("SettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
|
expect(mockShowToast.success).toHaveBeenCalledWith(
|
||||||
|
"Settings saved successfully",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error on save failure", async () => {
|
it("shows error toast on save failure", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -349,10 +374,9 @@ describe("SettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
expect(
|
"cycleLength must be between 21 and 45",
|
||||||
screen.getByText(/cycleLength must be between 21 and 45/i),
|
);
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -377,7 +401,7 @@ describe("SettingsPage", () => {
|
|||||||
fireEvent.click(saveButton);
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText(/cycle length/i)).not.toBeDisabled();
|
expect(screen.getByLabelText(/cycle length/i)).not.toBeDisabled();
|
||||||
@@ -444,65 +468,20 @@ describe("SettingsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("error handling", () => {
|
describe("toast notifications", () => {
|
||||||
it("clears error when user starts typing", async () => {
|
it("shows toast with fetch error on load failure", async () => {
|
||||||
mockFetch
|
mockFetch.mockResolvedValueOnce({
|
||||||
.mockResolvedValueOnce({
|
ok: false,
|
||||||
ok: true,
|
json: () => Promise.resolve({ error: "Failed to fetch user" }),
|
||||||
json: () => Promise.resolve(mockUser),
|
});
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
json: () => Promise.resolve({ error: "Failed to save" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<SettingsPage />);
|
render(<SettingsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith(
|
||||||
|
"Unable to fetch data. Retry?",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
|
||||||
fireEvent.click(saveButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
|
||||||
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
|
|
||||||
|
|
||||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears success message when user modifies form", async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(mockUser),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve(mockUser),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<SettingsPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText(/cycle length/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
|
||||||
fireEvent.click(saveButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/settings saved/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const cycleLengthInput = screen.getByLabelText(/cycle length/i);
|
|
||||||
fireEvent.change(cycleLengthInput, { target: { value: "30" } });
|
|
||||||
|
|
||||||
expect(screen.queryByText(/settings saved/i)).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -643,7 +622,7 @@ describe("SettingsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows error if logout fails", async () => {
|
it("shows error toast if logout fails", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -666,9 +645,176 @@ describe("SettingsPage", () => {
|
|||||||
fireEvent.click(logoutButton);
|
fireEvent.click(logoutButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(mockShowToast.error).toHaveBeenCalledWith("Logout failed");
|
||||||
expect(screen.getByText(/logout failed/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { showToast } from "@/components/ui/toaster";
|
||||||
|
|
||||||
interface UserData {
|
interface UserData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +16,11 @@ interface UserData {
|
|||||||
garminConnected: boolean;
|
garminConnected: boolean;
|
||||||
activeOverrides: string[];
|
activeOverrides: string[];
|
||||||
lastPeriodDate: string | null;
|
lastPeriodDate: string | null;
|
||||||
|
intensityGoalMenstrual: number;
|
||||||
|
intensityGoalFollicular: number;
|
||||||
|
intensityGoalOvulation: number;
|
||||||
|
intensityGoalEarlyLuteal: number;
|
||||||
|
intensityGoalLateLuteal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
@@ -23,16 +29,20 @@ export default function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [cycleLength, setCycleLength] = useState(28);
|
const [cycleLength, setCycleLength] = useState(28);
|
||||||
const [notificationTime, setNotificationTime] = useState("08:00");
|
const [notificationTime, setNotificationTime] = useState("08:00");
|
||||||
const [timezone, setTimezone] = useState("");
|
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 () => {
|
const fetchUserData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setLoadError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/user");
|
const response = await fetch("/api/user");
|
||||||
@@ -46,9 +56,15 @@ export default function SettingsPage() {
|
|||||||
setCycleLength(data.cycleLength);
|
setCycleLength(data.cycleLength);
|
||||||
setNotificationTime(data.notificationTime);
|
setNotificationTime(data.notificationTime);
|
||||||
setTimezone(data.timezone);
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "An error occurred";
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
setError(message);
|
setLoadError(message);
|
||||||
|
showToast.error("Unable to fetch data. Retry?");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -63,20 +79,12 @@ export default function SettingsPage() {
|
|||||||
value: T,
|
value: T,
|
||||||
) => {
|
) => {
|
||||||
setter(value);
|
setter(value);
|
||||||
if (error) {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
if (success) {
|
|
||||||
setSuccess(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
|
||||||
setSuccess(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/user", {
|
const response = await fetch("/api/user", {
|
||||||
@@ -86,6 +94,11 @@ export default function SettingsPage() {
|
|||||||
cycleLength,
|
cycleLength,
|
||||||
notificationTime,
|
notificationTime,
|
||||||
timezone,
|
timezone,
|
||||||
|
intensityGoalMenstrual,
|
||||||
|
intensityGoalFollicular,
|
||||||
|
intensityGoalOvulation,
|
||||||
|
intensityGoalEarlyLuteal,
|
||||||
|
intensityGoalLateLuteal,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,10 +109,10 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUserData(data);
|
setUserData(data);
|
||||||
setSuccess("Settings saved successfully");
|
showToast.success("Settings saved successfully");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "An error occurred";
|
const message = err instanceof Error ? err.message : "An error occurred";
|
||||||
setError(message);
|
showToast.error(message || "Failed to save. Try again.");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -107,7 +120,6 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/logout", {
|
const response = await fetch("/api/auth/logout", {
|
||||||
@@ -123,7 +135,7 @@ export default function SettingsPage() {
|
|||||||
router.push(data.redirectTo || "/login");
|
router.push(data.redirectTo || "/login");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Logout failed";
|
const message = err instanceof Error ? err.message : "Logout failed";
|
||||||
setError(message);
|
showToast.error(message);
|
||||||
setLoggingOut(false);
|
setLoggingOut(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -132,7 +144,7 @@ export default function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<main id="main-content" className="container mx-auto p-8">
|
<main id="main-content" className="container mx-auto p-8">
|
||||||
<h1 className="text-2xl font-bold mb-8">Settings</h1>
|
<h1 className="text-2xl font-bold mb-8">Settings</h1>
|
||||||
<p className="text-gray-500">Loading...</p>
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,34 +161,30 @@ export default function SettingsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{loadError && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6"
|
className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded mb-6"
|
||||||
>
|
>
|
||||||
{error}
|
{loadError}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded mb-6">
|
|
||||||
{success}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-lg">
|
<div className="max-w-lg">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<span className="block text-sm font-medium text-gray-700">Email</span>
|
<span className="block text-sm font-medium text-foreground">
|
||||||
<p className="mt-1 text-gray-900">{userData?.email}</p>
|
Email
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 text-foreground">{userData?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 p-4 border border-gray-200 rounded-lg">
|
<div className="mb-6 p-4 border border-input rounded-lg">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="block text-sm font-medium text-gray-700">
|
<span className="block text-sm font-medium text-foreground">
|
||||||
Garmin Connection
|
Garmin Connection
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{userData?.garminConnected
|
{userData?.garminConnected
|
||||||
? "Connected to Garmin"
|
? "Connected to Garmin"
|
||||||
: "Not connected"}
|
: "Not connected"}
|
||||||
@@ -195,7 +203,7 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="cycleLength"
|
htmlFor="cycleLength"
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
Cycle Length (days)
|
Cycle Length (days)
|
||||||
</label>
|
</label>
|
||||||
@@ -209,10 +217,10 @@ export default function SettingsPage() {
|
|||||||
handleInputChange(setCycleLength, Number(e.target.value))
|
handleInputChange(setCycleLength, Number(e.target.value))
|
||||||
}
|
}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
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"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Typical range: 21-45 days
|
Typical range: 21-45 days
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,7 +228,7 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="notificationTime"
|
htmlFor="notificationTime"
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
Notification Time
|
Notification Time
|
||||||
</label>
|
</label>
|
||||||
@@ -232,10 +240,10 @@ export default function SettingsPage() {
|
|||||||
handleInputChange(setNotificationTime, e.target.value)
|
handleInputChange(setNotificationTime, e.target.value)
|
||||||
}
|
}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
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 [color-scheme:light] dark:[color-scheme:dark]"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Time to receive daily email notification
|
Time to receive daily email notification
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,7 +251,7 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="timezone"
|
htmlFor="timezone"
|
||||||
className="block text-sm font-medium text-gray-700"
|
className="block text-sm font-medium text-foreground"
|
||||||
>
|
>
|
||||||
Timezone
|
Timezone
|
||||||
</label>
|
</label>
|
||||||
@@ -253,15 +261,141 @@ export default function SettingsPage() {
|
|||||||
value={timezone}
|
value={timezone}
|
||||||
onChange={(e) => handleInputChange(setTimezone, e.target.value)}
|
onChange={(e) => handleInputChange(setTimezone, e.target.value)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
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"
|
||||||
placeholder="America/New_York"
|
placeholder="America/New_York"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
IANA timezone (e.g., America/New_York, Europe/London)
|
IANA timezone (e.g., America/New_York, Europe/London)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="pt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -273,8 +407,8 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-8 pt-8 border-t border-gray-200">
|
<div className="mt-8 pt-8 border-t border-input">
|
||||||
<h2 className="text-lg font-medium text-gray-900 mb-4">Account</h2>
|
<h2 className="text-lg font-medium text-foreground mb-4">Account</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
|
|||||||
@@ -238,4 +238,36 @@ describe("DayCell", () => {
|
|||||||
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
|
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("period indicator", () => {
|
||||||
|
it("shows period indicator dot on cycle day 1", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={1} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🩸")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows period indicator dot on cycle day 2", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={2} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🩸")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows period indicator dot on cycle day 3", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={3} phase="MENSTRUAL" />);
|
||||||
|
|
||||||
|
expect(screen.getByText("🩸")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show period indicator on cycle day 4", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={4} phase="FOLLICULAR" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show period indicator on cycle day 10", () => {
|
||||||
|
render(<DayCell {...baseProps} cycleDay={10} phase="FOLLICULAR" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("🩸")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function DayCell({
|
|||||||
}: DayCellProps) {
|
}: DayCellProps) {
|
||||||
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
|
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
|
||||||
|
|
||||||
|
const isPeriodDay = cycleDay >= 1 && cycleDay <= 3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -61,7 +63,10 @@ export function DayCell({
|
|||||||
data-day={dataDay}
|
data-day={dataDay}
|
||||||
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
|
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{date.getDate()}</span>
|
<span className="text-sm font-medium">
|
||||||
|
{date.getDate()}
|
||||||
|
{isPeriodDay && <span className="ml-0.5">🩸</span>}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
|
<span className="text-xs text-gray-500 block">Day {cycleDay}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -218,6 +218,18 @@ describe("MonthView", () => {
|
|||||||
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
expect(screen.getByText(/early luteal/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
expect(screen.getByText(/late luteal/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("displays phase emojis per spec", () => {
|
||||||
|
render(<MonthView {...baseProps} />);
|
||||||
|
|
||||||
|
// Spec requires: 🩸 Menstrual | 🌱 Follicular | 🌸 Ovulation | 🌙 Early Luteal | 🌑 Late Luteal
|
||||||
|
// Look for complete legend items to avoid matching period indicator emojis
|
||||||
|
expect(screen.getByText(/🩸 Menstrual/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌱 Follicular/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌸 Ovulation/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌙 Early Luteal/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/🌑 Late Luteal/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cycle rollover", () => {
|
describe("cycle rollover", () => {
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ interface MonthViewProps {
|
|||||||
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
const PHASE_LEGEND = [
|
const PHASE_LEGEND = [
|
||||||
{ name: "Menstrual", color: "bg-blue-100" },
|
{ name: "Menstrual", color: "bg-blue-100", emoji: "🩸" },
|
||||||
{ name: "Follicular", color: "bg-green-100" },
|
{ name: "Follicular", color: "bg-green-100", emoji: "🌱" },
|
||||||
{ name: "Ovulation", color: "bg-purple-100" },
|
{ name: "Ovulation", color: "bg-purple-100", emoji: "🌸" },
|
||||||
{ name: "Early Luteal", color: "bg-yellow-100" },
|
{ name: "Early Luteal", color: "bg-yellow-100", emoji: "🌙" },
|
||||||
{ name: "Late Luteal", color: "bg-red-100" },
|
{ name: "Late Luteal", color: "bg-red-100", emoji: "🌑" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getDaysInMonth(year: number, month: number): number {
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
@@ -228,7 +228,9 @@ export function MonthView({
|
|||||||
{PHASE_LEGEND.map((phase) => (
|
{PHASE_LEGEND.map((phase) => (
|
||||||
<div key={phase.name} className="flex items-center gap-1">
|
<div key={phase.name} className="flex items-center gap-1">
|
||||||
<div className={`w-4 h-4 rounded ${phase.color}`} />
|
<div className={`w-4 h-4 rounded ${phase.color}`} />
|
||||||
<span className="text-xs text-gray-600">{phase.name}</span>
|
<span className="text-xs text-gray-600">
|
||||||
|
{phase.emoji} {phase.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ describe("DataPanel", () => {
|
|||||||
it("renders HRV status", () => {
|
it("renders HRV status", () => {
|
||||||
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
||||||
|
|
||||||
expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument();
|
expect(screen.getByText(/HRV:/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders week intensity with phase limit", () => {
|
it("renders week intensity with phase limit", () => {
|
||||||
@@ -83,19 +84,42 @@ describe("DataPanel", () => {
|
|||||||
it("displays Balanced HRV status", () => {
|
it("displays Balanced HRV status", () => {
|
||||||
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
||||||
|
|
||||||
expect(screen.getByText(/HRV: Balanced/)).toBeInTheDocument();
|
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Balanced");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays Unbalanced HRV status", () => {
|
it("displays Unbalanced HRV status", () => {
|
||||||
render(<DataPanel {...baseProps} hrvStatus="Unbalanced" />);
|
render(<DataPanel {...baseProps} hrvStatus="Unbalanced" />);
|
||||||
|
|
||||||
expect(screen.getByText(/HRV: Unbalanced/)).toBeInTheDocument();
|
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Unbalanced");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays Unknown HRV status", () => {
|
it("displays Unknown HRV status", () => {
|
||||||
render(<DataPanel {...baseProps} hrvStatus="Unknown" />);
|
render(<DataPanel {...baseProps} hrvStatus="Unknown" />);
|
||||||
|
|
||||||
expect(screen.getByText(/HRV: Unknown/)).toBeInTheDocument();
|
expect(screen.getByTestId("hrv-status")).toHaveTextContent("Unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HRV color-coding", () => {
|
||||||
|
it("applies green color class for Balanced HRV", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Balanced" />);
|
||||||
|
|
||||||
|
const hrvElement = screen.getByTestId("hrv-status");
|
||||||
|
expect(hrvElement).toHaveClass("text-green-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies red color class for Unbalanced HRV", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Unbalanced" />);
|
||||||
|
|
||||||
|
const hrvElement = screen.getByTestId("hrv-status");
|
||||||
|
expect(hrvElement).toHaveClass("text-red-600");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies gray color class for Unknown HRV", () => {
|
||||||
|
render(<DataPanel {...baseProps} hrvStatus="Unknown" />);
|
||||||
|
|
||||||
|
const hrvElement = screen.getByTestId("hrv-status");
|
||||||
|
expect(hrvElement).toHaveClass("text-gray-500");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,10 +142,10 @@ describe("DataPanel", () => {
|
|||||||
expect(screen.getByText(/Remaining: 0 min/)).toBeInTheDocument();
|
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} />);
|
render(<DataPanel {...baseProps} remainingMinutes={-50} />);
|
||||||
|
|
||||||
expect(screen.getByText(/Remaining: -50 min/)).toBeInTheDocument();
|
expect(screen.getByText(/Goal exceeded by 50 min/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,4 +164,110 @@ describe("DataPanel", () => {
|
|||||||
expect(heading).toHaveClass("font-semibold");
|
expect(heading).toHaveClass("font-semibold");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("intensity progress bar", () => {
|
||||||
|
it("renders a progress bar for week intensity", () => {
|
||||||
|
render(<DataPanel {...baseProps} />);
|
||||||
|
|
||||||
|
const progressBar = screen.getByRole("progressbar");
|
||||||
|
expect(progressBar).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets progress bar aria-valuenow to weekIntensity", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={120} />);
|
||||||
|
|
||||||
|
const progressBar = screen.getByRole("progressbar");
|
||||||
|
expect(progressBar).toHaveAttribute("aria-valuenow", "120");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets progress bar aria-valuemax to phaseLimit", () => {
|
||||||
|
render(<DataPanel {...baseProps} phaseLimit={200} />);
|
||||||
|
|
||||||
|
const progressBar = screen.getByRole("progressbar");
|
||||||
|
expect(progressBar).toHaveAttribute("aria-valuemax", "200");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates correct width percentage for progress bar", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={100} phaseLimit={200} />);
|
||||||
|
|
||||||
|
const progressFill = screen.getByTestId("progress-fill");
|
||||||
|
expect(progressFill).toHaveStyle({ width: "50%" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps progress bar at 100% when over limit", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={250} phaseLimit={200} />);
|
||||||
|
|
||||||
|
const progressFill = screen.getByTestId("progress-fill");
|
||||||
|
expect(progressFill).toHaveStyle({ width: "100%" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows warning color when approaching limit (>80%)", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={170} phaseLimit={200} />);
|
||||||
|
|
||||||
|
const progressFill = screen.getByTestId("progress-fill");
|
||||||
|
expect(progressFill).toHaveClass("bg-yellow-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows danger color when over limit", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={210} phaseLimit={200} />);
|
||||||
|
|
||||||
|
const progressFill = screen.getByTestId("progress-fill");
|
||||||
|
expect(progressFill).toHaveClass("bg-red-500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows normal color when well below limit (<80%)", () => {
|
||||||
|
render(<DataPanel {...baseProps} weekIntensity={100} phaseLimit={200} />);
|
||||||
|
|
||||||
|
const progressFill = screen.getByTestId("progress-fill");
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ABOUTME: Dashboard panel showing biometric data.
|
// ABOUTME: Dashboard panel showing biometric data.
|
||||||
// ABOUTME: Displays body battery, HRV, and intensity minutes.
|
// ABOUTME: Displays body battery, HRV, and intensity minutes with visual indicators.
|
||||||
interface DataPanelProps {
|
interface DataPanelProps {
|
||||||
bodyBatteryCurrent: number | null;
|
bodyBatteryCurrent: number | null;
|
||||||
bodyBatteryYesterdayLow: number | null;
|
bodyBatteryYesterdayLow: number | null;
|
||||||
@@ -7,6 +7,47 @@ interface DataPanelProps {
|
|||||||
weekIntensity: number;
|
weekIntensity: number;
|
||||||
phaseLimit: number;
|
phaseLimit: number;
|
||||||
remainingMinutes: 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 {
|
||||||
|
switch (status) {
|
||||||
|
case "Balanced":
|
||||||
|
return "text-green-600";
|
||||||
|
case "Unbalanced":
|
||||||
|
return "text-red-600";
|
||||||
|
default:
|
||||||
|
return "text-gray-500";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressBarColorClass(percentage: number): string {
|
||||||
|
if (percentage > 100) {
|
||||||
|
return "bg-red-500";
|
||||||
|
}
|
||||||
|
if (percentage > 80) {
|
||||||
|
return "bg-yellow-500";
|
||||||
|
}
|
||||||
|
return "bg-green-500";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataPanel({
|
export function DataPanel({
|
||||||
@@ -16,18 +57,67 @@ export function DataPanel({
|
|||||||
weekIntensity,
|
weekIntensity,
|
||||||
phaseLimit,
|
phaseLimit,
|
||||||
remainingMinutes,
|
remainingMinutes,
|
||||||
|
lastSyncedAt,
|
||||||
}: DataPanelProps) {
|
}: 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 (
|
return (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<h3 className="font-semibold mb-4">YOUR DATA</h3>
|
<h3 className="font-semibold mb-4">YOUR DATA</h3>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li>Body Battery: {bodyBatteryCurrent ?? "N/A"}</li>
|
<li>Body Battery: {bodyBatteryCurrent ?? "N/A"}</li>
|
||||||
<li>Yesterday Low: {bodyBatteryYesterdayLow ?? "N/A"}</li>
|
<li>Yesterday Low: {bodyBatteryYesterdayLow ?? "N/A"}</li>
|
||||||
<li>HRV: {hrvStatus}</li>
|
<li>
|
||||||
|
HRV:{" "}
|
||||||
|
<span
|
||||||
|
data-testid="hrv-status"
|
||||||
|
className={getHrvColorClass(hrvStatus)}
|
||||||
|
>
|
||||||
|
{hrvStatus}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Week: {weekIntensity}/{phaseLimit} min
|
Week: {weekIntensity}/{phaseLimit} min
|
||||||
</li>
|
</li>
|
||||||
<li>Remaining: {remainingMinutes} min</li>
|
<li className="pt-1">
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={weekIntensity}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={phaseLimit}
|
||||||
|
aria-label="Week intensity progress"
|
||||||
|
className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="progress-fill"
|
||||||
|
className={`h-full rounded-full transition-all ${getProgressBarColorClass(intensityPercentage)}`}
|
||||||
|
style={{ width: `${displayPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,21 +12,27 @@ function getStatusColors(status: Decision["status"]): {
|
|||||||
} {
|
} {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "REST":
|
case "REST":
|
||||||
return { background: "bg-red-100 border-red-300", text: "text-red-700" };
|
return {
|
||||||
|
background:
|
||||||
|
"bg-red-100 dark:bg-red-900/50 border-red-300 dark:border-red-700",
|
||||||
|
text: "text-red-700 dark:text-red-300",
|
||||||
|
};
|
||||||
case "GENTLE":
|
case "GENTLE":
|
||||||
case "LIGHT":
|
case "LIGHT":
|
||||||
case "REDUCED":
|
case "REDUCED":
|
||||||
return {
|
return {
|
||||||
background: "bg-yellow-100 border-yellow-300",
|
background:
|
||||||
text: "text-yellow-700",
|
"bg-yellow-100 dark:bg-yellow-900/50 border-yellow-300 dark:border-yellow-700",
|
||||||
|
text: "text-yellow-700 dark:text-yellow-300",
|
||||||
};
|
};
|
||||||
case "TRAIN":
|
case "TRAIN":
|
||||||
return {
|
return {
|
||||||
background: "bg-green-100 border-green-300",
|
background:
|
||||||
text: "text-green-700",
|
"bg-green-100 dark:bg-green-900/50 border-green-300 dark:border-green-700",
|
||||||
|
text: "text-green-700 dark:text-green-300",
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return { background: "border", text: "text-gray-600" };
|
return { background: "border", text: "text-muted-foreground" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +40,10 @@ export function DecisionCard({ decision }: DecisionCardProps) {
|
|||||||
const colors = getStatusColors(decision.status);
|
const colors = getStatusColors(decision.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg p-6 ${colors.background}`}>
|
<div
|
||||||
|
data-testid="decision-card"
|
||||||
|
className={`rounded-lg p-6 ${colors.background}`}
|
||||||
|
>
|
||||||
<div className="text-4xl mb-2">{decision.icon}</div>
|
<div className="text-4xl mb-2">{decision.icon}</div>
|
||||||
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
<h2 className="text-2xl font-bold">{decision.status}</h2>
|
||||||
<p className={colors.text}>{decision.reason}</p>
|
<p className={colors.text}>{decision.reason}</p>
|
||||||
|
|||||||
@@ -14,21 +14,24 @@ interface MiniCalendarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PHASE_COLORS: Record<CyclePhase, string> = {
|
const PHASE_COLORS: Record<CyclePhase, string> = {
|
||||||
MENSTRUAL: "bg-blue-100",
|
MENSTRUAL: "bg-blue-100 dark:bg-blue-900/50 text-blue-900 dark:text-blue-100",
|
||||||
FOLLICULAR: "bg-green-100",
|
FOLLICULAR:
|
||||||
OVULATION: "bg-purple-100",
|
"bg-green-100 dark:bg-green-900/50 text-green-900 dark:text-green-100",
|
||||||
EARLY_LUTEAL: "bg-yellow-100",
|
OVULATION:
|
||||||
LATE_LUTEAL: "bg-red-100",
|
"bg-purple-100 dark:bg-purple-900/50 text-purple-900 dark:text-purple-100",
|
||||||
|
EARLY_LUTEAL:
|
||||||
|
"bg-yellow-100 dark:bg-yellow-900/50 text-yellow-900 dark:text-yellow-100",
|
||||||
|
LATE_LUTEAL: "bg-red-100 dark:bg-red-900/50 text-red-900 dark:text-red-100",
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPACT_DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
const COMPACT_DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
const PHASE_LEGEND = [
|
const PHASE_LEGEND = [
|
||||||
{ name: "Menstrual", color: "bg-blue-100" },
|
{ name: "Menstrual", color: "bg-blue-100 dark:bg-blue-900/50" },
|
||||||
{ name: "Follicular", color: "bg-green-100" },
|
{ name: "Follicular", color: "bg-green-100 dark:bg-green-900/50" },
|
||||||
{ name: "Ovulation", color: "bg-purple-100" },
|
{ name: "Ovulation", color: "bg-purple-100 dark:bg-purple-900/50" },
|
||||||
{ name: "Early Luteal", color: "bg-yellow-100" },
|
{ name: "Early Luteal", color: "bg-yellow-100 dark:bg-yellow-900/50" },
|
||||||
{ name: "Late Luteal", color: "bg-red-100" },
|
{ name: "Late Luteal", color: "bg-red-100 dark:bg-red-900/50" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getDaysInMonth(year: number, month: number): number {
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
@@ -102,7 +105,7 @@ export function MiniCalendar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePreviousMonth}
|
onClick={handlePreviousMonth}
|
||||||
className="p-1 hover:bg-gray-100 rounded text-sm"
|
className="p-1 hover:bg-muted rounded text-sm"
|
||||||
aria-label="Previous month"
|
aria-label="Previous month"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
@@ -117,7 +120,7 @@ export function MiniCalendar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTodayClick}
|
onClick={handleTodayClick}
|
||||||
className="px-2 py-0.5 text-xs border rounded hover:bg-gray-100"
|
className="px-2 py-0.5 text-xs border rounded hover:bg-muted"
|
||||||
>
|
>
|
||||||
Today
|
Today
|
||||||
</button>
|
</button>
|
||||||
@@ -125,7 +128,7 @@ export function MiniCalendar({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNextMonth}
|
onClick={handleNextMonth}
|
||||||
className="p-1 hover:bg-gray-100 rounded text-sm"
|
className="p-1 hover:bg-muted rounded text-sm"
|
||||||
aria-label="Next month"
|
aria-label="Next month"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
@@ -138,7 +141,7 @@ export function MiniCalendar({
|
|||||||
<div
|
<div
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: Day names are fixed and index is stable
|
// biome-ignore lint/suspicious/noArrayIndexKey: Day names are fixed and index is stable
|
||||||
key={`day-header-${index}`}
|
key={`day-header-${index}`}
|
||||||
className="text-center text-xs font-medium text-gray-500"
|
className="text-center text-xs font-medium text-muted-foreground"
|
||||||
>
|
>
|
||||||
{dayName}
|
{dayName}
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +168,7 @@ export function MiniCalendar({
|
|||||||
type="button"
|
type="button"
|
||||||
key={date.toISOString()}
|
key={date.toISOString()}
|
||||||
className={`p-1 text-xs rounded ${PHASE_COLORS[phase]} ${
|
className={`p-1 text-xs rounded ${PHASE_COLORS[phase]} ${
|
||||||
isToday ? "ring-2 ring-black font-bold" : ""
|
isToday ? "ring-2 ring-foreground font-bold" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
@@ -182,7 +185,7 @@ export function MiniCalendar({
|
|||||||
{PHASE_LEGEND.map((phase) => (
|
{PHASE_LEGEND.map((phase) => (
|
||||||
<div key={phase.name} className="flex items-center gap-0.5">
|
<div key={phase.name} className="flex items-center gap-0.5">
|
||||||
<div className={`w-2 h-2 rounded ${phase.color}`} />
|
<div className={`w-2 h-2 rounded ${phase.color}`} />
|
||||||
<span className="text-xs text-gray-600">{phase.name}</span>
|
<span className="text-xs text-muted-foreground">{phase.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,6 +116,52 @@ describe("NutritionPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("seed switch alert", () => {
|
||||||
|
it("displays seed switch alert when provided", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText("🌱 SWITCH TODAY! Start Sesame + Sunflower"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not display alert section when seedSwitchAlert is null", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not display alert section when seedSwitchAlert is undefined", () => {
|
||||||
|
render(<NutritionPanel nutrition={baseNutrition} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/SWITCH TODAY/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders alert with prominent styling", () => {
|
||||||
|
const nutrition: NutritionGuidance = {
|
||||||
|
...baseNutrition,
|
||||||
|
seedSwitchAlert: "🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<NutritionPanel nutrition={nutrition} />);
|
||||||
|
|
||||||
|
const alert = screen.getByText(
|
||||||
|
"🌱 SWITCH TODAY! Start Sesame + Sunflower",
|
||||||
|
);
|
||||||
|
expect(alert).toHaveClass("bg-amber-100", "dark:bg-amber-900");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("styling", () => {
|
describe("styling", () => {
|
||||||
it("renders within a bordered container", () => {
|
it("renders within a bordered container", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export function NutritionPanel({ nutrition }: NutritionPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
|
<h3 className="font-semibold mb-4">NUTRITION TODAY</h3>
|
||||||
|
{nutrition.seedSwitchAlert && (
|
||||||
|
<div className="mb-3 p-2 rounded bg-amber-100 dark:bg-amber-900 text-sm font-medium">
|
||||||
|
{nutrition.seedSwitchAlert}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li>🌱 {nutrition.seeds}</li>
|
<li>🌱 {nutrition.seeds}</li>
|
||||||
<li>🍽️ Carbs: {nutrition.carbRange}</li>
|
<li>🍽️ Carbs: {nutrition.carbRange}</li>
|
||||||
|
|||||||
291
src/components/ui/toaster.test.tsx
Normal file
291
src/components/ui/toaster.test.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
// ABOUTME: Unit tests for the Toaster component and toast utility functions.
|
||||||
|
// ABOUTME: Tests cover rendering, toast types, auto-dismiss behavior, and error persistence.
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { AUTO_DISMISS_DURATION, showToast, Toaster } from "./toaster";
|
||||||
|
|
||||||
|
describe("Toaster", () => {
|
||||||
|
// Clear any existing toasts between tests
|
||||||
|
beforeEach(() => {
|
||||||
|
// Render a fresh toaster for each test
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
|
||||||
|
// Mock setPointerCapture/releasePointerCapture for jsdom
|
||||||
|
// Sonner uses these for swipe gestures which jsdom doesn't support
|
||||||
|
if (!Element.prototype.setPointerCapture) {
|
||||||
|
Element.prototype.setPointerCapture = vi.fn();
|
||||||
|
}
|
||||||
|
if (!Element.prototype.releasePointerCapture) {
|
||||||
|
Element.prototype.releasePointerCapture = vi.fn();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders without crashing", () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
// Toaster renders a portal, so it won't have visible content initially
|
||||||
|
expect(document.body).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders in bottom-right position by default", () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
// Sonner creates an ol element for toasts with data-sonner-toaster attribute
|
||||||
|
const toaster = document.querySelector("[data-sonner-toaster]");
|
||||||
|
expect(toaster).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("showToast utility", () => {
|
||||||
|
it("shows success toast", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.success("Operation completed");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Operation completed")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error toast", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Something went wrong");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows info toast", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.info("Here is some information");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Here is some information"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast with custom message from spec examples", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Unable to fetch data. Retry?");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Unable to fetch data. Retry?"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast with Garmin sync message from spec", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Garmin data unavailable. Using last known values.");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Garmin data unavailable. Using last known values."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast with save error message from spec", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Failed to save. Try again.");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Failed to save. Try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toast duration configuration", () => {
|
||||||
|
it("exports AUTO_DISMISS_DURATION as 5000ms", () => {
|
||||||
|
expect(AUTO_DISMISS_DURATION).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("success toasts are configured with auto-dismiss duration", () => {
|
||||||
|
// We verify the implementation by checking the exported constant
|
||||||
|
// and trusting the sonner library to honor the duration
|
||||||
|
expect(AUTO_DISMISS_DURATION).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error toasts are configured to persist", async () => {
|
||||||
|
// Error toasts should use Infinity duration
|
||||||
|
// We verify by checking that the error toast API exists
|
||||||
|
expect(showToast.error).toBeDefined();
|
||||||
|
expect(typeof showToast.error).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiple toasts", () => {
|
||||||
|
it("can show multiple toasts at once", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.success("First toast");
|
||||||
|
showToast.error("Second toast");
|
||||||
|
showToast.info("Third toast");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("First toast")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Second toast")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Third toast")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toast styling", () => {
|
||||||
|
it("applies correct data-type attribute for success toasts", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.success("Styled success");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const toast = screen
|
||||||
|
.getByText("Styled success")
|
||||||
|
.closest("[data-sonner-toast]");
|
||||||
|
expect(toast).toHaveAttribute("data-type", "success");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies correct data-type attribute for error toasts", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Styled error");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const toast = screen
|
||||||
|
.getByText("Styled error")
|
||||||
|
.closest("[data-sonner-toast]");
|
||||||
|
expect(toast).toHaveAttribute("data-type", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies correct data-type attribute for info toasts", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.info("Styled info");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const toast = screen
|
||||||
|
.getByText("Styled info")
|
||||||
|
.closest("[data-sonner-toast]");
|
||||||
|
expect(toast).toHaveAttribute("data-type", "info");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
it("toast container has aria-live for screen readers", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.success("Accessible toast");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Accessible toast")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sonner uses aria-live on the section container for announcements
|
||||||
|
const section = document.querySelector("section[aria-live]");
|
||||||
|
expect(section).toHaveAttribute("aria-live", "polite");
|
||||||
|
expect(section).toHaveAttribute("aria-label");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toast container has aria-atomic for complete announcements", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Error for screen reader");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Error for screen reader")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The section should have aria-atomic for screen reader announcements
|
||||||
|
const section = document.querySelector("section[aria-live]");
|
||||||
|
expect(section).toHaveAttribute("aria-atomic");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toast dismissal", () => {
|
||||||
|
it("toasts have close button", async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Dismissible toast");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const toast = screen
|
||||||
|
.getByText("Dismissible toast")
|
||||||
|
.closest("[data-sonner-toast]");
|
||||||
|
expect(toast).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button should be rendered for all toasts
|
||||||
|
const closeButton = document.querySelector(
|
||||||
|
"[data-sonner-toast] button[data-close-button]",
|
||||||
|
);
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking close button dismisses toast", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Toaster />);
|
||||||
|
|
||||||
|
showToast.error("Toast to dismiss");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Toast to dismiss")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find and click the close button
|
||||||
|
const closeButton = document.querySelector(
|
||||||
|
"[data-sonner-toast] button[data-close-button]",
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
await user.click(closeButton);
|
||||||
|
|
||||||
|
// Wait for toast to be dismissed
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Toast to dismiss"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
{ timeout: 1000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("API surface", () => {
|
||||||
|
it("exports Toaster component", () => {
|
||||||
|
expect(Toaster).toBeDefined();
|
||||||
|
expect(typeof Toaster).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports showToast with success method", () => {
|
||||||
|
expect(showToast.success).toBeDefined();
|
||||||
|
expect(typeof showToast.success).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports showToast with error method", () => {
|
||||||
|
expect(showToast.error).toBeDefined();
|
||||||
|
expect(typeof showToast.error).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports showToast with info method", () => {
|
||||||
|
expect(showToast.info).toBeDefined();
|
||||||
|
expect(typeof showToast.info).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
src/components/ui/toaster.tsx
Normal file
76
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// ABOUTME: Toast notification component wrapping sonner for consistent user feedback.
|
||||||
|
// ABOUTME: Exports Toaster component and showToast utility for success/error/info messages.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Toaster as SonnerToaster, toast } from "sonner";
|
||||||
|
|
||||||
|
// Auto-dismiss duration in ms (5 seconds per spec)
|
||||||
|
export const AUTO_DISMISS_DURATION = 5000;
|
||||||
|
|
||||||
|
// Error duration - Infinity means persist until dismissed
|
||||||
|
const ERROR_PERSIST_DURATION = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toaster component - renders in bottom-right position per spec.
|
||||||
|
* Should be placed in the root layout to be available throughout the app.
|
||||||
|
*/
|
||||||
|
export function Toaster() {
|
||||||
|
return (
|
||||||
|
<SonnerToaster
|
||||||
|
position="bottom-right"
|
||||||
|
toastOptions={{
|
||||||
|
// Default duration for non-error toasts
|
||||||
|
duration: AUTO_DISMISS_DURATION,
|
||||||
|
// Add close button for all toasts
|
||||||
|
closeButton: true,
|
||||||
|
// Styling that works with dark mode
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-white group-[.toaster]:text-zinc-950 group-[.toaster]:border-zinc-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-zinc-950 dark:group-[.toaster]:text-zinc-50 dark:group-[.toaster]:border-zinc-800",
|
||||||
|
success:
|
||||||
|
"group-[.toaster]:border-green-500 group-[.toaster]:text-green-700 dark:group-[.toaster]:text-green-400",
|
||||||
|
error:
|
||||||
|
"group-[.toaster]:border-red-500 group-[.toaster]:text-red-700 dark:group-[.toaster]:text-red-400",
|
||||||
|
info: "group-[.toaster]:border-blue-500 group-[.toaster]:text-blue-700 dark:group-[.toaster]:text-blue-400",
|
||||||
|
closeButton:
|
||||||
|
"group-[.toast]:bg-zinc-100 group-[.toast]:text-zinc-500 group-[.toast]:border-zinc-200 dark:group-[.toast]:bg-zinc-800 dark:group-[.toast]:text-zinc-400 dark:group-[.toast]:border-zinc-700",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast utility functions for showing notifications.
|
||||||
|
* Use these instead of calling toast() directly for consistent behavior.
|
||||||
|
*/
|
||||||
|
export const showToast = {
|
||||||
|
/**
|
||||||
|
* Show a success toast that auto-dismisses after 5 seconds.
|
||||||
|
*/
|
||||||
|
success: (message: string) => {
|
||||||
|
toast.success(message, {
|
||||||
|
duration: AUTO_DISMISS_DURATION,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an error toast that persists until manually dismissed.
|
||||||
|
* Per spec: "Errors persist until dismissed"
|
||||||
|
*/
|
||||||
|
error: (message: string) => {
|
||||||
|
toast.error(message, {
|
||||||
|
duration: ERROR_PERSIST_DURATION,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show an info toast that auto-dismisses after 5 seconds.
|
||||||
|
*/
|
||||||
|
info: (message: string) => {
|
||||||
|
toast.info(message, {
|
||||||
|
duration: AUTO_DISMISS_DURATION,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
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: "",
|
garminOauth1Token: "",
|
||||||
garminOauth2Token: "",
|
garminOauth2Token: "",
|
||||||
garminTokenExpiresAt: new Date(),
|
garminTokenExpiresAt: new Date(),
|
||||||
|
garminRefreshTokenExpiresAt: null,
|
||||||
calendarToken: "cal-token",
|
calendarToken: "cal-token",
|
||||||
lastPeriodDate: new Date("2025-01-01"),
|
lastPeriodDate: new Date("2025-01-01"),
|
||||||
cycleLength: 31,
|
cycleLength: 31,
|
||||||
notificationTime: "07:00",
|
notificationTime: "07:00",
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
activeOverrides: [],
|
activeOverrides: [],
|
||||||
|
intensityGoalMenstrual: 75,
|
||||||
|
intensityGoalFollicular: 150,
|
||||||
|
intensityGoalOvulation: 100,
|
||||||
|
intensityGoalEarlyLuteal: 120,
|
||||||
|
intensityGoalLateLuteal: 50,
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
updated: new Date(),
|
updated: new Date(),
|
||||||
};
|
};
|
||||||
@@ -79,6 +85,16 @@ describe("withAuth", () => {
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to create mock request with headers
|
||||||
|
const createMockRequest = (
|
||||||
|
headers: Record<string, string | null> = {},
|
||||||
|
): NextRequest =>
|
||||||
|
({
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) => headers[name] ?? null),
|
||||||
|
},
|
||||||
|
}) as unknown as NextRequest;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockCookies.mockResolvedValue(mockCookieStore);
|
mockCookies.mockResolvedValue(mockCookieStore);
|
||||||
@@ -91,7 +107,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = createMockRequest();
|
||||||
const response = await wrappedHandler(mockRequest);
|
const response = await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
@@ -109,11 +125,16 @@ describe("withAuth", () => {
|
|||||||
.mockResolvedValue(NextResponse.json({ data: "success" }));
|
.mockResolvedValue(NextResponse.json({ data: "success" }));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = createMockRequest();
|
||||||
const response = await wrappedHandler(mockRequest);
|
const response = await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, undefined);
|
expect(handler).toHaveBeenCalledWith(
|
||||||
|
mockRequest,
|
||||||
|
mockUser,
|
||||||
|
mockPbClient,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads auth from cookies before checking authentication", async () => {
|
it("loads auth from cookies before checking authentication", async () => {
|
||||||
@@ -123,7 +144,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockCreatePocketBaseClient).toHaveBeenCalled();
|
expect(mockCreatePocketBaseClient).toHaveBeenCalled();
|
||||||
expect(mockCookies).toHaveBeenCalled();
|
expect(mockCookies).toHaveBeenCalled();
|
||||||
@@ -141,7 +162,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const response = await wrappedHandler({} as NextRequest);
|
const response = await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
expect(handler).not.toHaveBeenCalled();
|
expect(handler).not.toHaveBeenCalled();
|
||||||
@@ -154,12 +175,12 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
const handler = vi.fn().mockResolvedValue(NextResponse.json({}));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const mockRequest = {} as NextRequest;
|
const mockRequest = createMockRequest();
|
||||||
const mockParams = { id: "123" };
|
const mockParams = { id: "123" };
|
||||||
|
|
||||||
await wrappedHandler(mockRequest, { params: mockParams });
|
await wrappedHandler(mockRequest, { params: mockParams });
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, {
|
expect(handler).toHaveBeenCalledWith(mockRequest, mockUser, mockPbClient, {
|
||||||
params: mockParams,
|
params: mockParams,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -171,7 +192,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockRejectedValue(new Error("Handler error"));
|
const handler = vi.fn().mockRejectedValue(new Error("Handler error"));
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
const response = await wrappedHandler({} as NextRequest);
|
const response = await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(response.status).toBe(500);
|
expect(response.status).toBe(500);
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
@@ -191,7 +212,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "not_authenticated" }),
|
expect.objectContaining({ reason: "not_authenticated" }),
|
||||||
@@ -199,6 +220,76 @@ describe("withAuth", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with IP address from x-forwarded-for header", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) =>
|
||||||
|
name === "x-forwarded-for" ? "192.168.1.100" : null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
|
||||||
|
await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "not_authenticated",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
}),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with IP address from x-real-ip header when x-forwarded-for not present", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: {
|
||||||
|
get: vi.fn((name: string) =>
|
||||||
|
name === "x-real-ip" ? "10.0.0.1" : null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
|
||||||
|
await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "not_authenticated",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
}),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs auth failure with unknown IP when no IP headers present", async () => {
|
||||||
|
mockIsAuthenticated.mockReturnValue(false);
|
||||||
|
|
||||||
|
const handler = vi.fn();
|
||||||
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: {
|
||||||
|
get: vi.fn(() => null),
|
||||||
|
},
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
|
||||||
|
await wrappedHandler(mockRequest);
|
||||||
|
|
||||||
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ reason: "not_authenticated", ip: "unknown" }),
|
||||||
|
expect.stringContaining("Auth failure"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("logs auth failure when getCurrentUser returns null", async () => {
|
it("logs auth failure when getCurrentUser returns null", async () => {
|
||||||
mockIsAuthenticated.mockReturnValue(true);
|
mockIsAuthenticated.mockReturnValue(true);
|
||||||
mockGetCurrentUser.mockReturnValue(null);
|
mockGetCurrentUser.mockReturnValue(null);
|
||||||
@@ -206,7 +297,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "user_not_found" }),
|
expect.objectContaining({ reason: "user_not_found" }),
|
||||||
@@ -222,7 +313,7 @@ describe("withAuth", () => {
|
|||||||
const handler = vi.fn().mockRejectedValue(testError);
|
const handler = vi.fn().mockRejectedValue(testError);
|
||||||
const wrappedHandler = withAuth(handler);
|
const wrappedHandler = withAuth(handler);
|
||||||
|
|
||||||
await wrappedHandler({} as NextRequest);
|
await wrappedHandler(createMockRequest());
|
||||||
|
|
||||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import type PocketBase from "pocketbase";
|
||||||
|
|
||||||
import type { User } from "@/types";
|
import type { User } from "@/types";
|
||||||
|
|
||||||
@@ -16,28 +17,48 @@ import {
|
|||||||
} from "./pocketbase";
|
} from "./pocketbase";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route handler function type that receives the authenticated user.
|
* Route handler function type that receives the authenticated user and PocketBase client.
|
||||||
*/
|
*/
|
||||||
export type AuthenticatedHandler<T = unknown> = (
|
export type AuthenticatedHandler<T = unknown> = (
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
user: User,
|
user: User,
|
||||||
|
pb: PocketBase,
|
||||||
context?: { params?: T },
|
context?: { params?: T },
|
||||||
) => Promise<NextResponse>;
|
) => Promise<NextResponse>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Higher-order function that wraps an API route handler with authentication.
|
* Higher-order function that wraps an API route handler with authentication.
|
||||||
* Loads auth from cookies, validates the session, and passes the user to the handler.
|
* Loads auth from cookies, validates the session, and passes the user and
|
||||||
|
* authenticated PocketBase client to the handler.
|
||||||
*
|
*
|
||||||
* @param handler - The route handler that requires authentication
|
* @param handler - The route handler that requires authentication
|
||||||
* @returns A wrapped handler that checks auth before calling the original handler
|
* @returns A wrapped handler that checks auth before calling the original handler
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* export const GET = withAuth(async (request, user) => {
|
* export const GET = withAuth(async (request, user, pb) => {
|
||||||
|
* const data = await pb.collection("users").getOne(user.id);
|
||||||
* return NextResponse.json({ email: user.email });
|
* return NextResponse.json({ email: user.email });
|
||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Extracts client IP address from request headers.
|
||||||
|
* Checks x-forwarded-for and x-real-ip headers, returns "unknown" if neither present.
|
||||||
|
*/
|
||||||
|
function getClientIp(request: NextRequest): string {
|
||||||
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
||||||
|
if (forwardedFor) {
|
||||||
|
// x-forwarded-for can contain multiple IPs; first one is the client
|
||||||
|
return forwardedFor.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
const realIp = request.headers.get("x-real-ip");
|
||||||
|
if (realIp) {
|
||||||
|
return realIp;
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
export function withAuth<T = unknown>(
|
export function withAuth<T = unknown>(
|
||||||
handler: AuthenticatedHandler<T>,
|
handler: AuthenticatedHandler<T>,
|
||||||
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
|
): (request: NextRequest, context?: { params?: T }) => Promise<NextResponse> {
|
||||||
@@ -53,21 +74,24 @@ export function withAuth<T = unknown>(
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
loadAuthFromCookies(pb, cookieStore);
|
loadAuthFromCookies(pb, cookieStore);
|
||||||
|
|
||||||
|
// Get client IP for logging
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
|
||||||
// Check if the user is authenticated
|
// Check if the user is authenticated
|
||||||
if (!isAuthenticated(pb)) {
|
if (!isAuthenticated(pb)) {
|
||||||
logger.warn({ reason: "not_authenticated" }, "Auth failure");
|
logger.warn({ reason: "not_authenticated", ip }, "Auth failure");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current user
|
// Get the current user
|
||||||
const user = getCurrentUser(pb);
|
const user = getCurrentUser(pb);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.warn({ reason: "user_not_found" }, "Auth failure");
|
logger.warn({ reason: "user_not_found", ip }, "Auth failure");
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the original handler with the user context
|
// Call the original handler with the user context and authenticated pb client
|
||||||
return await handler(request, user, context);
|
return await handler(request, user, pb, context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Auth middleware error");
|
logger.error({ err: error }, "Auth middleware error");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -157,10 +157,11 @@ describe("getPhase", () => {
|
|||||||
|
|
||||||
describe("getPhaseLimit", () => {
|
describe("getPhaseLimit", () => {
|
||||||
it("returns correct weekly limits for each phase", () => {
|
it("returns correct weekly limits for each phase", () => {
|
||||||
expect(getPhaseLimit("MENSTRUAL")).toBe(30);
|
// Default intensity goals (can be overridden per user)
|
||||||
expect(getPhaseLimit("FOLLICULAR")).toBe(120);
|
expect(getPhaseLimit("MENSTRUAL")).toBe(75);
|
||||||
expect(getPhaseLimit("OVULATION")).toBe(80);
|
expect(getPhaseLimit("FOLLICULAR")).toBe(150);
|
||||||
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(100);
|
expect(getPhaseLimit("OVULATION")).toBe(100);
|
||||||
|
expect(getPhaseLimit("EARLY_LUTEAL")).toBe(120);
|
||||||
expect(getPhaseLimit("LATE_LUTEAL")).toBe(50);
|
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.
|
// Base phase configurations with weekly limits and training guidance.
|
||||||
// Note: The 'days' field is for the default 31-day cycle; actual boundaries
|
// Note: The 'days' field is for the default 31-day cycle; actual boundaries
|
||||||
// are calculated dynamically by getPhaseBoundaries() based on cycleLength.
|
// are calculated dynamically by getPhaseBoundaries() based on cycleLength.
|
||||||
|
// Weekly limits are defaults that can be overridden per user.
|
||||||
export const PHASE_CONFIGS: PhaseConfig[] = [
|
export const PHASE_CONFIGS: PhaseConfig[] = [
|
||||||
{
|
{
|
||||||
name: "MENSTRUAL",
|
name: "MENSTRUAL",
|
||||||
days: [1, 3],
|
days: [1, 3],
|
||||||
weeklyLimit: 30,
|
weeklyLimit: 75,
|
||||||
dailyAvg: 10,
|
dailyAvg: 11,
|
||||||
trainingType: "Gentle rebounding only",
|
trainingType: "Gentle rebounding only",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "FOLLICULAR",
|
name: "FOLLICULAR",
|
||||||
days: [4, 15],
|
days: [4, 15],
|
||||||
weeklyLimit: 120,
|
weeklyLimit: 150,
|
||||||
dailyAvg: 17,
|
dailyAvg: 21,
|
||||||
trainingType: "Strength + rebounding",
|
trainingType: "Strength + rebounding",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "OVULATION",
|
name: "OVULATION",
|
||||||
days: [16, 17],
|
days: [16, 17],
|
||||||
weeklyLimit: 80,
|
weeklyLimit: 100,
|
||||||
dailyAvg: 40,
|
dailyAvg: 50,
|
||||||
trainingType: "Peak performance",
|
trainingType: "Peak performance",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "EARLY_LUTEAL",
|
name: "EARLY_LUTEAL",
|
||||||
days: [18, 24],
|
days: [18, 24],
|
||||||
weeklyLimit: 100,
|
weeklyLimit: 120,
|
||||||
dailyAvg: 14,
|
dailyAvg: 17,
|
||||||
trainingType: "Moderate training",
|
trainingType: "Moderate training",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,3 +97,38 @@ export function getPhaseConfig(phase: CyclePhase): PhaseConfig {
|
|||||||
export function getPhaseLimit(phase: CyclePhase): number {
|
export function getPhaseLimit(phase: CyclePhase): number {
|
||||||
return getPhaseConfig(phase).weeklyLimit;
|
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,8 +127,54 @@ 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("getDecisionWithOverrides", () => {
|
||||||
describe("override types force REST", () => {
|
describe("override types force appropriate decisions", () => {
|
||||||
it("flare override forces REST", () => {
|
it("flare override forces REST", () => {
|
||||||
const data = createHealthyData();
|
const data = createHealthyData();
|
||||||
const overrides: OverrideType[] = ["flare"];
|
const overrides: OverrideType[] = ["flare"];
|
||||||
@@ -145,20 +191,20 @@ describe("getDecisionWithOverrides", () => {
|
|||||||
expect(result.reason).toContain("stress");
|
expect(result.reason).toContain("stress");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sleep override forces REST", () => {
|
it("sleep override forces GENTLE (per spec)", () => {
|
||||||
const data = createHealthyData();
|
const data = createHealthyData();
|
||||||
const overrides: OverrideType[] = ["sleep"];
|
const overrides: OverrideType[] = ["sleep"];
|
||||||
const result = getDecisionWithOverrides(data, overrides);
|
const result = getDecisionWithOverrides(data, overrides);
|
||||||
expect(result.status).toBe("REST");
|
expect(result.status).toBe("GENTLE");
|
||||||
expect(result.reason).toContain("sleep");
|
expect(result.reason).toContain("sleep");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pms override forces REST", () => {
|
it("pms override forces GENTLE (per spec)", () => {
|
||||||
const data = createHealthyData();
|
const data = createHealthyData();
|
||||||
const overrides: OverrideType[] = ["pms"];
|
const overrides: OverrideType[] = ["pms"];
|
||||||
const result = getDecisionWithOverrides(data, overrides);
|
const result = getDecisionWithOverrides(data, overrides);
|
||||||
expect(result.status).toBe("REST");
|
expect(result.status).toBe("GENTLE");
|
||||||
expect(result.reason).toContain("pms");
|
expect(result.reason).toContain("PMS");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,31 @@ import type { DailyData, Decision, OverrideType } from "@/types";
|
|||||||
// Override priority order - checked before algorithmic rules
|
// Override priority order - checked before algorithmic rules
|
||||||
const OVERRIDE_PRIORITY: OverrideType[] = ["flare", "stress", "sleep", "pms"];
|
const OVERRIDE_PRIORITY: OverrideType[] = ["flare", "stress", "sleep", "pms"];
|
||||||
|
|
||||||
const OVERRIDE_REASONS: Record<OverrideType, string> = {
|
// Override decisions per spec: flare/stress -> REST, sleep/pms -> GENTLE
|
||||||
flare: "Hashimoto's flare - rest required",
|
const OVERRIDE_DECISIONS: Record<
|
||||||
stress: "High stress override - rest required",
|
OverrideType,
|
||||||
sleep: "Poor sleep override - rest required",
|
{ status: "REST" | "GENTLE"; reason: string; icon: string }
|
||||||
pms: "pms override - rest required",
|
> = {
|
||||||
|
flare: {
|
||||||
|
status: "REST",
|
||||||
|
reason: "Hashimoto's flare - rest required",
|
||||||
|
icon: "🛑",
|
||||||
|
},
|
||||||
|
stress: {
|
||||||
|
status: "REST",
|
||||||
|
reason: "High stress override - rest required",
|
||||||
|
icon: "🛑",
|
||||||
|
},
|
||||||
|
sleep: {
|
||||||
|
status: "GENTLE",
|
||||||
|
reason: "Poor sleep override - gentle activity only",
|
||||||
|
icon: "🟡",
|
||||||
|
},
|
||||||
|
pms: {
|
||||||
|
status: "GENTLE",
|
||||||
|
reason: "PMS override - gentle activity only",
|
||||||
|
icon: "🟡",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTrainingDecision(data: DailyData): Decision {
|
export function getTrainingDecision(data: DailyData): Decision {
|
||||||
@@ -27,7 +47,7 @@ export function getTrainingDecision(data: DailyData): Decision {
|
|||||||
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
|
return { status: "REST", reason: "HRV Unbalanced", icon: "🛑" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bbYesterdayLow < 30) {
|
if (bbYesterdayLow !== null && bbYesterdayLow < 30) {
|
||||||
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
|
return { status: "REST", reason: "BB too depleted", icon: "🛑" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +75,7 @@ export function getTrainingDecision(data: DailyData): Decision {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bbCurrent < 75) {
|
if (bbCurrent !== null && bbCurrent < 75) {
|
||||||
return {
|
return {
|
||||||
status: "LIGHT",
|
status: "LIGHT",
|
||||||
reason: "Light activity only - BB not recovered",
|
reason: "Light activity only - BB not recovered",
|
||||||
@@ -63,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: "🟡" };
|
return { status: "REDUCED", reason: "Reduce intensity 25%", icon: "🟡" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +101,11 @@ export function getDecisionWithOverrides(
|
|||||||
// Check overrides first, in priority order: flare > stress > sleep > pms
|
// Check overrides first, in priority order: flare > stress > sleep > pms
|
||||||
for (const override of OVERRIDE_PRIORITY) {
|
for (const override of OVERRIDE_PRIORITY) {
|
||||||
if (overrides.includes(override)) {
|
if (overrides.includes(override)) {
|
||||||
|
const overrideDecision = OVERRIDE_DECISIONS[override];
|
||||||
const decision: Decision = {
|
const decision: Decision = {
|
||||||
status: "REST",
|
status: overrideDecision.status,
|
||||||
reason: OVERRIDE_REASONS[override],
|
reason: overrideDecision.reason,
|
||||||
icon: "🛑",
|
icon: overrideDecision.icon,
|
||||||
};
|
};
|
||||||
decisionEngineCallsTotal.inc({ decision: decision.status });
|
decisionEngineCallsTotal.inc({ decision: decision.status });
|
||||||
return decision;
|
return decision;
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
// ABOUTME: Unit tests for email sending utilities.
|
// 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";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { mockSend, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
const { mockCreate, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => ({
|
||||||
mockSend: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
mockCreate: vi.fn().mockResolvedValue({ id: "mock-email-id" }),
|
||||||
mockLoggerInfo: vi.fn(),
|
mockLoggerInfo: vi.fn(),
|
||||||
mockLoggerError: vi.fn(),
|
mockLoggerError: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the resend module before importing email utilities
|
// Mock the mailgun.js module before importing email utilities
|
||||||
vi.mock("resend", () => ({
|
vi.mock("mailgun.js", () => ({
|
||||||
Resend: class MockResend {
|
default: class MockMailgun {
|
||||||
emails = { send: mockSend };
|
client() {
|
||||||
|
return {
|
||||||
|
messages: { create: mockCreate },
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock form-data (required by mailgun.js)
|
||||||
|
vi.mock("form-data", () => ({
|
||||||
|
default: class MockFormData {},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock("@/lib/logger", () => ({
|
vi.mock("@/lib/logger", () => ({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -55,24 +64,26 @@ describe("sendDailyEmail", () => {
|
|||||||
ketoGuidance: "No - exit keto, need carbs for ovulation",
|
ketoGuidance: "No - exit keto, need carbs for ovulation",
|
||||||
};
|
};
|
||||||
|
|
||||||
it("sends email with correct subject line", async () => {
|
it("sends email with correct subject line per spec", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
// Mailgun create takes (domain, messageData) - check second param
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "Today's Training: 💪 TRAIN",
|
subject: "PhaseFlow: 💪 TRAIN - Day 15 (OVULATION)",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes cycle day and phase in email body", async () => {
|
it("includes cycle day and phase in email body", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
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)");
|
expect(call.text).toContain("📅 CYCLE DAY: 15 (OVULATION)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes decision icon and reason", async () => {
|
it("includes decision icon and reason", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain(
|
expect(call.text).toContain(
|
||||||
"💪 Body battery high, HRV balanced - great day for training!",
|
"💪 Body battery high, HRV balanced - great day for training!",
|
||||||
);
|
);
|
||||||
@@ -80,7 +91,7 @@ describe("sendDailyEmail", () => {
|
|||||||
|
|
||||||
it("includes biometric data in email body", async () => {
|
it("includes biometric data in email body", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
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("Body Battery Now: 85");
|
||||||
expect(call.text).toContain("Yesterday's Low: 45");
|
expect(call.text).toContain("Yesterday's Low: 45");
|
||||||
expect(call.text).toContain("HRV Status: Balanced");
|
expect(call.text).toContain("HRV Status: Balanced");
|
||||||
@@ -90,7 +101,7 @@ describe("sendDailyEmail", () => {
|
|||||||
|
|
||||||
it("includes nutrition guidance in email body", async () => {
|
it("includes nutrition guidance in email body", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain(
|
expect(call.text).toContain(
|
||||||
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
"🌱 SEEDS: Sesame (1-2 tbsp) + Sunflower (1-2 tbsp)",
|
||||||
);
|
);
|
||||||
@@ -107,25 +118,44 @@ describe("sendDailyEmail", () => {
|
|||||||
bodyBatteryYesterdayLow: null,
|
bodyBatteryYesterdayLow: null,
|
||||||
};
|
};
|
||||||
await sendDailyEmail(dataWithNulls);
|
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("Body Battery Now: N/A");
|
||||||
expect(call.text).toContain("Yesterday's Low: N/A");
|
expect(call.text).toContain("Yesterday's Low: N/A");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends email to correct recipient", async () => {
|
it("sends email to correct recipient", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
await sendDailyEmail(sampleData);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
// Mailgun uses an array for recipients
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "user@example.com",
|
to: ["user@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes auto-generated footer", async () => {
|
it("includes auto-generated footer", async () => {
|
||||||
await sendDailyEmail(sampleData);
|
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");
|
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 = mockCreate.mock.calls[0][1];
|
||||||
|
expect(call.text).toContain("🌱 SWITCH TODAY! Start Sesame + Sunflower");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include seed switch alert on other days", async () => {
|
||||||
|
const day10Data: DailyEmailData = {
|
||||||
|
...sampleData,
|
||||||
|
cycleDay: 10,
|
||||||
|
};
|
||||||
|
await sendDailyEmail(day10Data);
|
||||||
|
const call = mockCreate.mock.calls[0][1];
|
||||||
|
expect(call.text).not.toContain("SWITCH TODAY");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendPeriodConfirmationEmail", () => {
|
describe("sendPeriodConfirmationEmail", () => {
|
||||||
@@ -139,7 +169,8 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "🔵 Period Tracking Updated",
|
subject: "🔵 Period Tracking Updated",
|
||||||
}),
|
}),
|
||||||
@@ -152,7 +183,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
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
|
// Date formatting depends on locale, so check for key parts
|
||||||
expect(call.text).toContain("Your cycle has been reset");
|
expect(call.text).toContain("Your cycle has been reset");
|
||||||
expect(call.text).toContain("Last period:");
|
expect(call.text).toContain("Last period:");
|
||||||
@@ -164,7 +195,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
28,
|
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");
|
expect(call.text).toContain("Phase calendar updated for next 28 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,9 +205,10 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "test@example.com",
|
to: ["test@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -187,7 +219,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -197,7 +229,7 @@ describe("sendPeriodConfirmationEmail", () => {
|
|||||||
new Date("2025-01-15"),
|
new Date("2025-01-15"),
|
||||||
31,
|
31,
|
||||||
);
|
);
|
||||||
const call = mockSend.mock.calls[0][0];
|
const call = mockCreate.mock.calls[0][1];
|
||||||
expect(call.text).toContain(
|
expect(call.text).toContain(
|
||||||
"Your calendar will update automatically within 24 hours",
|
"Your calendar will update automatically within 24 hours",
|
||||||
);
|
);
|
||||||
@@ -212,7 +244,8 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
describe("14-day warning", () => {
|
describe("14-day warning", () => {
|
||||||
it("sends email with correct subject for 14-day warning", async () => {
|
it("sends email with correct subject for 14-day warning", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
subject: "⚠️ PhaseFlow: Garmin tokens expire in 14 days",
|
||||||
}),
|
}),
|
||||||
@@ -221,29 +254,30 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
|
|
||||||
it("sends to correct recipient", async () => {
|
it("sends to correct recipient", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
await sendTokenExpirationWarning("user@example.com", 14);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "user@example.com",
|
to: ["user@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes days until expiry in body", async () => {
|
it("includes days until expiry in body", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
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");
|
expect(call.text).toContain("14 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes instructions to refresh tokens", async () => {
|
it("includes instructions to refresh tokens", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
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("Settings");
|
||||||
expect(call.text).toContain("Garmin");
|
expect(call.text).toContain("Garmin");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes auto-generated footer", async () => {
|
it("includes auto-generated footer", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 14);
|
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");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -251,7 +285,8 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
describe("7-day warning", () => {
|
describe("7-day warning", () => {
|
||||||
it("sends email with urgent subject for 7-day warning", async () => {
|
it("sends email with urgent subject for 7-day warning", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
subject:
|
subject:
|
||||||
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
"🚨 PhaseFlow: Garmin tokens expire in 7 days - action required",
|
||||||
@@ -261,28 +296,29 @@ describe("sendTokenExpirationWarning", () => {
|
|||||||
|
|
||||||
it("sends to correct recipient", async () => {
|
it("sends to correct recipient", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
await sendTokenExpirationWarning("user@example.com", 7);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: "user@example.com",
|
to: ["user@example.com"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes days until expiry in body", async () => {
|
it("includes days until expiry in body", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
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");
|
expect(call.text).toContain("7 days");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses more urgent tone than 14-day warning", async () => {
|
it("uses more urgent tone than 14-day warning", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
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");
|
expect(call.text).toContain("urgent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes auto-generated footer", async () => {
|
it("includes auto-generated footer", async () => {
|
||||||
await sendTokenExpirationWarning("user@example.com", 7);
|
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");
|
expect(call.text).toContain("Auto-generated by PhaseFlow");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -327,12 +363,12 @@ describe("email structured logging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs email failed with error level on failure", async () => {
|
it("logs email failed with error level on failure", async () => {
|
||||||
const error = new Error("Resend API failed");
|
const error = new Error("Mailgun API failed");
|
||||||
mockSend.mockRejectedValueOnce(error);
|
mockCreate.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendDailyEmail(sampleDailyEmailData, "user-123"),
|
sendDailyEmail(sampleDailyEmailData, "user-123"),
|
||||||
).rejects.toThrow("Resend API failed");
|
).rejects.toThrow("Mailgun API failed");
|
||||||
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -364,8 +400,8 @@ describe("email structured logging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs email failed with error level on failure", async () => {
|
it("logs email failed with error level on failure", async () => {
|
||||||
const error = new Error("Resend API failed");
|
const error = new Error("Mailgun API failed");
|
||||||
mockSend.mockRejectedValueOnce(error);
|
mockCreate.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendPeriodConfirmationEmail(
|
sendPeriodConfirmationEmail(
|
||||||
@@ -374,7 +410,7 @@ describe("email structured logging", () => {
|
|||||||
31,
|
31,
|
||||||
"user-456",
|
"user-456",
|
||||||
),
|
),
|
||||||
).rejects.toThrow("Resend API failed");
|
).rejects.toThrow("Mailgun API failed");
|
||||||
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -401,12 +437,12 @@ describe("email structured logging", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("logs email failed with error level on failure", async () => {
|
it("logs email failed with error level on failure", async () => {
|
||||||
const error = new Error("Resend API failed");
|
const error = new Error("Mailgun API failed");
|
||||||
mockSend.mockRejectedValueOnce(error);
|
mockCreate.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
|
sendTokenExpirationWarning("user@example.com", 14, "user-789"),
|
||||||
).rejects.toThrow("Resend API failed");
|
).rejects.toThrow("Mailgun API failed");
|
||||||
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
// ABOUTME: Email sending utilities using Resend.
|
// ABOUTME: Email sending utilities using Mailgun.
|
||||||
// ABOUTME: Sends daily training notifications and period confirmation emails.
|
// 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 { logger } from "@/lib/logger";
|
||||||
import { emailSentTotal } from "@/lib/metrics";
|
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";
|
const EMAIL_FROM = process.env.EMAIL_FROM || "phaseflow@example.com";
|
||||||
|
|
||||||
export interface DailyEmailData {
|
export interface DailyEmailData {
|
||||||
@@ -33,7 +50,12 @@ export async function sendDailyEmail(
|
|||||||
data: DailyEmailData,
|
data: DailyEmailData,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const subject = `Today's Training: ${data.decision.icon} ${data.decision.status}`;
|
// Subject format per spec: PhaseFlow: [STATUS] - Day [cycleDay] ([phase])
|
||||||
|
const subject = `PhaseFlow: ${data.decision.icon} ${data.decision.status} - Day ${data.cycleDay} (${data.phase})`;
|
||||||
|
|
||||||
|
// Check for seed switch alert on day 15
|
||||||
|
const seedSwitchAlert = getSeedSwitchAlert(data.cycleDay);
|
||||||
|
const seedSwitchSection = seedSwitchAlert ? `\n\n${seedSwitchAlert}` : "";
|
||||||
|
|
||||||
const body = `Good morning!
|
const body = `Good morning!
|
||||||
|
|
||||||
@@ -52,15 +74,15 @@ ${data.decision.icon} ${data.decision.reason}
|
|||||||
🌱 SEEDS: ${data.seeds}
|
🌱 SEEDS: ${data.seeds}
|
||||||
|
|
||||||
🍽️ MACROS: ${data.carbRange}
|
🍽️ MACROS: ${data.carbRange}
|
||||||
🥑 KETO: ${data.ketoGuidance}
|
🥑 KETO: ${data.ketoGuidance}${seedSwitchSection}
|
||||||
|
|
||||||
---
|
---
|
||||||
Auto-generated by PhaseFlow`;
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to: data.to,
|
to: [data.to],
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
@@ -90,9 +112,9 @@ Your calendar will update automatically within 24 hours.
|
|||||||
Auto-generated by PhaseFlow`;
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to,
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
text: body,
|
||||||
});
|
});
|
||||||
@@ -135,9 +157,9 @@ This will ensure your training recommendations continue to use fresh Garmin data
|
|||||||
Auto-generated by PhaseFlow`;
|
Auto-generated by PhaseFlow`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await resend.emails.send({
|
await getMailgunClient().messages.create(MAILGUN_DOMAIN, {
|
||||||
from: EMAIL_FROM,
|
from: EMAIL_FROM,
|
||||||
to,
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
text: body,
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user