// 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(); }); 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 }); }); }); });