All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m41s
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
// src/hooks/useAuth.test.tsx
|
|
import React, { ReactNode } from 'react';
|
|
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { useAuth } from './useAuth';
|
|
import { AuthProvider } from '../providers/AuthProvider';
|
|
import * as apiClient from '../services/apiClient';
|
|
import type { UserProfile } from '../types';
|
|
import * as tokenStorage from '../services/tokenStorage';
|
|
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
|
import { logger } from '../services/logger.client';
|
|
|
|
// Must explicitly call vi.mock() for apiClient
|
|
vi.mock('../services/apiClient');
|
|
vi.mock('../services/tokenStorage');
|
|
|
|
const mockedApiClient = vi.mocked(apiClient);
|
|
const mockedTokenStorage = vi.mocked(tokenStorage);
|
|
|
|
const mockProfile: UserProfile = createMockUserProfile({
|
|
full_name: 'Test User',
|
|
points: 100,
|
|
role: 'user',
|
|
user: { user_id: 'user-abc-123', email: 'test@example.com' },
|
|
});
|
|
|
|
// Create a fresh QueryClient for each test to ensure isolation
|
|
const createTestQueryClient = () =>
|
|
new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
gcTime: 0,
|
|
},
|
|
mutations: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Reusable wrapper for rendering the hook within the provider
|
|
const wrapper = ({ children }: { children: ReactNode }) => {
|
|
const testQueryClient = createTestQueryClient();
|
|
return (
|
|
<QueryClientProvider client={testQueryClient}>
|
|
<AuthProvider>{children}</AuthProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
};
|
|
|
|
describe('useAuth Hook and AuthProvider', () => {
|
|
beforeEach(() => {
|
|
// Reset mocks and storage before each test
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('throws an error when useAuth is used outside of an AuthProvider', () => {
|
|
// Suppress console error for this expected failure
|
|
const originalError = console.error;
|
|
console.error = vi.fn();
|
|
expect(() => renderHook(() => useAuth())).toThrow(
|
|
'useAuth must be used within an AuthProvider',
|
|
);
|
|
console.error = originalError;
|
|
});
|
|
it('initializes with a default state', async () => {
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
// We verify that it starts in a loading state or quickly resolves to signed out
|
|
// depending on the execution speed.
|
|
// To avoid flakiness, we just ensure it is in a valid state structure.
|
|
expect(result.current.userProfile).toBeNull();
|
|
// It should eventually settle
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Initial Auth Check (useEffect)', () => {
|
|
it('sets state to SIGNED_OUT if no token is found in storage', async () => {
|
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
expect(result.current.userProfile).toBeNull();
|
|
});
|
|
|
|
it('sets state to AUTHENTICATED if a valid token is found', async () => {
|
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockProfile),
|
|
} as unknown as Response);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.authStatus).toBe('AUTHENTICATED');
|
|
expect(result.current.userProfile).toEqual(mockProfile);
|
|
|
|
// Check that it was called at least once.
|
|
// React 18 Strict Mode might call effects twice in dev/test environment.
|
|
expect(mockedApiClient.getAuthenticatedUserProfile.mock.calls.length).toBeGreaterThanOrEqual(
|
|
1,
|
|
);
|
|
});
|
|
|
|
it('sets state to SIGNED_OUT and removes token if validation fails', async () => {
|
|
mockedTokenStorage.getToken.mockReturnValue('invalid-token');
|
|
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Invalid token'));
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
expect(result.current.userProfile).toBeNull();
|
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('sets state to SIGNED_OUT and removes token if profile fetch returns null after token validation', async () => {
|
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
|
// Mock getAuthenticatedUserProfile to return a 200 OK response with a null body
|
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(null), // Simulate API returning no profile data
|
|
} as unknown as Response);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
expect(result.current.userProfile).toBeNull();
|
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[AuthProvider] Token was present but profile is null. Signing out.',
|
|
);
|
|
});
|
|
|
|
describe('login function', () => {
|
|
// This was the failing test
|
|
it('sets token, fetches profile, and updates state on successful login', async () => {
|
|
// --- FIX ---
|
|
// Explicitly mock that no token exists initially to prevent state leakage from other tests.
|
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
|
|
|
// --- FIX ---
|
|
// The mock for `getAuthenticatedUserProfile` must resolve to a `Response`-like object,
|
|
// as this is the return type of the actual function. The `useApi` hook then
|
|
// processes this response. This mock is now type-safe.
|
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockProfile),
|
|
} as Response);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
// 1. Wait for the initial effect to complete and loading to be false
|
|
console.log('[TEST-DEBUG] Waiting for initial auth check to complete...');
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
console.log(
|
|
'[TEST-DEBUG] Initial auth check complete. Current status:',
|
|
result.current.authStatus,
|
|
);
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
|
|
// 2. Perform login
|
|
await act(async () => {
|
|
console.log('[TEST-DEBUG] Calling login function...');
|
|
await result.current.login('new-valid-token', mockProfile);
|
|
console.log('[TEST-DEBUG] Login function promise resolved.');
|
|
});
|
|
|
|
console.log('[TEST-DEBUG] State immediately after login `act` call:', result.current);
|
|
|
|
// 3. Assertions
|
|
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('new-valid-token');
|
|
|
|
// 4. We must wait for the state update inside the hook to propagate
|
|
await waitFor(() => {
|
|
console.log(
|
|
`[TEST-DEBUG] Checking authStatus in waitFor... Current status: ${result.current.authStatus}`,
|
|
);
|
|
expect(result.current.authStatus).toBe('AUTHENTICATED');
|
|
});
|
|
|
|
console.log('[TEST-DEBUG] Final state after successful login:', result.current);
|
|
expect(result.current.userProfile).toEqual(mockProfile);
|
|
});
|
|
|
|
it('logs out and throws an error if profile fetch fails after login', async () => {
|
|
const fetchError = new Error('API is down');
|
|
// The hook will throw, so we mock the rejection.
|
|
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(fetchError);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
|
|
// The login function should reject the promise it returns.
|
|
await act(async () => {
|
|
await expect(result.current.login('new-token')).rejects.toThrow(
|
|
/Login succeeded, but failed to fetch your data/,
|
|
);
|
|
});
|
|
|
|
// Should trigger the logout flow
|
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT'); // This was a duplicate, fixed.
|
|
expect(result.current.userProfile).toBeNull();
|
|
});
|
|
|
|
it('logs out and throws an error if profile fetch returns null after login (no profileData)', async () => {
|
|
// Simulate successful token setting, but subsequent profile fetch returns null
|
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(null), // Simulate API returning no profile data
|
|
} as unknown as Response);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
|
|
// Call login without profileData, forcing a profile fetch
|
|
await act(async () => {
|
|
await expect(result.current.login('new-token-no-profile-data')).rejects.toThrow(
|
|
'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.',
|
|
);
|
|
});
|
|
|
|
// Should trigger the logout flow
|
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
expect(result.current.userProfile).toBeNull();
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.any(String), // The error message
|
|
expect.objectContaining({ error: 'Received null or undefined profile from API.' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('logout function', () => {
|
|
it('removes token and resets auth state', async () => {
|
|
// Start in a logged-in state by mocking the token storage
|
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockProfile),
|
|
} as unknown as Response);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.authStatus).toBe('AUTHENTICATED'));
|
|
|
|
expect(result.current.userProfile).not.toBeNull();
|
|
|
|
act(() => {
|
|
result.current.logout();
|
|
});
|
|
|
|
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
expect(result.current.userProfile).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('updateProfile function', () => {
|
|
it('merges new data into the existing profile state', async () => {
|
|
// Start in a logged-in state
|
|
mockedTokenStorage.getToken.mockReturnValue('valid-token');
|
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: () => Promise.resolve(mockProfile),
|
|
} as unknown as Response);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
await waitFor(() => expect(result.current.authStatus).toBe('AUTHENTICATED'));
|
|
|
|
expect(result.current.userProfile?.full_name).toBe('Test User');
|
|
|
|
const updatedData: Partial<UserProfile> = {
|
|
full_name: 'Test User Updated',
|
|
points: 150,
|
|
};
|
|
|
|
act(() => {
|
|
result.current.updateProfile(updatedData);
|
|
});
|
|
|
|
expect(result.current.userProfile?.full_name).toBe('Test User Updated');
|
|
expect(result.current.userProfile?.points).toBe(150);
|
|
// Ensure other data was not overwritten
|
|
expect(result.current.userProfile?.role).toBe('user');
|
|
});
|
|
|
|
it('should not update profile if user is not authenticated', async () => {
|
|
// --- FIX ---
|
|
// Explicitly mock that no token exists initially to prevent state leakage from other tests.
|
|
mockedTokenStorage.getToken.mockReturnValue(null);
|
|
|
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
|
|
|
// Wait for initial check to complete
|
|
await waitFor(() => {
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.authStatus).toBe('SIGNED_OUT');
|
|
expect(result.current.userProfile).toBeNull();
|
|
|
|
const updatedData: Partial<UserProfile> = {
|
|
full_name: 'Should Not Update',
|
|
};
|
|
|
|
act(() => result.current.updateProfile(updatedData));
|
|
|
|
expect(result.current.userProfile).toBeNull();
|
|
});
|
|
});
|
|
});
|