- Fix race conditions: Set workers: 1 since all tests share test user state - Fix stale data: GET /api/user and /api/cycle/current now fetch fresh data from database instead of returning stale PocketBase auth store cache - Fix timing: Replace waitForTimeout with retry-based Playwright assertions - Fix mobile test: Use exact heading match to avoid strict mode violation - Add test user setup: Include notificationTime and update rule for users All 1014 unit tests and 190 E2E tests pass. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
// 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 });
|
|
});
|
|
});
|
|
});
|