Files
flyer-crawler.projectium.com/src/hooks/useAuth.test.tsx
Torben Sorensen 503e7084da
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m41s
Adopt TanStack Query fixes
2026-01-10 17:42:45 -08:00

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();
});
});
});