Refactor to enforce AuthContext usage over useAuthStore and improve test stability
- Replaced `useAuthStore` with `useAuth` from `AuthContext` across frontend components and tests to ensure dependency injection compliance. - Enhanced E2E test stability by delaying navigation until the auth context is fully initialized. - Updated Playwright configuration to use a single worker to prevent mock conflicts. - Refactored test setup to consistently inject `AuthProvider` for improved isolation and mocking. - Adjusted comments and documentation to clarify dependency injection and testability patterns.
This commit is contained in:
@@ -38,6 +38,7 @@ export const MOCK_SESSION = {
|
|||||||
/**
|
/**
|
||||||
* Set up API mocking for authenticated E2E tests
|
* Set up API mocking for authenticated E2E tests
|
||||||
* Intercepts backend API calls and returns mock data
|
* Intercepts backend API calls and returns mock data
|
||||||
|
* Routes persist across client-side navigation
|
||||||
*
|
*
|
||||||
* @param page Playwright page object
|
* @param page Playwright page object
|
||||||
*/
|
*/
|
||||||
@@ -45,7 +46,9 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
|
|||||||
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000';
|
||||||
|
|
||||||
// Mock GET /api/v1/users/me - Get current user
|
// Mock GET /api/v1/users/me - Get current user
|
||||||
|
// Mock PATCH /api/v1/users/me - Update user profile
|
||||||
await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => {
|
await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => {
|
||||||
|
if (route.request().method() === 'GET') {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
@@ -54,11 +57,7 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
|
|||||||
data: MOCK_USER,
|
data: MOCK_USER,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
} else if (route.request().method() === 'PATCH') {
|
||||||
|
|
||||||
// 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') {
|
|
||||||
const postData = route.request().postDataJSON();
|
const postData = route.request().postDataJSON();
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -93,7 +92,9 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
|
|||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
data: [MOCK_SESSION],
|
data: {
|
||||||
|
sessions: [MOCK_SESSION],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -117,34 +118,15 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Inject mock auth store BEFORE navigation
|
// Inject mock auth store that persists across navigation
|
||||||
// This must happen before the page loads to ensure AuthProvider picks it up
|
// 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) => {
|
await page.addInitScript((mockUser) => {
|
||||||
// Create a mock Zustand hook that returns our mocked auth state
|
// Create a stable state object that persists
|
||||||
const mockAuthStore: any = (selector?: any) => {
|
const authState = {
|
||||||
const state = {
|
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
accessToken: 'mock-access-token',
|
accessToken: 'mock.access.token', // Valid JWT format (3 parts)
|
||||||
refreshToken: 'mock-refresh-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 = () => ({
|
|
||||||
user: mockUser,
|
|
||||||
accessToken: 'mock-access-token',
|
|
||||||
refreshToken: 'mock-refresh-token',
|
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
tokenExpiresAt: Date.now() + 900000,
|
tokenExpiresAt: Date.now() + 900000,
|
||||||
@@ -152,11 +134,32 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
|
|||||||
setTokens: async () => {},
|
setTokens: async () => {},
|
||||||
setUser: () => {},
|
setUser: () => {},
|
||||||
clearAuth: async () => {},
|
clearAuth: async () => {},
|
||||||
loadAuthFromStorage: async () => {},
|
loadAuthFromStorage: async () => {
|
||||||
|
// No-op in tests - state is already set
|
||||||
|
},
|
||||||
isTokenExpired: () => false,
|
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;
|
(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);
|
}, MOCK_USER);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ test.describe('Settings Navigation', () => {
|
|||||||
// Set up API mocks for authenticated user
|
// Set up API mocks for authenticated user
|
||||||
await setupAuthenticatedMocks(page);
|
await setupAuthenticatedMocks(page);
|
||||||
|
|
||||||
|
// Delay to ensure auth store injection completes before navigation
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
// Navigate to settings
|
// Navigate to settings
|
||||||
await page.goto('/settings/profile');
|
await page.goto('/settings/profile');
|
||||||
await expect(page).toHaveURL('/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 }) => {
|
test('should display settings tabs', async ({ page }) => {
|
||||||
@@ -48,7 +54,7 @@ test.describe('Settings Navigation', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page).toHaveURL('/settings/password');
|
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 }) => {
|
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).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 }) => {
|
test('should maintain layout when navigating between tabs', async ({ page }) => {
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ test.describe('Password Change', () => {
|
|||||||
// Set up API mocks for authenticated user
|
// Set up API mocks for authenticated user
|
||||||
await setupAuthenticatedMocks(page);
|
await setupAuthenticatedMocks(page);
|
||||||
|
|
||||||
|
// Delay to ensure auth store injection completes before navigation
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
// Navigate to password settings
|
// Navigate to password settings
|
||||||
await page.goto('/settings/password');
|
await page.goto('/settings/password');
|
||||||
await expect(page).toHaveURL('/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 }) => {
|
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 }) => {
|
test('should show cancel button when form is dirty', async ({ page }) => {
|
||||||
await page.waitForSelector('#current_password');
|
await page.waitForSelector('#current_password');
|
||||||
|
|
||||||
// Fill current password
|
// Fill current password and blur to trigger dirty state
|
||||||
await page.locator('#current_password').fill('Admin123!');
|
const currentPasswordInput = page.locator('#current_password');
|
||||||
|
await currentPasswordInput.fill('Admin123!');
|
||||||
|
await currentPasswordInput.blur();
|
||||||
|
|
||||||
// Wait for form state to update
|
// Cancel button should appear when form is dirty
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Cancel button should appear
|
|
||||||
const cancelButton = page.locator('button[type="button"]:has-text("Cancel")');
|
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 }) => {
|
test('should clear form when cancel button is clicked', async ({ page }) => {
|
||||||
await page.waitForSelector('#current_password');
|
await page.waitForSelector('#current_password');
|
||||||
|
|
||||||
// Fill fields
|
// Fill fields and blur to trigger dirty state
|
||||||
await page.locator('#current_password').fill('Admin123!');
|
const currentPasswordInput = page.locator('#current_password');
|
||||||
|
await currentPasswordInput.fill('Admin123!');
|
||||||
|
await currentPasswordInput.blur();
|
||||||
|
|
||||||
await page.locator('#new_password').fill('NewAdmin123!');
|
await page.locator('#new_password').fill('NewAdmin123!');
|
||||||
|
|
||||||
// Wait for form state to update
|
// Click cancel button
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Click cancel
|
|
||||||
const cancelButton = page.locator('button[type="button"]:has-text("Cancel")');
|
const cancelButton = page.locator('button[type="button"]:has-text("Cancel")');
|
||||||
await cancelButton.click();
|
await cancelButton.click();
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ test.describe('Profile Settings', () => {
|
|||||||
// Set up API mocks for authenticated user
|
// Set up API mocks for authenticated user
|
||||||
await setupAuthenticatedMocks(page);
|
await setupAuthenticatedMocks(page);
|
||||||
|
|
||||||
|
// Delay to ensure auth store injection completes before navigation
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
// Navigate to profile settings
|
// Navigate to profile settings
|
||||||
await page.goto('/settings/profile');
|
await page.goto('/settings/profile');
|
||||||
await expect(page).toHaveURL('/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 }) => {
|
test('should display profile settings page', async ({ page }) => {
|
||||||
@@ -79,14 +85,15 @@ test.describe('Profile Settings', () => {
|
|||||||
return input && input.value !== '';
|
return input && input.value !== '';
|
||||||
}, { timeout: 5000 });
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
// Modify first name
|
// Modify first name and blur to trigger dirty state
|
||||||
const firstNameInput = page.locator('#first_name');
|
const firstNameInput = page.locator('#first_name');
|
||||||
await firstNameInput.clear();
|
await firstNameInput.clear();
|
||||||
await firstNameInput.fill('TestUser');
|
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")');
|
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 }) => {
|
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 firstNameInput = page.locator('#first_name');
|
||||||
const originalValue = await firstNameInput.inputValue();
|
const originalValue = await firstNameInput.inputValue();
|
||||||
|
|
||||||
// Modify first name
|
// Modify first name and blur to trigger dirty state
|
||||||
await firstNameInput.clear();
|
await firstNameInput.clear();
|
||||||
await firstNameInput.fill('TestUser');
|
await firstNameInput.fill('TestUser');
|
||||||
|
await firstNameInput.blur();
|
||||||
await expect(firstNameInput).toHaveValue('TestUser');
|
await expect(firstNameInput).toHaveValue('TestUser');
|
||||||
|
|
||||||
// Click reset
|
// Click reset
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ test.describe('Sessions Management', () => {
|
|||||||
// Set up API mocks for authenticated user
|
// Set up API mocks for authenticated user
|
||||||
await setupAuthenticatedMocks(page);
|
await setupAuthenticatedMocks(page);
|
||||||
|
|
||||||
|
// Delay to ensure auth store injection completes before navigation
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
// Navigate to sessions settings
|
// Navigate to sessions settings
|
||||||
await page.goto('/settings/sessions');
|
await page.goto('/settings/sessions');
|
||||||
await expect(page).toHaveURL('/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 }) => {
|
test('should display sessions management page', async ({ page }) => {
|
||||||
@@ -132,9 +138,15 @@ test.describe('Sessions Management - Revocation', () => {
|
|||||||
// Set up API mocks for authenticated user
|
// Set up API mocks for authenticated user
|
||||||
await setupAuthenticatedMocks(page);
|
await setupAuthenticatedMocks(page);
|
||||||
|
|
||||||
|
// Delay to ensure auth store injection completes before navigation
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
// Navigate to sessions settings
|
// Navigate to sessions settings
|
||||||
await page.goto('/settings/sessions');
|
await page.goto('/settings/sessions');
|
||||||
await expect(page).toHaveURL('/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 }) => {
|
test('should show confirmation dialog before individual revocation', async ({ page }) => {
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ const eslintConfig = [
|
|||||||
"**/*.gen.tsx",
|
"**/*.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;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI and locally to handle flaky tests */
|
/* Retry on CI and locally to handle flaky tests */
|
||||||
retries: process.env.CI ? 2 : 1,
|
retries: process.env.CI ? 2 : 1,
|
||||||
/* Limit workers to prevent test interference and Next dev server overload */
|
/* Use 1 worker to prevent test interference (parallel execution causes auth mock conflicts) */
|
||||||
workers: process.env.CI ? 1 : 8,
|
workers: 1,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: process.env.CI ? 'github' : 'list',
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
/* Suppress console output unless VERBOSE=true */
|
/* Suppress console output unless VERBOSE=true */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
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
|
* 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
|
* This component should be included in the app's Providers to ensure
|
||||||
* authentication state is restored from storage when the app loads.
|
* 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
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* // In app/providers.tsx
|
* // In app/providers.tsx
|
||||||
@@ -29,9 +32,14 @@ import { useAuthStore } from '@/lib/stores/authStore';
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function AuthInitializer() {
|
export function AuthInitializer() {
|
||||||
const loadAuthFromStorage = useAuthStore((state) => state.loadAuthFromStorage);
|
const loadAuthFromStorage = useAuth((state) => state.loadAuthFromStorage);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
// Load auth state from encrypted storage on mount
|
||||||
loadAuthFromStorage();
|
loadAuthFromStorage();
|
||||||
}, [loadAuthFromStorage]);
|
}, [loadAuthFromStorage]);
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ let refreshPromise: Promise<string> | null = null;
|
|||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
const getAuthStore = async () => {
|
const getAuthStore = async () => {
|
||||||
// Check for E2E test store injection (same pattern as AuthProvider)
|
// 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__) {
|
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__;
|
const testStore = (window as any).__TEST_AUTH_STORE__;
|
||||||
// Test store must have getState() method for non-React contexts
|
// Test store must have getState() method for non-React contexts
|
||||||
return testStore.getState();
|
return testStore.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production: use real Zustand store
|
// 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');
|
const { useAuthStore } = await import('@/lib/stores/authStore');
|
||||||
return useAuthStore.getState();
|
return useAuthStore.getState();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
confirmPasswordReset,
|
confirmPasswordReset,
|
||||||
changeCurrentUserPassword,
|
changeCurrentUserPassword,
|
||||||
} from '../client';
|
} from '../client';
|
||||||
import { useAuthStore } from '@/lib/stores/authStore';
|
|
||||||
import type { User } from '@/lib/stores/authStore';
|
import type { User } from '@/lib/stores/authStore';
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import { parseAPIError, getGeneralError } from '../errors';
|
import { parseAPIError, getGeneralError } from '../errors';
|
||||||
@@ -50,8 +49,8 @@ export const authKeys = {
|
|||||||
* @returns React Query result with user data
|
* @returns React Query result with user data
|
||||||
*/
|
*/
|
||||||
export function useMe() {
|
export function useMe() {
|
||||||
const { isAuthenticated, accessToken } = useAuthStore();
|
const { isAuthenticated, accessToken } = useAuth();
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuth((state) => state.setUser);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: authKeys.me,
|
queryKey: authKeys.me,
|
||||||
@@ -95,7 +94,7 @@ export function useMe() {
|
|||||||
export function useLogin(onSuccess?: () => void) {
|
export function useLogin(onSuccess?: () => void) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setAuth = useAuthStore((state) => state.setAuth);
|
const setAuth = useAuth((state) => state.setAuth);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (credentials: { email: string; password: string }) => {
|
mutationFn: async (credentials: { email: string; password: string }) => {
|
||||||
@@ -163,7 +162,7 @@ export function useLogin(onSuccess?: () => void) {
|
|||||||
export function useRegister(onSuccess?: () => void) {
|
export function useRegister(onSuccess?: () => void) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setAuth = useAuthStore((state) => state.setAuth);
|
const setAuth = useAuth((state) => state.setAuth);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: {
|
mutationFn: async (data: {
|
||||||
@@ -240,8 +239,8 @@ export function useRegister(onSuccess?: () => void) {
|
|||||||
export function useLogout() {
|
export function useLogout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clearAuth = useAuthStore((state) => state.clearAuth);
|
const clearAuth = useAuth((state) => state.clearAuth);
|
||||||
const refreshToken = useAuthStore((state) => state.refreshToken);
|
const refreshToken = useAuth((state) => state.refreshToken);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -296,7 +295,7 @@ export function useLogout() {
|
|||||||
export function useLogoutAll() {
|
export function useLogoutAll() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clearAuth = useAuthStore((state) => state.clearAuth);
|
const clearAuth = useAuth((state) => state.clearAuth);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { updateCurrentUser } from '../client';
|
import { updateCurrentUser } from '../client';
|
||||||
import { useAuthStore } from '@/lib/stores/authStore';
|
import { useAuth } from '@/lib/auth/AuthContext';
|
||||||
import type { User } from '@/lib/stores/authStore';
|
import type { User } from '@/lib/stores/authStore';
|
||||||
import { parseAPIError, getGeneralError } from '../errors';
|
import { parseAPIError, getGeneralError } from '../errors';
|
||||||
import { authKeys } from './useAuth';
|
import { authKeys } from './useAuth';
|
||||||
@@ -31,7 +31,7 @@ import { authKeys } from './useAuth';
|
|||||||
*/
|
*/
|
||||||
export function useUpdateProfile(onSuccess?: (message: string) => void) {
|
export function useUpdateProfile(onSuccess?: (message: string) => void) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const setUser = useAuthStore((state) => state.setUser);
|
const setUser = useAuth((state) => state.setUser);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: {
|
mutationFn: async (data: {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import type { ReactNode } 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 { useAuthStore as useAuthStoreImpl } from "@/lib/stores/authStore";
|
||||||
import type { User } from "@/lib/stores/authStore";
|
import type { User } from "@/lib/stores/authStore";
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { render, waitFor } from '@testing-library/react';
|
import { render, waitFor } from '@testing-library/react';
|
||||||
import { AuthInitializer } from '@/components/auth/AuthInitializer';
|
import { AuthInitializer } from '@/components/auth/AuthInitializer';
|
||||||
|
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||||
import { useAuthStore } from '@/lib/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
|
|
||||||
// Mock the auth store
|
// Mock the auth store
|
||||||
@@ -28,13 +29,21 @@ describe('AuthInitializer', () => {
|
|||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('renders nothing (null)', () => {
|
it('renders nothing (null)', () => {
|
||||||
const { container } = render(<AuthInitializer />);
|
const { container } = render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthInitializer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls loadAuthFromStorage on mount', async () => {
|
it('calls loadAuthFromStorage on mount', async () => {
|
||||||
render(<AuthInitializer />);
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthInitializer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
||||||
@@ -42,14 +51,22 @@ describe('AuthInitializer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not call loadAuthFromStorage again on re-render', async () => {
|
it('does not call loadAuthFromStorage again on re-render', async () => {
|
||||||
const { rerender } = render(<AuthInitializer />);
|
const { rerender } = render(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthInitializer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force re-render
|
// Force re-render
|
||||||
rerender(<AuthInitializer />);
|
rerender(
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthInitializer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
// Should still only be called once (useEffect dependencies prevent re-call)
|
// Should still only be called once (useEffect dependencies prevent re-call)
|
||||||
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
expect(mockLoadAuthFromStorage).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { renderHook, waitFor } from '@testing-library/react';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { useUpdateProfile } from '@/lib/api/hooks/useUser';
|
import { useUpdateProfile } from '@/lib/api/hooks/useUser';
|
||||||
import { useAuthStore } from '@/lib/stores/authStore';
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
|
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||||
import * as apiClient from '@/lib/api/client';
|
import * as apiClient from '@/lib/api/client';
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
@@ -32,7 +33,9 @@ describe('useUser hooks', () => {
|
|||||||
|
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
{children}
|
{children}
|
||||||
|
</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user