diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts index d907521..ff203bc 100644 --- a/frontend/e2e/helpers/auth.ts +++ b/frontend/e2e/helpers/auth.ts @@ -38,6 +38,7 @@ export const MOCK_SESSION = { /** * Set up API mocking for authenticated E2E tests * Intercepts backend API calls and returns mock data + * Routes persist across client-side navigation * * @param page Playwright page object */ @@ -45,20 +46,18 @@ export async function setupAuthenticatedMocks(page: Page): Promise { const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'; // Mock GET /api/v1/users/me - Get current user - await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - success: true, - data: MOCK_USER, - }), - }); - }); - // Mock PATCH /api/v1/users/me - Update user profile await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => { - if (route.request().method() === 'PATCH') { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: MOCK_USER, + }), + }); + } else if (route.request().method() === 'PATCH') { const postData = route.request().postDataJSON(); await route.fulfill({ status: 200, @@ -93,7 +92,9 @@ export async function setupAuthenticatedMocks(page: Page): Promise { contentType: 'application/json', body: JSON.stringify({ success: true, - data: [MOCK_SESSION], + data: { + sessions: [MOCK_SESSION], + }, }), }); } else { @@ -117,34 +118,15 @@ export async function setupAuthenticatedMocks(page: Page): Promise { } }); - // Inject mock auth store BEFORE navigation - // This must happen before the page loads to ensure AuthProvider picks it up + // Inject mock auth store that persists across navigation + // This creates a mock Zustand store accessible via window.__TEST_AUTH_STORE__ + // CRITICAL: Must be set BEFORE React renders to be picked up by AuthProvider await page.addInitScript((mockUser) => { - // Create a mock Zustand hook that returns our mocked auth state - const mockAuthStore: any = (selector?: any) => { - const state = { - user: mockUser, - accessToken: 'mock-access-token', - refreshToken: 'mock-refresh-token', - isAuthenticated: true, - isLoading: false, - tokenExpiresAt: Date.now() + 900000, // 15 minutes from now - // Mock action functions - setAuth: async () => {}, - setTokens: async () => {}, - setUser: () => {}, - clearAuth: async () => {}, - loadAuthFromStorage: async () => {}, - isTokenExpired: () => false, - }; - return selector ? selector(state) : state; - }; - - // Add getState method for non-React contexts (API client, etc.) - mockAuthStore.getState = () => ({ + // Create a stable state object that persists + const authState = { user: mockUser, - accessToken: 'mock-access-token', - refreshToken: 'mock-refresh-token', + accessToken: 'mock.access.token', // Valid JWT format (3 parts) + refreshToken: 'mock.refresh.token', isAuthenticated: true, isLoading: false, tokenExpiresAt: Date.now() + 900000, @@ -152,11 +134,32 @@ export async function setupAuthenticatedMocks(page: Page): Promise { setTokens: async () => {}, setUser: () => {}, clearAuth: async () => {}, - loadAuthFromStorage: async () => {}, + loadAuthFromStorage: async () => { + // No-op in tests - state is already set + }, isTokenExpired: () => false, - }); + }; - // Inject into window for AuthProvider to pick up + // Mock Zustand hook - must support both selector and no-selector calls + const mockAuthStore: any = (selector?: any) => { + // If selector provided, call it with the state + if (selector && typeof selector === 'function') { + return selector(authState); + } + // Otherwise return the full state + return authState; + }; + + // Add getState method that Zustand stores have + mockAuthStore.getState = () => authState; + + // Add subscribe method (required by Zustand) + mockAuthStore.subscribe = () => () => {}; // Returns unsubscribe function + + // Make it globally available for AuthProvider (window as any).__TEST_AUTH_STORE__ = mockAuthStore; + + // Also set a flag to indicate we're in a test environment + (window as any).__E2E_TEST__ = true; }, MOCK_USER); } diff --git a/frontend/e2e/settings-navigation.spec.ts b/frontend/e2e/settings-navigation.spec.ts index 6c85eb2..95e5f7e 100644 --- a/frontend/e2e/settings-navigation.spec.ts +++ b/frontend/e2e/settings-navigation.spec.ts @@ -11,9 +11,15 @@ test.describe('Settings Navigation', () => { // Set up API mocks for authenticated user await setupAuthenticatedMocks(page); + // Delay to ensure auth store injection completes before navigation + await page.waitForTimeout(200); + // Navigate to settings await page.goto('/settings/profile'); await expect(page).toHaveURL('/settings/profile'); + + // Wait for page to fully load with auth context + await page.waitForSelector('h2:has-text("Profile")', { timeout: 10000 }); }); test('should display settings tabs', async ({ page }) => { @@ -48,7 +54,7 @@ test.describe('Settings Navigation', () => { ]); await expect(page).toHaveURL('/settings/password'); - await expect(page.locator('h2')).toContainText(/Change Password/i); + await expect(page.locator('h2')).toContainText(/Password Settings/i); }); test('should navigate from Profile to Sessions', async ({ page }) => { @@ -95,7 +101,7 @@ test.describe('Settings Navigation', () => { ]); await expect(page).toHaveURL('/settings/password'); - await expect(page.locator('h2')).toContainText(/Change Password/i); + await expect(page.locator('h2')).toContainText(/Password Settings/i); }); test('should maintain layout when navigating between tabs', async ({ page }) => { diff --git a/frontend/e2e/settings-password.spec.ts b/frontend/e2e/settings-password.spec.ts index 4527728..cf82045 100644 --- a/frontend/e2e/settings-password.spec.ts +++ b/frontend/e2e/settings-password.spec.ts @@ -11,9 +11,15 @@ test.describe('Password Change', () => { // Set up API mocks for authenticated user await setupAuthenticatedMocks(page); + // Delay to ensure auth store injection completes before navigation + await page.waitForTimeout(200); + // Navigate to password settings await page.goto('/settings/password'); await expect(page).toHaveURL('/settings/password'); + + // Wait for page to fully load with auth context + await page.waitForSelector('h2', { timeout: 10000 }); }); test('should display password change page', async ({ page }) => { @@ -53,28 +59,27 @@ test.describe('Password Change', () => { test('should show cancel button when form is dirty', async ({ page }) => { await page.waitForSelector('#current_password'); - // Fill current password - await page.locator('#current_password').fill('Admin123!'); + // Fill current password and blur to trigger dirty state + const currentPasswordInput = page.locator('#current_password'); + await currentPasswordInput.fill('Admin123!'); + await currentPasswordInput.blur(); - // Wait for form state to update - await page.waitForTimeout(100); - - // Cancel button should appear + // Cancel button should appear when form is dirty const cancelButton = page.locator('button[type="button"]:has-text("Cancel")'); - await expect(cancelButton).toBeVisible(); + await expect(cancelButton).toBeVisible({ timeout: 3000 }); }); test('should clear form when cancel button is clicked', async ({ page }) => { await page.waitForSelector('#current_password'); - // Fill fields - await page.locator('#current_password').fill('Admin123!'); + // Fill fields and blur to trigger dirty state + const currentPasswordInput = page.locator('#current_password'); + await currentPasswordInput.fill('Admin123!'); + await currentPasswordInput.blur(); + await page.locator('#new_password').fill('NewAdmin123!'); - // Wait for form state to update - await page.waitForTimeout(100); - - // Click cancel + // Click cancel button const cancelButton = page.locator('button[type="button"]:has-text("Cancel")'); await cancelButton.click(); diff --git a/frontend/e2e/settings-profile.spec.ts b/frontend/e2e/settings-profile.spec.ts index d31986b..2bfa0d4 100644 --- a/frontend/e2e/settings-profile.spec.ts +++ b/frontend/e2e/settings-profile.spec.ts @@ -11,9 +11,15 @@ test.describe('Profile Settings', () => { // Set up API mocks for authenticated user await setupAuthenticatedMocks(page); + // Delay to ensure auth store injection completes before navigation + await page.waitForTimeout(200); + // Navigate to profile settings await page.goto('/settings/profile'); await expect(page).toHaveURL('/settings/profile'); + + // Wait for page to fully load with auth context + await page.waitForSelector('h2:has-text("Profile")', { timeout: 10000 }); }); test('should display profile settings page', async ({ page }) => { @@ -79,14 +85,15 @@ test.describe('Profile Settings', () => { return input && input.value !== ''; }, { timeout: 5000 }); - // Modify first name + // Modify first name and blur to trigger dirty state const firstNameInput = page.locator('#first_name'); await firstNameInput.clear(); await firstNameInput.fill('TestUser'); + await firstNameInput.blur(); - // Reset button should appear + // Reset button should appear when form is dirty const resetButton = page.locator('button[type="button"]:has-text("Reset")'); - await expect(resetButton).toBeVisible(); + await expect(resetButton).toBeVisible({ timeout: 3000 }); }); test('should reset form when reset button is clicked', async ({ page }) => { @@ -102,9 +109,10 @@ test.describe('Profile Settings', () => { const firstNameInput = page.locator('#first_name'); const originalValue = await firstNameInput.inputValue(); - // Modify first name + // Modify first name and blur to trigger dirty state await firstNameInput.clear(); await firstNameInput.fill('TestUser'); + await firstNameInput.blur(); await expect(firstNameInput).toHaveValue('TestUser'); // Click reset diff --git a/frontend/e2e/settings-sessions.spec.ts b/frontend/e2e/settings-sessions.spec.ts index 8e65cf9..998c6b7 100644 --- a/frontend/e2e/settings-sessions.spec.ts +++ b/frontend/e2e/settings-sessions.spec.ts @@ -11,9 +11,15 @@ test.describe('Sessions Management', () => { // Set up API mocks for authenticated user await setupAuthenticatedMocks(page); + // Delay to ensure auth store injection completes before navigation + await page.waitForTimeout(200); + // Navigate to sessions settings await page.goto('/settings/sessions'); await expect(page).toHaveURL('/settings/sessions'); + + // Wait for page to fully load with auth context + await page.waitForSelector('h2', { timeout: 10000 }); }); test('should display sessions management page', async ({ page }) => { @@ -132,9 +138,15 @@ test.describe('Sessions Management - Revocation', () => { // Set up API mocks for authenticated user await setupAuthenticatedMocks(page); + // Delay to ensure auth store injection completes before navigation + await page.waitForTimeout(200); + // Navigate to sessions settings await page.goto('/settings/sessions'); await expect(page).toHaveURL('/settings/sessions'); + + // Wait for page to fully load with auth context + await page.waitForSelector('h2', { timeout: 10000 }); }); test('should show confirmation dialog before individual revocation', async ({ page }) => { diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index cc8411d..3a5f004 100755 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -24,6 +24,21 @@ const eslintConfig = [ "**/*.gen.tsx", ], }, + { + rules: { + // Enforce Dependency Injection pattern for auth store + // Components/hooks must use useAuth() from AuthContext, not useAuthStore directly + // This ensures testability via DI (E2E mocks, unit test props) + // Exception: Non-React contexts (client.ts) use dynamic import + __TEST_AUTH_STORE__ check + "no-restricted-imports": ["error", { + "patterns": [{ + "group": ["**/stores/authStore"], + "importNames": ["useAuthStore"], + "message": "Import useAuth from '@/lib/auth/AuthContext' instead. Direct authStore imports bypass dependency injection and break test mocking." + }] + }] + } + } ]; export default eslintConfig; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index b0bd86d..21b586a 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -17,8 +17,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI and locally to handle flaky tests */ retries: process.env.CI ? 2 : 1, - /* Limit workers to prevent test interference and Next dev server overload */ - workers: process.env.CI ? 1 : 8, + /* Use 1 worker to prevent test interference (parallel execution causes auth mock conflicts) */ + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: process.env.CI ? 'github' : 'list', /* Suppress console output unless VERBOSE=true */ diff --git a/frontend/src/components/auth/AuthInitializer.tsx b/frontend/src/components/auth/AuthInitializer.tsx index 81b6072..736476d 100644 --- a/frontend/src/components/auth/AuthInitializer.tsx +++ b/frontend/src/components/auth/AuthInitializer.tsx @@ -7,7 +7,7 @@ 'use client'; import { useEffect } from 'react'; -import { useAuthStore } from '@/lib/stores/authStore'; +import { useAuth } from '@/lib/auth/AuthContext'; /** * AuthInitializer - Initializes auth state from encrypted storage on mount @@ -15,6 +15,9 @@ import { useAuthStore } from '@/lib/stores/authStore'; * This component should be included in the app's Providers to ensure * authentication state is restored from storage when the app loads. * + * IMPORTANT: Uses useAuth() to respect dependency injection for testability. + * Do NOT import useAuthStore directly - it bypasses the Context wrapper. + * * @example * ```tsx * // In app/providers.tsx @@ -29,9 +32,14 @@ import { useAuthStore } from '@/lib/stores/authStore'; * ``` */ export function AuthInitializer() { - const loadAuthFromStorage = useAuthStore((state) => state.loadAuthFromStorage); + const loadAuthFromStorage = useAuth((state) => state.loadAuthFromStorage); useEffect(() => { + // Skip loading from storage in E2E tests - test store is already injected + if (typeof window !== 'undefined' && (window as any).__E2E_TEST__) { + return; + } + // Load auth state from encrypted storage on mount loadAuthFromStorage(); }, [loadAuthFromStorage]); diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 31cab9d..7a0dde1 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -35,13 +35,16 @@ let refreshPromise: Promise | null = null; /* istanbul ignore next */ const getAuthStore = async () => { // Check for E2E test store injection (same pattern as AuthProvider) + // eslint-disable-next-line @typescript-eslint/no-explicit-any if (typeof window !== 'undefined' && (window as any).__TEST_AUTH_STORE__) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const testStore = (window as any).__TEST_AUTH_STORE__; // Test store must have getState() method for non-React contexts return testStore.getState(); } // Production: use real Zustand store + // Note: Dynamic import is acceptable here (non-React context, checks __TEST_AUTH_STORE__ first) const { useAuthStore } = await import('@/lib/stores/authStore'); return useAuthStore.getState(); }; diff --git a/frontend/src/lib/api/hooks/useAuth.ts b/frontend/src/lib/api/hooks/useAuth.ts index 054e8ec..5a83d96 100755 --- a/frontend/src/lib/api/hooks/useAuth.ts +++ b/frontend/src/lib/api/hooks/useAuth.ts @@ -20,7 +20,6 @@ import { confirmPasswordReset, changeCurrentUserPassword, } from '../client'; -import { useAuthStore } from '@/lib/stores/authStore'; import type { User } from '@/lib/stores/authStore'; import { useAuth } from '@/lib/auth/AuthContext'; import { parseAPIError, getGeneralError } from '../errors'; @@ -50,8 +49,8 @@ export const authKeys = { * @returns React Query result with user data */ export function useMe() { - const { isAuthenticated, accessToken } = useAuthStore(); - const setUser = useAuthStore((state) => state.setUser); + const { isAuthenticated, accessToken } = useAuth(); + const setUser = useAuth((state) => state.setUser); const query = useQuery({ queryKey: authKeys.me, @@ -95,7 +94,7 @@ export function useMe() { export function useLogin(onSuccess?: () => void) { const router = useRouter(); const queryClient = useQueryClient(); - const setAuth = useAuthStore((state) => state.setAuth); + const setAuth = useAuth((state) => state.setAuth); return useMutation({ mutationFn: async (credentials: { email: string; password: string }) => { @@ -163,7 +162,7 @@ export function useLogin(onSuccess?: () => void) { export function useRegister(onSuccess?: () => void) { const router = useRouter(); const queryClient = useQueryClient(); - const setAuth = useAuthStore((state) => state.setAuth); + const setAuth = useAuth((state) => state.setAuth); return useMutation({ mutationFn: async (data: { @@ -240,8 +239,8 @@ export function useRegister(onSuccess?: () => void) { export function useLogout() { const router = useRouter(); const queryClient = useQueryClient(); - const clearAuth = useAuthStore((state) => state.clearAuth); - const refreshToken = useAuthStore((state) => state.refreshToken); + const clearAuth = useAuth((state) => state.clearAuth); + const refreshToken = useAuth((state) => state.refreshToken); return useMutation({ mutationFn: async () => { @@ -296,7 +295,7 @@ export function useLogout() { export function useLogoutAll() { const router = useRouter(); const queryClient = useQueryClient(); - const clearAuth = useAuthStore((state) => state.clearAuth); + const clearAuth = useAuth((state) => state.clearAuth); return useMutation({ mutationFn: async () => { diff --git a/frontend/src/lib/api/hooks/useUser.ts b/frontend/src/lib/api/hooks/useUser.ts index 8295501..52d7bec 100644 --- a/frontend/src/lib/api/hooks/useUser.ts +++ b/frontend/src/lib/api/hooks/useUser.ts @@ -9,7 +9,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateCurrentUser } from '../client'; -import { useAuthStore } from '@/lib/stores/authStore'; +import { useAuth } from '@/lib/auth/AuthContext'; import type { User } from '@/lib/stores/authStore'; import { parseAPIError, getGeneralError } from '../errors'; import { authKeys } from './useAuth'; @@ -31,7 +31,7 @@ import { authKeys } from './useAuth'; */ export function useUpdateProfile(onSuccess?: (message: string) => void) { const queryClient = useQueryClient(); - const setUser = useAuthStore((state) => state.setUser); + const setUser = useAuth((state) => state.setUser); return useMutation({ mutationFn: async (data: { diff --git a/frontend/src/lib/auth/AuthContext.tsx b/frontend/src/lib/auth/AuthContext.tsx index 649cd41..fd47778 100644 --- a/frontend/src/lib/auth/AuthContext.tsx +++ b/frontend/src/lib/auth/AuthContext.tsx @@ -13,6 +13,7 @@ import { createContext, useContext } from "react"; import type { ReactNode } from "react"; +// eslint-disable-next-line no-restricted-imports -- This is the DI boundary, needs real store for production import { useAuthStore as useAuthStoreImpl } from "@/lib/stores/authStore"; import type { User } from "@/lib/stores/authStore"; diff --git a/frontend/tests/components/auth/AuthInitializer.test.tsx b/frontend/tests/components/auth/AuthInitializer.test.tsx index 7356e7c..f6d17b1 100644 --- a/frontend/tests/components/auth/AuthInitializer.test.tsx +++ b/frontend/tests/components/auth/AuthInitializer.test.tsx @@ -5,6 +5,7 @@ import { render, waitFor } from '@testing-library/react'; import { AuthInitializer } from '@/components/auth/AuthInitializer'; +import { AuthProvider } from '@/lib/auth/AuthContext'; import { useAuthStore } from '@/lib/stores/authStore'; // Mock the auth store @@ -28,13 +29,21 @@ describe('AuthInitializer', () => { describe('Initialization', () => { it('renders nothing (null)', () => { - const { container } = render(); + const { container } = render( + + + + ); expect(container.firstChild).toBeNull(); }); it('calls loadAuthFromStorage on mount', async () => { - render(); + render( + + + + ); await waitFor(() => { expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1); @@ -42,14 +51,22 @@ describe('AuthInitializer', () => { }); it('does not call loadAuthFromStorage again on re-render', async () => { - const { rerender } = render(); + const { rerender } = render( + + + + ); await waitFor(() => { expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1); }); // Force re-render - rerender(); + rerender( + + + + ); // Should still only be called once (useEffect dependencies prevent re-call) expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1); diff --git a/frontend/tests/lib/api/hooks/useUser.test.tsx b/frontend/tests/lib/api/hooks/useUser.test.tsx index 0e654b3..d9a915e 100644 --- a/frontend/tests/lib/api/hooks/useUser.test.tsx +++ b/frontend/tests/lib/api/hooks/useUser.test.tsx @@ -7,6 +7,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useUpdateProfile } from '@/lib/api/hooks/useUser'; import { useAuthStore } from '@/lib/stores/authStore'; +import { AuthProvider } from '@/lib/auth/AuthContext'; import * as apiClient from '@/lib/api/client'; // Mock dependencies @@ -32,7 +33,9 @@ describe('useUser hooks', () => { const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + );