// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2025 Pierre Fitness Intelligence
// ABOUTME: Playwright E2E tests for the login flow.
// ABOUTME: Tests successful login, error handling, and form accessibility.
import { test, expect } from '@playwright/test';
// Helper to set up common API mocks for the login page
async function setupBasicMocks(page: import('@playwright/test').Page) {
// Mock setup status - this must be set up BEFORE navigating
await page.route('**/admin/setup/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
needs_setup: false,
admin_user_exists: true,
message: 'Admin user configured',
}),
});
});
}
test.describe('Login Page', () => {
test('renders login form with all expected elements', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
// Wait for page to load - look for the form container
await page.waitForSelector('form', { timeout: 10000 });
// Check for main heading
const heading = page.locator('h1');
await expect(heading).toContainText('Pierre Fitness Platform');
// Check for form elements
await expect(page.locator('input[name="email"]')).toBeVisible();
await expect(page.locator('input[name="password"]')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
// When admin exists, no status indicator is shown - the form itself is the indicator
await expect(page.getByText('Setup Required')).not.toBeVisible();
});
test('renders Google Sign-In button when Firebase is configured', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
// Google Sign-In button is only visible when Firebase env vars are configured
// In CI without Firebase config, the button is hidden - this is expected behavior
const googleButton = page.getByRole('button', { name: /continue with google/i });
const isGoogleVisible = await googleButton.isVisible().catch(() => false);
if (isGoogleVisible) {
await expect(googleButton).toBeVisible();
await expect(page.getByText('or continue with')).toBeVisible();
} else {
// Firebase not configured - button should not be present
await expect(googleButton).not.toBeVisible();
await expect(page.getByText('or continue with')).not.toBeVisible();
}
});
test('Google Sign-In button shows loading state when clicked', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
// Skip this test if Firebase is not configured (Google button not visible)
const googleButton = page.getByRole('button', { name: /continue with google/i });
const isGoogleVisible = await googleButton.isVisible().catch(() => false);
if (!isGoogleVisible) {
// Firebase not configured in this environment - skip test
test.skip();
return;
}
await expect(googleButton).toBeEnabled();
// Click the button - it should show loading state
// Note: We can't fully test Firebase redirect flow in E2E, but we can test the UI response
await googleButton.click();
// Button should show "Signing in..." text while loading
await expect(page.getByRole('button', { name: /signing in/i })).toBeVisible({ timeout: 2000 });
});
test('allows typing in email and password fields', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
// Wait for form to be ready
await page.waitForSelector('form');
const emailInput = page.locator('input[name="email"]');
const passwordInput = page.locator('input[name="password"]');
await emailInput.fill('admin@test.com');
await passwordInput.fill('TestPassword123');
await expect(emailInput).toHaveValue('admin@test.com');
await expect(passwordInput).toHaveValue('TestPassword123');
});
test('toggles password visibility when clicking eye icon', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
const passwordInput = page.locator('input[name="password"]');
await passwordInput.fill('TestPassword123');
// Password should be hidden by default
await expect(passwordInput).toHaveAttribute('type', 'password');
// Click the toggle button (button inside the password field container)
const toggleButton = page.locator('button[type="button"]').first();
await toggleButton.click();
// Password should now be visible
await expect(passwordInput).toHaveAttribute('type', 'text');
// Click again to hide
await toggleButton.click();
await expect(passwordInput).toHaveAttribute('type', 'password');
});
test('shows loading state during form submission', async ({ page }) => {
await setupBasicMocks(page);
// Mock a slow OAuth2 ROPC login response
await page.route('**/oauth/token', async (route) => {
// Delay the response to observe loading state
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'test-jwt-token',
token_type: 'Bearer',
expires_in: 86400,
csrf_token: 'test-csrf-token',
user: { id: '1', email: 'admin@test.com', is_admin: true },
}),
});
});
await page.goto('/');
await page.waitForSelector('form');
await page.locator('input[name="email"]').fill('admin@test.com');
await page.locator('input[name="password"]').fill('TestPassword123');
// Click submit and check for loading state
const submitButton = page.getByRole('button', { name: 'Sign in' });
await submitButton.click();
// Should show loading text
await expect(page.getByRole('button', { name: 'Signing in...' })).toBeVisible();
});
test('successful login shows dashboard', async ({ page }) => {
await setupBasicMocks(page);
// Track login state
let hasLoggedIn = false;
// Mock successful OAuth2 ROPC login
await page.route('**/oauth/token', async (route) => {
hasLoggedIn = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'test-jwt-token',
token_type: 'Bearer',
expires_in: 86400,
csrf_token: 'test-csrf-token',
user: { id: '1', email: 'admin@test.com', display_name: 'Admin', is_admin: true, user_status: 'active', role: 'admin' },
}),
});
});
// Mock /api/auth/me to return authenticated state after login
await page.route('**/api/auth/me', async (route) => {
if (hasLoggedIn) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: '1',
email: 'admin@test.com',
display_name: 'Admin',
is_admin: true,
role: 'admin',
}),
});
} else {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'Not authenticated' }),
});
}
});
// Mock dashboard overview for when Dashboard loads
await page.route('**/api/dashboard/overview**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
total_api_keys: 5,
active_api_keys: 3,
total_requests_today: 100,
total_requests_this_month: 2500,
}),
});
});
await page.goto('/');
await page.waitForSelector('form');
await page.locator('input[name="email"]').fill('admin@test.com');
await page.locator('input[name="password"]').fill('TestPassword123');
await page.getByRole('button', { name: 'Sign in' }).click();
// After successful login, the Login form should no longer be visible
// (Dashboard is shown instead based on isAuthenticated state)
await expect(page.locator('input[name="email"]')).not.toBeVisible({ timeout: 10000 });
});
test('displays error message on login failure', async ({ page }) => {
await setupBasicMocks(page);
// Mock failed OAuth2 ROPC login with error response
await page.route('**/oauth/token', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
error: 'invalid_grant',
error_description: 'Invalid email or password',
}),
});
});
await page.goto('/');
await page.waitForSelector('form');
await page.locator('input[name="email"]').fill('admin@test.com');
await page.locator('input[name="password"]').fill('WrongPassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait a moment for the request to complete
await page.waitForTimeout(2000);
// The button should return to "Sign in" state (not "Signing in...")
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({ timeout: 5000 });
// Verify we're still on the login page (not redirected to dashboard)
await expect(page.locator('input[name="email"]')).toBeVisible();
// Check for error message - the Login component sets error and shows in bg-red-50 div
// If no error displayed, that's ok as long as we didn't redirect
const errorElement = page.locator('.bg-red-50');
const hasError = await errorElement.isVisible().catch(() => false);
if (hasError) {
await expect(errorElement).toContainText(/Invalid|failed/i);
}
});
test('displays generic error for network failures', async ({ page }) => {
await setupBasicMocks(page);
// Mock network error
await page.route('**/oauth/token', async (route) => {
await route.abort('failed');
});
await page.goto('/');
await page.waitForSelector('form');
await page.locator('input[name="email"]').fill('admin@test.com');
await page.locator('input[name="password"]').fill('TestPassword123');
await page.getByRole('button', { name: 'Sign in' }).click();
// Should display generic error message
await expect(page.getByText('Login failed')).toBeVisible();
});
test('validates required fields before submission', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
const submitButton = page.getByRole('button', { name: 'Sign in' });
// Try to submit with empty fields - HTML5 validation should prevent submission
await submitButton.click();
// Form should still be visible (not submitted)
await expect(page.locator('input[name="email"]')).toBeVisible();
await expect(submitButton).toBeVisible();
});
});
test.describe('Login Page - Basic Rendering', () => {
test('shows login form without setup status indicators', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
// Login form should be visible immediately without setup status indicators
await expect(page.getByText('Setup Required')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
});
test.describe('Login Page - Accessibility', () => {
test('form fields have proper labels and IDs', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
// Check that labels are properly associated with inputs
const emailInput = page.locator('input[name="email"]');
const passwordInput = page.locator('input[name="password"]');
await expect(emailInput).toHaveAttribute('id', 'email');
await expect(passwordInput).toHaveAttribute('id', 'password');
});
test('form can be navigated with keyboard', async ({ page }) => {
await setupBasicMocks(page);
await page.goto('/');
await page.waitForSelector('form');
// Focus on email field
const emailInput = page.locator('input[name="email"]');
await emailInput.focus();
await expect(emailInput).toBeFocused();
// Tab to password field
await page.keyboard.press('Tab');
const passwordInput = page.locator('input[name="password"]');
await expect(passwordInput).toBeFocused();
});
test('submit button shows disabled state when loading', async ({ page }) => {
await setupBasicMocks(page);
await page.route('**/oauth/token', async (route) => {
// Very slow response to observe disabled state
await new Promise((resolve) => setTimeout(resolve, 3000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
access_token: 'test-jwt-token',
token_type: 'Bearer',
expires_in: 86400,
csrf_token: 'test-csrf-token',
user: { id: '1', email: 'admin@test.com', is_admin: true },
}),
});
});
await page.goto('/');
await page.waitForSelector('form');
await page.locator('input[name="email"]').fill('admin@test.com');
await page.locator('input[name="password"]').fill('TestPassword123');
const submitButton = page.getByRole('button', { name: 'Sign in' });
await submitButton.click();
// Button should be disabled during loading
await expect(page.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
});
});