diff --git a/e2e/garmin.spec.ts b/e2e/garmin.spec.ts new file mode 100644 index 0000000..88a55a2 --- /dev/null +++ b/e2e/garmin.spec.ts @@ -0,0 +1,191 @@ +// 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"); + }); + + 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(); + }); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 36babd1..5cbb6e1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -54,6 +54,8 @@ export default defineConfig({ // 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", }, }, }); diff --git a/scripts/setup-db.ts b/scripts/setup-db.ts index 2f884b7..8761bb9 100644 --- a/scripts/setup-db.ts +++ b/scripts/setup-db.ts @@ -138,6 +138,57 @@ export const DAILY_LOGS_COLLECTION: CollectionDefinition = { */ const REQUIRED_COLLECTIONS = [PERIOD_LOGS_COLLECTION, DAILY_LOGS_COLLECTION]; +/** + * Custom fields to add to the users collection. + * These are required for Garmin integration and app functionality. + */ +const USER_CUSTOM_FIELDS = [ + { name: "garminConnected", type: "bool" }, + { name: "garminOauth1Token", type: "text" }, + { name: "garminOauth2Token", type: "text" }, + { name: "garminTokenExpiresAt", type: "date" }, + { name: "calendarToken", type: "text" }, + { name: "lastPeriodDate", type: "date" }, + { name: "cycleLength", type: "number" }, + { name: "notificationTime", type: "text" }, + { name: "timezone", type: "text" }, + { name: "activeOverrides", type: "json" }, +]; + +/** + * Adds custom fields to the users collection if they don't already exist. + * This is idempotent - safe to run multiple times. + */ +export async function addUserFields(pb: PocketBase): Promise { + const usersCollection = await pb.collections.getOne("users"); + + // Get existing field names + const existingFieldNames = new Set( + (usersCollection.fields || []).map((f: { name: string }) => f.name), + ); + + // Filter to only new fields + const newFields = USER_CUSTOM_FIELDS.filter( + (f) => !existingFieldNames.has(f.name), + ); + + if (newFields.length > 0) { + // Combine existing fields with new ones + const allFields = [...(usersCollection.fields || []), ...newFields]; + + await pb.collections.update(usersCollection.id, { + fields: allFields, + }); + + console.log( + ` Added ${newFields.length} field(s) to users:`, + newFields.map((f) => f.name), + ); + } else { + console.log(" All user fields already exist."); + } +} + /** * Gets the names of existing collections from PocketBase. */ @@ -242,6 +293,10 @@ async function main(): Promise { 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( diff --git a/src/app/api/garmin/status/route.ts b/src/app/api/garmin/status/route.ts index cfa27ea..5087ce4 100644 --- a/src/app/api/garmin/status/route.ts +++ b/src/app/api/garmin/status/route.ts @@ -8,7 +8,8 @@ import { daysUntilExpiry, isTokenExpired } from "@/lib/garmin"; export const GET = withAuth(async (_request, user, pb) => { // Fetch fresh user data from database (auth store cookie may be stale) const freshUser = await pb.collection("users").getOne(user.id); - const connected = freshUser.garminConnected; + // Use strict equality to handle undefined (field missing from schema) + const connected = freshUser.garminConnected === true; if (!connected) { return NextResponse.json({ diff --git a/src/app/api/garmin/tokens/route.test.ts b/src/app/api/garmin/tokens/route.test.ts index 3dabc7c..4a94058 100644 --- a/src/app/api/garmin/tokens/route.test.ts +++ b/src/app/api/garmin/tokens/route.test.ts @@ -12,10 +12,14 @@ let currentMockUser: User | null = null; // Track PocketBase update calls const mockPbUpdate = vi.fn().mockResolvedValue({}); +// Track PocketBase getOne calls - returns user with garminConnected: true after update +const mockPbGetOne = vi.fn().mockResolvedValue({ garminConnected: true }); + // Create mock PocketBase client const mockPb = { collection: vi.fn(() => ({ update: mockPbUpdate, + getOne: mockPbGetOne, })), }; diff --git a/src/app/api/garmin/tokens/route.ts b/src/app/api/garmin/tokens/route.ts index a773915..59dfeba 100644 --- a/src/app/api/garmin/tokens/route.ts +++ b/src/app/api/garmin/tokens/route.ts @@ -5,6 +5,7 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/lib/auth-middleware"; import { encrypt } from "@/lib/encryption"; import { daysUntilExpiry } from "@/lib/garmin"; +import { logger } from "@/lib/logger"; export const POST = withAuth(async (request, user, pb) => { const body = await request.json(); @@ -63,6 +64,26 @@ export const POST = withAuth(async (request, user, pb) => { garminConnected: true, }); + // 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 expiry const expiryDays = daysUntilExpiry({ oauth1: "",