diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 8426c01..ae248dc 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -22,8 +22,6 @@ const customJestConfig = { '!src/**/*.stories.{js,jsx,ts,tsx}', '!src/**/__tests__/**', '!src/lib/api/generated/**', // Auto-generated API client - do not test - '!src/lib/api/client.ts', // TODO: Replace with generated client + thin interceptor wrapper - '!src/lib/api/errors.ts', // TODO: Remove - error parsing should be in generated client '!src/**/*.old.{js,jsx,ts,tsx}', // Old implementation files '!src/components/ui/**', // shadcn/ui components - third-party, no need to test '!src/app/**', // Next.js app directory - layout/page files (test in E2E) diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js index 8babd3e..cedec30 100644 --- a/frontend/jest.setup.js +++ b/frontend/jest.setup.js @@ -1,10 +1,22 @@ // Learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom' +import 'whatwg-fetch'; // Polyfill fetch API for MSW import { Crypto } from '@peculiar/webcrypto'; // Mock window object global.window = global.window || {}; +// Mock BroadcastChannel for MSW +global.BroadcastChannel = class BroadcastChannel { + constructor(name) { + this.name = name; + } + postMessage() {} + close() {} + addEventListener() {} + removeEventListener() {} +}; + // Use real Web Crypto API polyfill for Node environment const cryptoPolyfill = new Crypto(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 993f658..11dfe27 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -51,12 +51,14 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "axios-mock-adapter": "^2.1.0", "eslint": "^9", "eslint-config-next": "15.2.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "whatwg-fetch": "^3.6.20" } }, "node_modules/@adobe/css-tools": { @@ -5013,6 +5015,20 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", + "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7860,6 +7876,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -12734,6 +12774,13 @@ "node": ">=18" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f313fab..027285e 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -63,11 +63,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "axios-mock-adapter": "^2.1.0", "eslint": "^9", "eslint-config-next": "15.2.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "whatwg-fetch": "^3.6.20" } } diff --git a/frontend/src/lib/api/client-config.ts b/frontend/src/lib/api/client-config.ts deleted file mode 100644 index 8891a80..0000000 --- a/frontend/src/lib/api/client-config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Configure the generated API client - * Integrates @hey-api/openapi-ts client with our auth logic - * - * This file wraps the auto-generated client without modifying generated code - * Note: @hey-api client doesn't support axios-style interceptors - * We configure it to work with existing manual client.ts for now - */ - -import { client } from './generated/client.gen'; -import config from '@/config/app.config'; - -/** - * Configure generated client with base URL - * Auth token injection handled via fetch interceptor pattern - */ -export function configureApiClient() { - client.setConfig({ - baseURL: config.api.url, - }); -} - -// Configure client on module load -configureApiClient(); - -// Re-export configured client for use in hooks -export { client as generatedClient }; - -// Re-export all SDK functions -export * from './generated/sdk.gen'; - -// Re-export types -export type * from './generated/types.gen'; - -// Also export manual client for backward compatibility -export { apiClient } from './client'; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 7a65f38..05cab65 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,98 +1,106 @@ /** - * API Client with secure token refresh and error handling - * Implements singleton refresh pattern to prevent race conditions + * API Client Configuration with Interceptors + * + * This module configures the auto-generated API client with: + * - Token refresh interceptor (prevents race conditions with singleton pattern) + * - Request interceptor (adds Authorization header) + * - Response interceptor (handles 401, 403, 429, 500 errors) + * + * IMPORTANT: Do NOT modify generated files. All customization happens here. + * + * @module lib/api/client */ -import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios'; -import { useAuthStore } from '@/stores/authStore'; -import { parseAPIError, type APIErrorResponse } from './errors'; +import type { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios'; +import { client } from './generated/client.gen'; +import { refreshToken as refreshTokenFn } from './generated/sdk.gen'; import config from '@/config/app.config'; /** - * Separate axios instance for auth endpoints - * Prevents interceptor loops during token refresh - */ -const authClient = axios.create({ - baseURL: config.api.url, - timeout: config.api.timeout, - headers: { - 'Content-Type': 'application/json', - }, -}); - -/** - * Main API client instance - */ -export const apiClient = axios.create({ - baseURL: config.api.url, - timeout: config.api.timeout, - headers: { - 'Content-Type': 'application/json', - }, -}); - -/** - * Token refresh state - * Singleton pattern prevents multiple simultaneous refresh requests + * Token refresh state management (singleton pattern) + * Prevents race conditions when multiple requests fail with 401 simultaneously */ let isRefreshing = false; let refreshPromise: Promise | null = null; +/** + * Auth store accessor + * Dynamically imported to avoid circular dependencies + */ +const getAuthStore = async () => { + const { useAuthStore } = await import('@/stores/authStore'); + return useAuthStore.getState(); +}; + /** * Refresh access token using refresh token - * Implements singleton pattern to prevent race conditions - * @returns Promise resolving to new access token + * + * @returns Promise New access token + * @throws Error if refresh fails */ async function refreshAccessToken(): Promise { - // If already refreshing, return existing promise + // Singleton pattern: reuse in-flight refresh request if (isRefreshing && refreshPromise) { return refreshPromise; } - // Start new refresh isRefreshing = true; - refreshPromise = (async () => { try { - const refreshToken = useAuthStore.getState().refreshToken; + const authStore = await getAuthStore(); + const { refreshToken } = authStore; if (!refreshToken) { throw new Error('No refresh token available'); } - // Use separate auth client to avoid interceptor loop - const response = await authClient.post<{ - access_token: string; - refresh_token: string; - expires_in?: number; - token_type: string; - }>('/auth/refresh', { - refresh_token: refreshToken, - }); - - // Validate response structure - if (!response.data?.access_token || !response.data?.refresh_token) { - throw new Error('Invalid refresh response format'); + if (config.debug.api) { + console.log('[API Client] Refreshing access token...'); } - const { access_token, refresh_token, expires_in } = response.data; + // Use generated SDK function for refresh + const response = await refreshTokenFn({ + body: { refresh_token: refreshToken }, + throwOnError: true, + }); + + const newAccessToken = response.data.access_token; + const newRefreshToken = response.data.refresh_token || refreshToken; // Update tokens in store - await useAuthStore.getState().setTokens(access_token, refresh_token, expires_in); + // Note: Token type from OpenAPI spec doesn't include expires_in, + // but backend may return it. We handle both cases gracefully. + await authStore.setTokens( + newAccessToken, + newRefreshToken, + undefined // expires_in not in spec, will use default + ); - return access_token; + if (config.debug.api) { + console.log('[API Client] Token refreshed successfully'); + } + + return newAccessToken; } catch (error) { - // Refresh failed - clear auth and redirect - console.error('Token refresh failed:', error); - await useAuthStore.getState().clearAuth(); + if (config.debug.api) { + console.error('[API Client] Token refresh failed:', error); + } + // Clear auth and redirect to login + const authStore = await getAuthStore(); + await authStore.clearAuth(); + + // Redirect to login if we're in browser if (typeof window !== 'undefined') { - window.location.href = config.routes.login; + const currentPath = window.location.pathname; + const returnUrl = currentPath !== '/login' && currentPath !== '/register' + ? `?returnUrl=${encodeURIComponent(currentPath)}` + : ''; + window.location.href = `/login${returnUrl}`; } throw error; } finally { - // Reset refresh state isRefreshing = false; refreshPromise = null; } @@ -102,68 +110,62 @@ async function refreshAccessToken(): Promise { } /** - * Request interceptor - Add authentication token + * Request Interceptor + * Adds Authorization header with access token to all requests */ -apiClient.interceptors.request.use( - (requestConfig: InternalAxiosRequestConfig) => { - // Get access token from auth store - const accessToken = useAuthStore.getState().accessToken; +client.instance.interceptors.request.use( + async (requestConfig: InternalAxiosRequestConfig) => { + const authStore = await getAuthStore(); + const { accessToken } = authStore; // Add Authorization header if token exists - if (accessToken) { + if (accessToken && requestConfig.headers) { requestConfig.headers.Authorization = `Bearer ${accessToken}`; } - // Debug logging in development if (config.debug.api) { - console.log('🚀 API Request:', { - method: requestConfig.method?.toUpperCase(), - url: requestConfig.url, - hasAuth: !!accessToken, - }); + console.log('[API Client] Request:', requestConfig.method?.toUpperCase(), requestConfig.url); } return requestConfig; }, - (error: AxiosError) => { - console.error('Request interceptor error:', error); + (error) => { + if (config.debug.api) { + console.error('[API Client] Request error:', error); + } return Promise.reject(error); } ); /** - * Response interceptor - Handle errors and token refresh + * Response Interceptor + * Handles errors and token refresh */ -apiClient.interceptors.response.use( - (response) => { - // Debug logging in development +client.instance.interceptors.response.use( + (response: AxiosResponse) => { if (config.debug.api) { - console.log('✅ API Response:', { - status: response.status, - url: response.config.url, - }); + console.log('[API Client] Response:', response.status, response.config.url); } - return response; }, - async (error: AxiosError) => { - const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean }; + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; - // Debug logging in development - if (config.env.isDevelopment) { - console.error('❌ API Error:', { - status: error.response?.status, - url: error.config?.url, - message: error.message, - }); + if (config.debug.api) { + console.error('[API Client] Response error:', error.response?.status, error.config?.url); } - // Handle 401 Unauthorized - Token refresh logic + // Handle 401 Unauthorized - Token expired if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + // Avoid retrying refresh endpoint itself + if (originalRequest.url?.includes('/auth/refresh')) { + return Promise.reject(error); + } + originalRequest._retry = true; try { - // Attempt to refresh token (singleton pattern) + // Refresh token const newAccessToken = await refreshAccessToken(); // Retry original request with new token @@ -171,43 +173,64 @@ apiClient.interceptors.response.use( originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; } - return apiClient.request(originalRequest); + return client.instance(originalRequest); } catch (refreshError) { - // Refresh failed - error already logged, auth cleared return Promise.reject(refreshError); } } // Handle 403 Forbidden if (error.response?.status === 403) { - console.warn('Access forbidden - insufficient permissions'); - // Toast notification would be added here in Phase 4 + if (config.debug.api) { + console.warn('[API Client] Access forbidden (403)'); + } + // Let the component handle this (might be permission issue, not auth) } // Handle 429 Too Many Requests if (error.response?.status === 429) { - const retryAfter = error.response.headers['retry-after']; - console.warn(`Rate limit exceeded${retryAfter ? `. Retry after ${retryAfter}s` : ''}`); - // Toast notification would be added here in Phase 4 + if (config.debug.api) { + console.warn('[API Client] Rate limit exceeded (429)'); + } + // Add retry-after handling if needed in future } // Handle 500+ Server Errors if (error.response?.status && error.response.status >= 500) { - console.error('Server error occurred'); - // Toast notification would be added here in Phase 4 + if (config.debug.api) { + console.error('[API Client] Server error:', error.response.status); + } + // Could add error tracking service integration here } - // Handle Network Errors - if (!error.response) { - console.error('Network error - check your connection'); - // Toast notification would be added here in Phase 4 - } - - // Parse and reject with structured error - const parsedErrors = parseAPIError(error); - return Promise.reject(parsedErrors); + return Promise.reject(error); } ); -// Export default for convenience -export default apiClient; +/** + * Configure the generated client with base settings + */ +client.setConfig({ + baseURL: config.api.url, + timeout: config.api.timeout, + headers: { + 'Content-Type': 'application/json', + }, +}); + +/** + * Configured API client instance + * Use this for all API calls + */ +export { client as apiClient }; + +/** + * Re-export all SDK functions for convenience + * These are already configured with interceptors + */ +export * from './generated/sdk.gen'; + +/** + * Re-export types for convenience + */ +export type * from './generated/types.gen'; diff --git a/frontend/src/lib/api/errors.ts b/frontend/src/lib/api/errors.ts index 809f416..84daeda 100755 --- a/frontend/src/lib/api/errors.ts +++ b/frontend/src/lib/api/errors.ts @@ -59,15 +59,42 @@ export const ERROR_MESSAGES: Record = { 'RATE_LIMIT': 'Too many requests. Please slow down', }; +/** + * Type guard to check if error is an AxiosError + */ +function isAxiosError(error: unknown): error is AxiosError { + return ( + typeof error === 'object' && + error !== null && + 'isAxiosError' in error && + (error as any).isAxiosError === true + ); +} + /** * Parse API error response - * @param error AxiosError from API request + * @param error Error from API request (unknown type for flexibility) * @returns Array of structured APIError objects */ -export function parseAPIError(error: AxiosError): APIError[] { +export function parseAPIError(error: unknown): APIError[] { + // Type guard: check if it's an AxiosError + if (!isAxiosError(error)) { + // Generic error + return [ + { + code: 'UNKNOWN', + message: error instanceof Error ? error.message : ERROR_MESSAGES['UNKNOWN'], + }, + ]; + } // Backend structured errors - if (error.response?.data?.errors && Array.isArray(error.response.data.errors)) { - return error.response.data.errors; + if ( + error.response?.data && + typeof error.response.data === 'object' && + 'errors' in error.response.data && + Array.isArray((error.response.data as any).errors) + ) { + return (error.response.data as any).errors; } // Network errors (no response) diff --git a/frontend/src/lib/api/hooks/useAuth.ts b/frontend/src/lib/api/hooks/useAuth.ts index 210c92d..126402c 100644 --- a/frontend/src/lib/api/hooks/useAuth.ts +++ b/frontend/src/lib/api/hooks/useAuth.ts @@ -1,60 +1,31 @@ /** * Authentication React Query Hooks - * Integrates with authStore for state management - * Implements all auth endpoints from backend API + * + * Integrates with auto-generated API client and authStore for state management. + * All hooks use generated SDK functions for type safety and OpenAPI compliance. + * + * @module lib/api/hooks/useAuth */ import { useEffect } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { apiClient } from '../client'; +import { + login, + register, + logout, + logoutAll, + getCurrentUserInfo, + requestPasswordReset, + confirmPasswordReset, + changeCurrentUserPassword, +} from '../client'; import { useAuthStore } from '@/stores/authStore'; import type { User } from '@/stores/authStore'; -import type { APIError } from '../errors'; +import { parseAPIError, getGeneralError } from '../errors'; +import { isTokenWithUser, type TokenWithUser } from '../types'; import config from '@/config/app.config'; -// ============================================================================ -// Types -// ============================================================================ - -export interface LoginCredentials { - email: string; - password: string; -} - -export interface RegisterData { - email: string; - password: string; - first_name: string; - last_name?: string; -} - -export interface PasswordResetRequest { - email: string; -} - -export interface PasswordResetConfirm { - token: string; - new_password: string; -} - -export interface PasswordChange { - current_password: string; - new_password: string; -} - -export interface AuthResponse { - access_token: string; - refresh_token: string; - token_type: 'bearer'; - user: User; -} - -export interface SuccessResponse { - success: true; - message: string; -} - // ============================================================================ // Query Keys // ============================================================================ @@ -71,7 +42,11 @@ export const authKeys = { /** * Get current user from token * GET /api/v1/auth/me - * Updates auth store with fetched user data + * + * Automatically syncs user data to auth store when fetched. + * Only enabled when user is authenticated with access token. + * + * @returns React Query result with user data */ export function useMe() { const { isAuthenticated, accessToken } = useAuthStore(); @@ -80,8 +55,10 @@ export function useMe() { const query = useQuery({ queryKey: authKeys.me, queryFn: async (): Promise => { - const response = await apiClient.get('/auth/me'); - return response.data; + const response = await getCurrentUserInfo({ + throwOnError: true, + }); + return response.data as User; }, enabled: isAuthenticated && !!accessToken, staleTime: 5 * 60 * 1000, // 5 minutes @@ -105,33 +82,67 @@ export function useMe() { /** * Login mutation * POST /api/v1/auth/login + * + * On success: + * - Stores tokens and user in auth store + * - Invalidates auth queries + * - Redirects to home page + * + * @param onSuccess Optional callback after successful login + * @returns React Query mutation */ -export function useLogin() { +export function useLogin(onSuccess?: () => void) { const router = useRouter(); const queryClient = useQueryClient(); const setAuth = useAuthStore((state) => state.setAuth); return useMutation({ - mutationFn: async (credentials: LoginCredentials): Promise => { - const response = await apiClient.post('/auth/login', credentials); - return response.data; + mutationFn: async (credentials: { email: string; password: string }) => { + const response = await login({ + body: credentials, + throwOnError: false, // Handle errors manually + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + const data = (response as { data: unknown }).data; + + // Type guard to ensure response has user data + if (!isTokenWithUser(data)) { + throw new Error('Invalid login response: missing user data'); + } + + return data; }, onSuccess: async (data) => { - const { access_token, refresh_token, user } = data; + const { access_token, refresh_token, user, expires_in } = data; // Update auth store with user and tokens - await setAuth(user, access_token, refresh_token); + await setAuth( + user as User, + access_token, + refresh_token || '', + expires_in + ); // Invalidate and refetch user data queryClient.invalidateQueries({ queryKey: authKeys.all }); + // Call custom success callback if provided + if (onSuccess) { + onSuccess(); + } + // Redirect to home or intended destination - // TODO: Add redirect to intended route from query params - router.push('/'); + router.push(config.routes.home); }, - onError: (errors: APIError[]) => { - console.error('Login failed:', errors); - // Error toast will be handled in the component + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Login failed:', generalError || 'Unknown error'); }, }); } @@ -139,48 +150,115 @@ export function useLogin() { /** * Register mutation * POST /api/v1/auth/register + * + * On success: + * - Stores tokens and user in auth store + * - Invalidates auth queries + * - Redirects to home page (auto-login) + * + * @param onSuccess Optional callback after successful registration + * @returns React Query mutation */ -export function useRegister() { +export function useRegister(onSuccess?: () => void) { const router = useRouter(); const queryClient = useQueryClient(); const setAuth = useAuthStore((state) => state.setAuth); return useMutation({ - mutationFn: async (data: RegisterData): Promise => { - const response = await apiClient.post('/auth/register', data); - return response.data; + mutationFn: async (data: { + email: string; + password: string; + first_name: string; + last_name?: string; + }) => { + const response = await register({ + body: { + email: data.email, + password: data.password, + first_name: data.first_name, + last_name: data.last_name || '', + }, + throwOnError: false, + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + const responseData = (response as { data: unknown }).data; + + // Type guard to ensure response has user data + if (!isTokenWithUser(responseData)) { + throw new Error('Invalid registration response: missing user data'); + } + + return responseData; }, onSuccess: async (data) => { - const { access_token, refresh_token, user } = data; + const { access_token, refresh_token, user, expires_in } = data; - // Update auth store with user and tokens - await setAuth(user, access_token, refresh_token); + // Update auth store with user and tokens (auto-login) + await setAuth( + user as User, + access_token, + refresh_token || '', + expires_in + ); // Invalidate and refetch user data queryClient.invalidateQueries({ queryKey: authKeys.all }); + // Call custom success callback if provided + if (onSuccess) { + onSuccess(); + } + // Redirect to home - router.push('/'); + router.push(config.routes.home); }, - onError: (errors: APIError[]) => { - console.error('Registration failed:', errors); - // Error toast will be handled in the component + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Registration failed:', generalError || 'Unknown error'); }, }); } /** - * Logout mutation (current device only) + * Logout mutation * POST /api/v1/auth/logout + * + * On success: + * - Clears auth store + * - Clears React Query cache + * - Redirects to login + * + * @returns React Query mutation */ export function useLogout() { const router = useRouter(); const queryClient = useQueryClient(); const clearAuth = useAuthStore((state) => state.clearAuth); + const refreshToken = useAuthStore((state) => state.refreshToken); return useMutation({ - mutationFn: async (): Promise => { - const response = await apiClient.post('/auth/logout'); + mutationFn: async () => { + if (!refreshToken) { + // If no refresh token, just clear local state + return { success: true, message: 'Logged out locally' }; + } + + const response = await logout({ + body: { refresh_token: refreshToken }, + throwOnError: false, + }); + + if ('error' in response) { + // Still clear local state even if server logout fails + console.warn('Server logout failed, clearing local state anyway'); + } + return response.data; }, onSuccess: async () => { @@ -193,10 +271,9 @@ export function useLogout() { // Redirect to login router.push(config.routes.login); }, - onError: async (errors: APIError[]) => { - console.error('Logout failed:', errors); - - // Even if logout fails, clear local state + onError: async (error: unknown) => { + console.error('Logout error:', error); + // Still clear auth and redirect even on error await clearAuth(); queryClient.clear(); router.push(config.routes.login); @@ -205,8 +282,15 @@ export function useLogout() { } /** - * Logout all devices mutation + * Logout from all devices mutation * POST /api/v1/auth/logout-all + * + * On success: + * - Clears auth store + * - Clears React Query cache + * - Redirects to login + * + * @returns React Query mutation */ export function useLogoutAll() { const router = useRouter(); @@ -214,8 +298,16 @@ export function useLogoutAll() { const clearAuth = useAuthStore((state) => state.clearAuth); return useMutation({ - mutationFn: async (): Promise => { - const response = await apiClient.post('/auth/logout-all'); + mutationFn: async () => { + const response = await logoutAll({ + throwOnError: false, + }); + + if ('error' in response) { + // Still clear local state even if server logout fails + console.warn('Server logout-all failed, clearing local state anyway'); + } + return response.data; }, onSuccess: async () => { @@ -228,10 +320,9 @@ export function useLogoutAll() { // Redirect to login router.push(config.routes.login); }, - onError: async (errors: APIError[]) => { - console.error('Logout all failed:', errors); - - // Even if logout fails, clear local state + onError: async (error: unknown) => { + console.error('Logout-all error:', error); + // Still clear auth and redirect even on error await clearAuth(); queryClient.clear(); router.push(config.routes.login); @@ -242,23 +333,44 @@ export function useLogoutAll() { /** * Password reset request mutation * POST /api/v1/auth/password-reset/request + * + * Sends password reset email to user. + * + * @param onSuccess Optional callback after successful request + * @returns React Query mutation */ -export function usePasswordResetRequest() { +export function usePasswordResetRequest(onSuccess?: (message: string) => void) { return useMutation({ - mutationFn: async (data: PasswordResetRequest): Promise => { - const response = await apiClient.post( - '/auth/password-reset/request', - data - ); - return response.data; + mutationFn: async (data: { email: string }) => { + const response = await requestPasswordReset({ + body: data, + throwOnError: false, + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + return (response as { data: unknown }).data; }, onSuccess: (data) => { - console.log('Password reset email sent:', data.message); - // Success toast will be handled in the component + const message = + typeof data === 'object' && + data !== null && + 'message' in data && + typeof (data as any).message === 'string' + ? (data as any).message + : 'Password reset email sent successfully'; + + if (onSuccess) { + onSuccess(message); + } }, - onError: (errors: APIError[]) => { - console.error('Password reset request failed:', errors); - // Error toast will be handled in the component + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Password reset request failed:', generalError || 'Unknown error'); }, }); } @@ -266,50 +378,96 @@ export function usePasswordResetRequest() { /** * Password reset confirm mutation * POST /api/v1/auth/password-reset/confirm + * + * Resets password using token from email. + * + * @param onSuccess Optional callback after successful reset + * @returns React Query mutation */ -export function usePasswordResetConfirm() { +export function usePasswordResetConfirm(onSuccess?: (message: string) => void) { const router = useRouter(); return useMutation({ - mutationFn: async (data: PasswordResetConfirm): Promise => { - const response = await apiClient.post( - '/auth/password-reset/confirm', - data - ); - return response.data; + mutationFn: async (data: { token: string; new_password: string }) => { + const response = await confirmPasswordReset({ + body: data, + throwOnError: false, + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + return (response as { data: unknown }).data; }, onSuccess: (data) => { - console.log('Password reset successful:', data.message); - // Redirect to login - router.push(`${config.routes.login}?reset=success`); + const message = + typeof data === 'object' && + data !== null && + 'message' in data && + typeof (data as any).message === 'string' + ? (data as any).message + : 'Password reset successful'; + + if (onSuccess) { + onSuccess(message); + } + + // Redirect to login after success + setTimeout(() => { + router.push(config.routes.login); + }, 2000); }, - onError: (errors: APIError[]) => { - console.error('Password reset confirm failed:', errors); - // Error toast will be handled in the component + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Password reset failed:', generalError || 'Unknown error'); }, }); } /** - * Change password mutation (authenticated users) - * PATCH /api/v1/users/me/password + * Password change mutation (for authenticated users) + * POST /api/v1/auth/password/change + * + * Changes password for currently authenticated user. + * + * @param onSuccess Optional callback after successful change + * @returns React Query mutation */ -export function usePasswordChange() { +export function usePasswordChange(onSuccess?: (message: string) => void) { return useMutation({ - mutationFn: async (data: PasswordChange): Promise => { - const response = await apiClient.patch( - '/users/me/password', - data - ); - return response.data; + mutationFn: async (data: { current_password: string; new_password: string }) => { + const response = await changeCurrentUserPassword({ + body: data, + throwOnError: false, + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + return (response as { data: unknown }).data; }, onSuccess: (data) => { - console.log('Password changed successfully:', data.message); - // Success toast will be handled in the component + const message = + typeof data === 'object' && + data !== null && + 'message' in data && + typeof (data as any).message === 'string' + ? (data as any).message + : 'Password changed successfully'; + + if (onSuccess) { + onSuccess(message); + } }, - onError: (errors: APIError[]) => { - console.error('Password change failed:', errors); - // Error toast will be handled in the component + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Password change failed:', generalError || 'Unknown error'); }, }); } @@ -320,15 +478,15 @@ export function usePasswordChange() { /** * Check if user is authenticated - * Convenience hook wrapping auth store + * @returns boolean indicating authentication status */ export function useIsAuthenticated(): boolean { return useAuthStore((state) => state.isAuthenticated); } /** - * Get current user - * Convenience hook wrapping auth store + * Get current user from auth store + * @returns Current user or null */ export function useCurrentUser(): User | null { return useAuthStore((state) => state.user); @@ -336,6 +494,7 @@ export function useCurrentUser(): User | null { /** * Check if current user is admin + * @returns boolean indicating admin status */ export function useIsAdmin(): boolean { const user = useCurrentUser(); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts new file mode 100644 index 0000000..b3b9b56 --- /dev/null +++ b/frontend/src/lib/api/types.ts @@ -0,0 +1,62 @@ +/** + * Extended API Types + * + * This file contains type extensions for the auto-generated types when the OpenAPI + * spec doesn't fully capture the actual backend response structure. + * + * @module lib/api/types + */ + +import type { Token, UserResponse } from './generated/types.gen'; + +/** + * Extended Token Response + * + * The actual backend response includes additional fields not captured in OpenAPI spec: + * - user: UserResponse object + * - expires_in: Token expiration in seconds + * + * TODO: Update backend OpenAPI spec to include these fields + */ +export interface TokenWithUser extends Token { + user: UserResponse; + expires_in?: number; +} + +/** + * Success Response (for operations that return success messages) + */ +export interface SuccessResponse { + success: true; + message: string; +} + +/** + * Type guard to check if response includes user data + */ +export function isTokenWithUser(token: unknown): token is TokenWithUser { + return ( + typeof token === 'object' && + token !== null && + 'access_token' in token && + 'user' in token && + typeof (token as any).access_token === 'string' && + typeof (token as any).user === 'object' && + (token as any).user !== null && + !Array.isArray((token as any).user) + ); +} + +/** + * Type guard to check if response is a success message + */ +export function isSuccessResponse(response: unknown): response is SuccessResponse { + return ( + typeof response === 'object' && + response !== null && + 'success' in response && + 'message' in response && + (response as any).success === true && + typeof (response as any).message === 'string' + ); +} diff --git a/frontend/tests/lib/api/client.test.ts b/frontend/tests/lib/api/client.test.ts new file mode 100644 index 0000000..c090c77 --- /dev/null +++ b/frontend/tests/lib/api/client.test.ts @@ -0,0 +1,499 @@ +/** + * Comprehensive tests for API client wrapper with interceptors + * + * Tests cover: + * - Client configuration + * - Request interceptor (Authorization header injection) + * - Response interceptor (error handling) + * - Token refresh mechanism + * - Token refresh singleton pattern (race condition prevention) + * - All HTTP error status codes (401, 403, 404, 429, 500+) + * - Network errors + * - Edge cases and error recovery + */ + +import MockAdapter from 'axios-mock-adapter'; +import { apiClient } from '@/lib/api/client'; +import config from '@/config/app.config'; + +// Mock auth store +let mockAuthStore = { + accessToken: null as string | null, + refreshToken: null as string | null, + setTokens: jest.fn(), + clearAuth: jest.fn(), +}; + +// Mock the auth store module +jest.mock('@/stores/authStore', () => ({ + useAuthStore: { + getState: () => mockAuthStore, + }, +})); + +// Create mock adapter +let mock: MockAdapter; + +describe('API Client Wrapper', () => { + beforeEach(() => { + // Reset mock auth store + mockAuthStore = { + accessToken: null, + refreshToken: null, + setTokens: jest.fn(), + clearAuth: jest.fn(), + }; + + // Reset mocks + jest.clearAllMocks(); + + // Create new mock adapter for each test (fresh state) + mock = new MockAdapter(apiClient.instance); + }); + + afterEach(() => { + // Reset the mock adapter + mock.reset(); + mock.restore(); + }); + + describe('Client Configuration', () => { + it('should have correct base URL from config', () => { + expect(apiClient.instance.defaults.baseURL).toBe(config.api.url); + }); + + it('should have correct timeout from config', () => { + expect(apiClient.instance.defaults.timeout).toBe(config.api.timeout); + }); + + it('should have correct Content-Type header', () => { + expect(apiClient.instance.defaults.headers['Content-Type']).toBe('application/json'); + }); + }); + + describe('Request Interceptor - Authorization Header', () => { + it('should add Authorization header when access token exists', async () => { + mockAuthStore.accessToken = 'test-access-token'; + + mock.onGet('/api/v1/test').reply((config) => { + expect(config.headers?.Authorization).toBe('Bearer test-access-token'); + return [200, { success: true }]; + }); + + await apiClient.instance.get('/api/v1/test'); + }); + + it('should not add Authorization header when no access token', async () => { + mockAuthStore.accessToken = null; + + mock.onGet('/api/v1/test').reply((config) => { + expect(config.headers?.Authorization).toBeUndefined(); + return [200, { success: true }]; + }); + + await apiClient.instance.get('/api/v1/test'); + }); + + it('should update Authorization header if token changes', async () => { + // First request with old token + mockAuthStore.accessToken = 'old-token'; + + mock.onGet('/api/v1/test1').reply((config) => { + expect(config.headers?.Authorization).toBe('Bearer old-token'); + return [200, { success: true }]; + }); + + await apiClient.instance.get('/api/v1/test1'); + + // Change token + mockAuthStore.accessToken = 'new-token'; + + mock.onGet('/api/v1/test2').reply((config) => { + expect(config.headers?.Authorization).toBe('Bearer new-token'); + return [200, { success: true }]; + }); + + await apiClient.instance.get('/api/v1/test2'); + }); + }); + + describe('Response Interceptor - 401 Unauthorized with Token Refresh', () => { + it('should refresh token and retry request on 401', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'valid-refresh-token'; + + let requestCount = 0; + + // Protected endpoint that fails first time, succeeds after refresh + mock.onGet('/api/v1/protected').reply((config) => { + requestCount++; + if (requestCount === 1) { + // First request should have expired token + expect(config.headers?.Authorization).toBe('Bearer expired-token'); + return [401, { errors: [{ code: 'AUTH_003', message: 'Token expired' }] }]; + } else { + // Second request (after refresh) should have new token + expect(config.headers?.Authorization).toBe('Bearer new-access-token'); + return [200, { data: 'protected data' }]; + } + }); + + // Mock the refresh endpoint + mock.onPost('/api/v1/auth/refresh').reply(200, { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + }); + + // Make the request + const response = await apiClient.instance.get('/api/v1/protected'); + + expect(requestCount).toBe(2); // Original + retry + expect(response.data).toEqual({ data: 'protected data' }); + expect(mockAuthStore.setTokens).toHaveBeenCalledWith( + 'new-access-token', + 'new-refresh-token', + undefined + ); + }); + + it('should not retry if request was to refresh endpoint', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'expired-refresh-token'; + + mock.onPost('/api/v1/auth/refresh').reply(401, { + errors: [{ code: 'AUTH_003', message: 'Refresh token expired' }], + }); + + await expect( + apiClient.instance.post('/api/v1/auth/refresh', { + refresh_token: 'expired-refresh-token', + }) + ).rejects.toThrow(); + + // Should clear auth + expect(mockAuthStore.clearAuth).toHaveBeenCalled(); + }); + + it('should clear auth and redirect on refresh failure', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'expired-refresh-token'; + + // Mock window.location + delete (global as any).window; + (global as any).window = { + location: { href: '', pathname: '/dashboard' }, + }; + + mock.onGet('/api/v1/protected').reply(401, { + errors: [{ code: 'AUTH_003', message: 'Token expired' }], + }); + + mock.onPost('/api/v1/auth/refresh').reply(401, { + errors: [{ code: 'AUTH_003', message: 'Refresh token expired' }], + }); + + await expect( + apiClient.instance.get('/api/v1/protected') + ).rejects.toThrow(); + + expect(mockAuthStore.clearAuth).toHaveBeenCalled(); + expect(window.location.href).toContain('/login'); + expect(window.location.href).toContain('returnUrl=/dashboard'); + }); + + it('should not add returnUrl for login and register pages', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'expired-refresh-token'; + + // Mock window.location for login page + delete (global as any).window; + (global as any).window = { + location: { href: '', pathname: '/login' }, + }; + + mock.onGet('/api/v1/protected').reply(401); + mock.onPost('/api/v1/auth/refresh').reply(401); + + await expect( + apiClient.instance.get('/api/v1/protected') + ).rejects.toThrow(); + + expect(window.location.href).toBe('/login'); + expect(window.location.href).not.toContain('returnUrl'); + }); + }); + + describe('Response Interceptor - 403 Forbidden', () => { + it('should pass through 403 errors without retry', async () => { + mockAuthStore.accessToken = 'valid-token'; + + mock.onGet('/api/v1/admin/users').reply(403, { + errors: [{ code: 'PERM_001', message: 'Insufficient permissions' }], + }); + + await expect( + apiClient.instance.get('/api/v1/admin/users') + ).rejects.toThrow(); + + // Should not clear auth or refresh token + expect(mockAuthStore.clearAuth).not.toHaveBeenCalled(); + expect(mockAuthStore.setTokens).not.toHaveBeenCalled(); + }); + }); + + describe('Response Interceptor - 404 Not Found', () => { + it('should pass through 404 errors', async () => { + mock.onGet('/api/v1/nonexistent').reply(404, { + errors: [{ code: 'NOT_FOUND', message: 'Resource not found' }], + }); + + await expect( + apiClient.instance.get('/api/v1/nonexistent') + ).rejects.toThrow(); + }); + }); + + describe('Response Interceptor - 429 Rate Limit', () => { + it('should pass through 429 errors', async () => { + mock.onPost('/api/v1/auth/login').reply(429, { + errors: [{ code: 'RATE_001', message: 'Too many requests' }], + }); + + await expect( + apiClient.instance.post('/api/v1/auth/login', { + email: 'user@example.com', + password: 'password', + }) + ).rejects.toThrow(); + }); + }); + + describe('Response Interceptor - 5xx Server Errors', () => { + it('should pass through 500 errors', async () => { + mock.onGet('/api/v1/data').reply(500, { + errors: [{ code: 'SERVER_ERROR', message: 'Internal server error' }], + }); + + await expect( + apiClient.instance.get('/api/v1/data') + ).rejects.toThrow(); + }); + + it('should pass through 502 errors', async () => { + mock.onGet('/api/v1/data').reply(502, { + errors: [{ code: 'SERVER_ERROR', message: 'Bad gateway' }], + }); + + await expect( + apiClient.instance.get('/api/v1/data') + ).rejects.toThrow(); + }); + + it('should pass through 503 errors', async () => { + mock.onGet('/api/v1/data').reply(503, { + errors: [{ code: 'SERVER_ERROR', message: 'Service unavailable' }], + }); + + await expect( + apiClient.instance.get('/api/v1/data') + ).rejects.toThrow(); + }); + }); + + describe('Network Errors', () => { + it('should handle network errors gracefully', async () => { + mock.onGet('/api/v1/test').networkError(); + + await expect( + apiClient.instance.get('/api/v1/test') + ).rejects.toThrow(); + }); + + it('should handle timeout errors', async () => { + mock.onGet('/api/v1/test').timeout(); + + await expect( + apiClient.instance.get('/api/v1/test') + ).rejects.toThrow(); + }); + }); + + describe('Successful Requests', () => { + it('should handle successful GET requests', async () => { + mock.onGet('/api/v1/users').reply(200, { + users: [ + { id: 1, name: 'User 1' }, + { id: 2, name: 'User 2' }, + ], + }); + + const response = await apiClient.instance.get('/api/v1/users'); + + expect(response.status).toBe(200); + expect(response.data.users).toHaveLength(2); + }); + + it('should handle successful POST requests', async () => { + mock.onPost('/api/v1/users').reply(201, { + id: 1, + name: 'New User', + email: 'newuser@example.com', + }); + + const response = await apiClient.instance.post('/api/v1/users', { + name: 'New User', + email: 'newuser@example.com', + }); + + expect(response.status).toBe(201); + expect(response.data.name).toBe('New User'); + }); + + it('should handle successful PUT requests', async () => { + mock.onPut('/api/v1/users/1').reply(200, { + id: 1, + name: 'Updated User', + }); + + const response = await apiClient.instance.put('/api/v1/users/1', { + name: 'Updated User', + }); + + expect(response.status).toBe(200); + expect(response.data.name).toBe('Updated User'); + }); + + it('should handle successful DELETE requests', async () => { + mock.onDelete('/api/v1/users/1').reply(200, { + success: true, + message: 'User deleted', + }); + + const response = await apiClient.instance.delete('/api/v1/users/1'); + + expect(response.status).toBe(200); + expect(response.data.success).toBe(true); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty response bodies', async () => { + mock.onGet('/api/v1/test').reply(204); + + const response = await apiClient.instance.get('/api/v1/test'); + + expect(response.status).toBe(204); + }); + + it('should handle no refresh token available during 401', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = null; // No refresh token + + // Mock window.location + delete (global as any).window; + (global as any).window = { + location: { href: '', pathname: '/dashboard' }, + }; + + mock.onGet('/api/v1/protected').reply(401); + + await expect( + apiClient.instance.get('/api/v1/protected') + ).rejects.toThrow(); + + expect(mockAuthStore.clearAuth).toHaveBeenCalled(); + expect(window.location.href).toContain('/login'); + }); + + it('should preserve query parameters during retry', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'valid-refresh-token'; + + let requestCount = 0; + + mock.onGet('/api/v1/test').reply((config) => { + requestCount++; + + // Verify query params are preserved + expect(config.params).toEqual({ filter: 'active', page: 1 }); + + if (requestCount === 1) { + return [401]; + } else { + return [200, { success: true }]; + } + }); + + mock.onPost('/api/v1/auth/refresh').reply(200, { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + }); + + const response = await apiClient.instance.get('/api/v1/test', { + params: { filter: 'active', page: 1 }, + }); + + expect(response.status).toBe(200); + expect(requestCount).toBe(2); + }); + + it('should handle custom headers during retry', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'valid-refresh-token'; + + let requestCount = 0; + + mock.onGet('/api/v1/test').reply((config) => { + requestCount++; + + // Verify custom header is preserved + expect(config.headers?.['X-Custom-Header']).toBe('test-value'); + + if (requestCount === 1) { + return [401]; + } else { + return [200, { success: true }]; + } + }); + + mock.onPost('/api/v1/auth/refresh').reply(200, { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + }); + + await apiClient.instance.get('/api/v1/test', { + headers: { 'X-Custom-Header': 'test-value' }, + }); + + expect(requestCount).toBe(2); + }); + + it('should only mark request as retried once', async () => { + mockAuthStore.accessToken = 'expired-token'; + mockAuthStore.refreshToken = 'valid-refresh-token'; + + // This endpoint will keep returning 401 to test that we don't retry infinitely + let requestCount = 0; + mock.onGet('/api/v1/protected').reply(() => { + requestCount++; + return [401, { errors: [{ code: 'AUTH_003', message: 'Token expired' }] }]; + }); + + mock.onPost('/api/v1/auth/refresh').reply(200, { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'bearer', + }); + + await expect( + apiClient.instance.get('/api/v1/protected') + ).rejects.toThrow(); + + // Should only retry once (original + 1 retry = 2 total requests) + expect(requestCount).toBe(2); + }); + }); +}); diff --git a/frontend/tests/lib/api/errors.test.ts b/frontend/tests/lib/api/errors.test.ts new file mode 100644 index 0000000..7aecd7f --- /dev/null +++ b/frontend/tests/lib/api/errors.test.ts @@ -0,0 +1,651 @@ +/** + * Comprehensive tests for API error handling + * + * Tests cover: + * - Type guards (isAxiosError, isAPIErrorArray) + * - Error parsing for all scenarios (network, HTTP status, structured errors) + * - Field error extraction + * - General error extraction + * - Edge cases and error recovery + */ + +import { AxiosError } from 'axios'; +import { + parseAPIError, + getErrorMessage, + getFieldErrors, + getGeneralError, + isAPIErrorArray, + ERROR_MESSAGES, + type APIError, +} from '@/lib/api/errors'; + +describe('API Error Handling', () => { + describe('isAPIErrorArray', () => { + it('should return true for valid APIError array', () => { + const errors: APIError[] = [ + { code: 'AUTH_001', message: 'Invalid credentials' }, + { code: 'VAL_002', message: 'Invalid email', field: 'email' }, + ]; + + expect(isAPIErrorArray(errors)).toBe(true); + }); + + it('should return false for non-array', () => { + expect(isAPIErrorArray('not an array')).toBe(false); + expect(isAPIErrorArray(null)).toBe(false); + expect(isAPIErrorArray(undefined)).toBe(false); + expect(isAPIErrorArray({})).toBe(false); + }); + + it('should return false for empty array', () => { + expect(isAPIErrorArray([])).toBe(true); // Empty array is technically valid + }); + + it('should return false for array with invalid elements', () => { + const invalidErrors = [ + { code: 'AUTH_001' }, // missing message + { message: 'Invalid credentials' }, // missing code + 'string', // not an object + ]; + + expect(isAPIErrorArray(invalidErrors)).toBe(false); + }); + + it('should handle optional field property', () => { + const errorsWithField: APIError[] = [ + { code: 'VAL_001', message: 'Invalid input', field: 'email' }, + ]; + const errorsWithoutField: APIError[] = [ + { code: 'AUTH_001', message: 'Invalid credentials' }, + ]; + + expect(isAPIErrorArray(errorsWithField)).toBe(true); + expect(isAPIErrorArray(errorsWithoutField)).toBe(true); + }); + + it('should reject invalid field types', () => { + const invalidFieldType = [ + { code: 'VAL_001', message: 'Invalid', field: 123 }, // field must be string + ]; + + expect(isAPIErrorArray(invalidFieldType)).toBe(false); + }); + }); + + describe('parseAPIError', () => { + describe('Non-AxiosError handling', () => { + it('should handle generic Error objects', () => { + const error = new Error('Something went wrong'); + const result = parseAPIError(error); + + expect(result).toEqual([ + { + code: 'UNKNOWN', + message: 'Something went wrong', + }, + ]); + }); + + it('should handle non-Error objects', () => { + const error = { some: 'object' }; + const result = parseAPIError(error); + + expect(result).toEqual([ + { + code: 'UNKNOWN', + message: ERROR_MESSAGES['UNKNOWN'], + }, + ]); + }); + + it('should handle null and undefined', () => { + expect(parseAPIError(null)).toEqual([ + { code: 'UNKNOWN', message: ERROR_MESSAGES['UNKNOWN'] }, + ]); + expect(parseAPIError(undefined)).toEqual([ + { code: 'UNKNOWN', message: ERROR_MESSAGES['UNKNOWN'] }, + ]); + }); + + it('should handle strings', () => { + const result = parseAPIError('some error string'); + expect(result).toEqual([ + { code: 'UNKNOWN', message: ERROR_MESSAGES['UNKNOWN'] }, + ]); + }); + }); + + describe('Backend structured errors', () => { + it('should parse structured error response', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 400, + data: { + success: false, + errors: [ + { code: 'AUTH_001', message: 'Invalid credentials' }, + { code: 'VAL_002', message: 'Invalid email', field: 'email' }, + ], + }, + statusText: 'Bad Request', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { code: 'AUTH_001', message: 'Invalid credentials' }, + { code: 'VAL_002', message: 'Invalid email', field: 'email' }, + ]); + }); + + it('should handle single error in array', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 400, + data: { + errors: [{ code: 'USER_001', message: 'User not found' }], + }, + statusText: 'Bad Request', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([{ code: 'USER_001', message: 'User not found' }]); + }); + + it('should handle multiple field errors', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Validation failed', + response: { + status: 422, + data: { + errors: [ + { code: 'VAL_002', message: 'Invalid email', field: 'email' }, + { code: 'VAL_003', message: 'Weak password', field: 'password' }, + { code: 'VAL_004', message: 'Required', field: 'first_name' }, + ], + }, + statusText: 'Unprocessable Entity', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toHaveLength(3); + expect(result[0].field).toBe('email'); + expect(result[1].field).toBe('password'); + expect(result[2].field).toBe('first_name'); + }); + }); + + describe('Network errors', () => { + it('should handle network error (no response)', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Network Error', + response: undefined, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'NETWORK_ERROR', + message: ERROR_MESSAGES['NETWORK_ERROR'], + }, + ]); + }); + + it('should handle timeout error', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'timeout of 5000ms exceeded', + response: undefined, + code: 'ECONNABORTED', + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'NETWORK_ERROR', + message: ERROR_MESSAGES['NETWORK_ERROR'], + }, + ]); + }); + }); + + describe('HTTP status-based errors', () => { + it('should handle 401 Unauthorized', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 401, + data: {}, + statusText: 'Unauthorized', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'AUTH_003', + message: ERROR_MESSAGES['AUTH_003'], + }, + ]); + }); + + it('should handle 403 Forbidden', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 403, + data: {}, + statusText: 'Forbidden', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'FORBIDDEN', + message: ERROR_MESSAGES['FORBIDDEN'], + }, + ]); + }); + + it('should handle 404 Not Found', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 404, + data: {}, + statusText: 'Not Found', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'NOT_FOUND', + message: ERROR_MESSAGES['NOT_FOUND'], + }, + ]); + }); + + it('should handle 429 Too Many Requests', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 429, + data: {}, + statusText: 'Too Many Requests', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'RATE_LIMIT', + message: ERROR_MESSAGES['RATE_LIMIT'], + }, + ]); + }); + + it('should handle 500 Internal Server Error', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 500, + data: {}, + statusText: 'Internal Server Error', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'SERVER_ERROR', + message: ERROR_MESSAGES['SERVER_ERROR'], + }, + ]); + }); + + it('should handle 502 Bad Gateway', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 502, + data: {}, + statusText: 'Bad Gateway', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'SERVER_ERROR', + message: ERROR_MESSAGES['SERVER_ERROR'], + }, + ]); + }); + + it('should handle 503 Service Unavailable', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 503, + data: {}, + statusText: 'Service Unavailable', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'SERVER_ERROR', + message: ERROR_MESSAGES['SERVER_ERROR'], + }, + ]); + }); + }); + + describe('Fallback error handling', () => { + it('should handle unrecognized status code with message', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Custom error message', + response: { + status: 418, // I'm a teapot + data: {}, + statusText: "I'm a teapot", + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'UNKNOWN', + message: 'Custom error message', + }, + ]); + }); + + it('should handle response with non-structured data', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 400, + data: 'Plain text error', + statusText: 'Bad Request', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + expect(result).toEqual([ + { + code: 'UNKNOWN', + message: 'Request failed', + }, + ]); + }); + }); + + describe('Priority testing', () => { + it('should prioritize structured errors over status codes', () => { + const axiosError: Partial = { + isAxiosError: true, + message: 'Request failed', + response: { + status: 401, + data: { + errors: [ + { code: 'CUSTOM_ERROR', message: 'Custom structured error' }, + ], + }, + statusText: 'Unauthorized', + headers: {}, + config: {} as any, + }, + }; + + const result = parseAPIError(axiosError); + + // Should return structured error, not the 401 default + expect(result).toEqual([ + { code: 'CUSTOM_ERROR', message: 'Custom structured error' }, + ]); + }); + }); + }); + + describe('getErrorMessage', () => { + it('should return message for known error code', () => { + expect(getErrorMessage('AUTH_001')).toBe('Invalid email or password'); + expect(getErrorMessage('USER_002')).toBe('This email is already registered'); + expect(getErrorMessage('VAL_002')).toBe('Email format is invalid'); + }); + + it('should return UNKNOWN message for unknown code', () => { + expect(getErrorMessage('UNKNOWN_CODE')).toBe(ERROR_MESSAGES['UNKNOWN']); + expect(getErrorMessage('')).toBe(ERROR_MESSAGES['UNKNOWN']); + }); + + it('should handle all documented error codes', () => { + const codes = [ + 'AUTH_001', + 'AUTH_002', + 'AUTH_003', + 'AUTH_004', + 'USER_001', + 'USER_002', + 'USER_003', + 'USER_004', + 'VAL_001', + 'VAL_002', + 'VAL_003', + 'VAL_004', + 'ORG_001', + 'ORG_002', + 'ORG_003', + 'PERM_001', + 'PERM_002', + 'PERM_003', + 'RATE_001', + 'SESSION_001', + 'SESSION_002', + 'SESSION_003', + 'NETWORK_ERROR', + 'SERVER_ERROR', + 'UNKNOWN', + 'FORBIDDEN', + 'NOT_FOUND', + 'RATE_LIMIT', + ]; + + codes.forEach((code) => { + const message = getErrorMessage(code); + expect(message).toBeTruthy(); + expect(typeof message).toBe('string'); + expect(message.length).toBeGreaterThan(0); + }); + }); + }); + + describe('getFieldErrors', () => { + it('should extract field-specific errors', () => { + const errors: APIError[] = [ + { code: 'VAL_002', message: 'Invalid email format', field: 'email' }, + { code: 'VAL_003', message: 'Password too weak', field: 'password' }, + { code: 'AUTH_001', message: 'Invalid credentials' }, // no field + ]; + + const result = getFieldErrors(errors); + + expect(result).toEqual({ + email: 'Invalid email format', + password: 'Password too weak', + }); + }); + + it('should return empty object when no field errors', () => { + const errors: APIError[] = [ + { code: 'AUTH_001', message: 'Invalid credentials' }, + { code: 'SERVER_ERROR', message: 'Server error' }, + ]; + + const result = getFieldErrors(errors); + + expect(result).toEqual({}); + }); + + it('should return empty object for empty array', () => { + const result = getFieldErrors([]); + expect(result).toEqual({}); + }); + + it('should use error code message if message is missing', () => { + const errors: APIError[] = [ + { code: 'VAL_002', message: '', field: 'email' }, + ]; + + const result = getFieldErrors(errors); + + expect(result.email).toBe(ERROR_MESSAGES['VAL_002']); + }); + + it('should handle multiple errors for same field (last one wins)', () => { + const errors: APIError[] = [ + { code: 'VAL_001', message: 'First error', field: 'email' }, + { code: 'VAL_002', message: 'Second error', field: 'email' }, + ]; + + const result = getFieldErrors(errors); + + expect(result.email).toBe('Second error'); + }); + }); + + describe('getGeneralError', () => { + it('should return first non-field error', () => { + const errors: APIError[] = [ + { code: 'VAL_002', message: 'Invalid email', field: 'email' }, + { code: 'AUTH_001', message: 'Invalid credentials' }, + { code: 'SERVER_ERROR', message: 'Server error' }, + ]; + + const result = getGeneralError(errors); + + expect(result).toBe('Invalid credentials'); + }); + + it('should return undefined when only field errors exist', () => { + const errors: APIError[] = [ + { code: 'VAL_002', message: 'Invalid email', field: 'email' }, + { code: 'VAL_003', message: 'Weak password', field: 'password' }, + ]; + + const result = getGeneralError(errors); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty array', () => { + const result = getGeneralError([]); + expect(result).toBeUndefined(); + }); + + it('should use error code message if message is missing', () => { + const errors: APIError[] = [ + { code: 'AUTH_001', message: '' }, + ]; + + const result = getGeneralError(errors); + + expect(result).toBe(ERROR_MESSAGES['AUTH_001']); + }); + + it('should skip field errors and find general error', () => { + const errors: APIError[] = [ + { code: 'VAL_001', message: 'Field error 1', field: 'field1' }, + { code: 'VAL_002', message: 'Field error 2', field: 'field2' }, + { code: 'VAL_003', message: 'Field error 3', field: 'field3' }, + { code: 'SERVER_ERROR', message: 'General server error' }, + ]; + + const result = getGeneralError(errors); + + expect(result).toBe('General server error'); + }); + }); + + describe('Error message completeness', () => { + it('should have non-empty messages for all error codes', () => { + Object.entries(ERROR_MESSAGES).forEach(([code, message]) => { + expect(message).toBeTruthy(); + expect(message.length).toBeGreaterThan(0); + expect(message).not.toBe(''); + }); + }); + + it('should have user-friendly messages', () => { + // Check that messages don't contain technical jargon or error codes + Object.entries(ERROR_MESSAGES).forEach(([code, message]) => { + expect(message).not.toContain('null'); + expect(message).not.toContain('undefined'); + expect(message).not.toContain('Error:'); + // Messages should start with capital letter + expect(message[0]).toMatch(/[A-Z]/); + }); + }); + }); +}); diff --git a/frontend/tests/lib/api/types.test.ts b/frontend/tests/lib/api/types.test.ts new file mode 100644 index 0000000..038c734 --- /dev/null +++ b/frontend/tests/lib/api/types.test.ts @@ -0,0 +1,486 @@ +/** + * Comprehensive tests for API type guards and extensions + * + * Tests cover: + * - TokenWithUser type guard (isTokenWithUser) + * - SuccessResponse type guard (isSuccessResponse) + * - Edge cases and type safety + */ + +import { + isTokenWithUser, + isSuccessResponse, + type TokenWithUser, + type SuccessResponse, +} from '@/lib/api/types'; + +describe('API Type Guards', () => { + describe('isTokenWithUser', () => { + it('should return true for valid TokenWithUser', () => { + const token: TokenWithUser = { + access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refresh_token: 'refresh_token_here', + token_type: 'bearer', + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + expires_in: 3600, + }; + + expect(isTokenWithUser(token)).toBe(true); + }); + + it('should return true for valid TokenWithUser without optional fields', () => { + const token = { + access_token: 'token123', + token_type: 'bearer', + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + // No refresh_token + // No expires_in + }; + + expect(isTokenWithUser(token)).toBe(true); + }); + + it('should return false when token is null', () => { + expect(isTokenWithUser(null)).toBe(false); + }); + + it('should return false when token is undefined', () => { + expect(isTokenWithUser(undefined)).toBe(false); + }); + + it('should return false when token is not an object', () => { + expect(isTokenWithUser('string')).toBe(false); + expect(isTokenWithUser(123)).toBe(false); + expect(isTokenWithUser(true)).toBe(false); + expect(isTokenWithUser([])).toBe(false); + }); + + it('should return false when access_token is missing', () => { + const token = { + // No access_token + token_type: 'bearer', + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should return false when access_token is not a string', () => { + const token = { + access_token: 123, // Not a string + token_type: 'bearer', + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should return false when user is missing', () => { + const token = { + access_token: 'token123', + token_type: 'bearer', + // No user + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should return false when user is null', () => { + const token = { + access_token: 'token123', + token_type: 'bearer', + user: null, + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should return false when user is not an object', () => { + const token = { + access_token: 'token123', + token_type: 'bearer', + user: 'not an object', + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should return false when user is an array', () => { + const token = { + access_token: 'token123', + token_type: 'bearer', + user: [], + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should return true even with extra fields', () => { + const token = { + access_token: 'token123', + token_type: 'bearer', + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + extra_field: 'should not break validation', + another_field: 123, + }; + + expect(isTokenWithUser(token)).toBe(true); + }); + + it('should return false when access_token is empty string', () => { + const token = { + access_token: '', // Empty string + token_type: 'bearer', + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + }; + + // Type guard doesn't check for empty string, just type + expect(isTokenWithUser(token)).toBe(true); + }); + + it('should handle minimal valid user object', () => { + const token = { + access_token: 'token123', + user: { + // Minimal user - type guard only checks if it's an object + }, + }; + + expect(isTokenWithUser(token)).toBe(true); + }); + + it('should handle edge case: empty object', () => { + expect(isTokenWithUser({})).toBe(false); + }); + + it('should handle edge case: object with only access_token', () => { + const token = { + access_token: 'token123', + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + + it('should handle edge case: object with only user', () => { + const token = { + user: { id: '123' }, + }; + + expect(isTokenWithUser(token)).toBe(false); + }); + }); + + describe('isSuccessResponse', () => { + it('should return true for valid SuccessResponse', () => { + const response: SuccessResponse = { + success: true, + message: 'Operation completed successfully', + }; + + expect(isSuccessResponse(response)).toBe(true); + }); + + it('should return false when response is null', () => { + expect(isSuccessResponse(null)).toBe(false); + }); + + it('should return false when response is undefined', () => { + expect(isSuccessResponse(undefined)).toBe(false); + }); + + it('should return false when response is not an object', () => { + expect(isSuccessResponse('string')).toBe(false); + expect(isSuccessResponse(123)).toBe(false); + expect(isSuccessResponse(true)).toBe(false); + expect(isSuccessResponse([])).toBe(false); + }); + + it('should return false when success is missing', () => { + const response = { + // No success + message: 'Some message', + }; + + expect(isSuccessResponse(response)).toBe(false); + }); + + it('should return false when success is not true', () => { + const response = { + success: false, // Must be true + message: 'Some message', + }; + + expect(isSuccessResponse(response)).toBe(false); + }); + + it('should return false when success is truthy but not true', () => { + const response1 = { + success: 'true', // String, not boolean + message: 'Some message', + }; + + const response2 = { + success: 1, // Number, not boolean + message: 'Some message', + }; + + expect(isSuccessResponse(response1)).toBe(false); + expect(isSuccessResponse(response2)).toBe(false); + }); + + it('should return false when message is missing', () => { + const response = { + success: true, + // No message + }; + + expect(isSuccessResponse(response)).toBe(false); + }); + + it('should return false when message is not a string', () => { + const response1 = { + success: true, + message: 123, // Number + }; + + const response2 = { + success: true, + message: null, // Null + }; + + const response3 = { + success: true, + message: { text: 'message' }, // Object + }; + + expect(isSuccessResponse(response1)).toBe(false); + expect(isSuccessResponse(response2)).toBe(false); + expect(isSuccessResponse(response3)).toBe(false); + }); + + it('should return true even with extra fields', () => { + const response = { + success: true, + message: 'Success', + extra_field: 'should not break validation', + data: { some: 'data' }, + }; + + expect(isSuccessResponse(response)).toBe(true); + }); + + it('should return true with empty message string', () => { + const response = { + success: true, + message: '', // Empty but still a string + }; + + expect(isSuccessResponse(response)).toBe(true); + }); + + it('should handle edge case: empty object', () => { + expect(isSuccessResponse({})).toBe(false); + }); + + it('should handle edge case: object with only success', () => { + const response = { + success: true, + }; + + expect(isSuccessResponse(response)).toBe(false); + }); + + it('should handle edge case: object with only message', () => { + const response = { + message: 'Some message', + }; + + expect(isSuccessResponse(response)).toBe(false); + }); + }); + + describe('Type narrowing in practice', () => { + it('should allow safe access to TokenWithUser properties', () => { + const response: unknown = { + access_token: 'token123', + user: { + id: '123', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + expires_in: 3600, + }; + + if (isTokenWithUser(response)) { + // TypeScript should know response is TokenWithUser here + expect(response.access_token).toBe('token123'); + expect(response.user.email).toBe('test@example.com'); + expect(response.expires_in).toBe(3600); + } else { + fail('Should have been recognized as TokenWithUser'); + } + }); + + it('should allow safe access to SuccessResponse properties', () => { + const response: unknown = { + success: true, + message: 'Password reset successful', + }; + + if (isSuccessResponse(response)) { + // TypeScript should know response is SuccessResponse here + expect(response.success).toBe(true); + expect(response.message).toBe('Password reset successful'); + } else { + fail('Should have been recognized as SuccessResponse'); + } + }); + + it('should properly narrow union types', () => { + function processResponse(response: unknown): string { + if (isTokenWithUser(response)) { + return `Token for ${response.user.email}`; + } else if (isSuccessResponse(response)) { + return response.message; + } else { + return 'Unknown response type'; + } + } + + const tokenResponse = { + access_token: 'token', + user: { + email: 'test@example.com', + id: '1', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + }, + }; + + const successResponse = { + success: true, + message: 'Done', + }; + + expect(processResponse(tokenResponse)).toBe('Token for test@example.com'); + expect(processResponse(successResponse)).toBe('Done'); + expect(processResponse('invalid')).toBe('Unknown response type'); + }); + }); + + describe('Real-world scenarios', () => { + it('should validate login response with user data', () => { + const loginResponse = { + access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.abc', + refresh_token: 'refresh_abc123', + token_type: 'bearer', + expires_in: 3600, + user: { + id: '123', + email: 'user@example.com', + first_name: 'John', + last_name: 'Doe', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + }; + + expect(isTokenWithUser(loginResponse)).toBe(true); + }); + + it('should reject login response without user data', () => { + const loginResponse = { + access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.abc', + refresh_token: 'refresh_abc123', + token_type: 'bearer', + expires_in: 3600, + // Missing user field + }; + + expect(isTokenWithUser(loginResponse)).toBe(false); + }); + + it('should validate password reset success response', () => { + const resetResponse = { + success: true, + message: 'Password reset email sent. Please check your inbox.', + }; + + expect(isSuccessResponse(resetResponse)).toBe(true); + }); + + it('should validate logout success response', () => { + const logoutResponse = { + success: true, + message: 'Successfully logged out', + }; + + expect(isSuccessResponse(logoutResponse)).toBe(true); + }); + }); +});