import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, waitFor, act } from '@testing-library/react' import type { ReactNode } from 'react' import { AuthProvider, useAuth } from './AuthContext' import * as api from '@/services/api' import { ApiRequestError } from '@/services/errors' import type { TokenResponse } from '@/types' vi.mock('@/services/api') function wrapper({ children }: { children: ReactNode }) { return {children} } const tokenResponse: TokenResponse = { access_token: 'token-abc', token_type: 'bearer', expires_in: 3600, } describe('AuthContext', () => { beforeEach(() => { vi.clearAllMocks() // Default mount behavior: no valid refresh cookie, so the effect settles // to a logged-out state unless a test overrides this. vi.mocked(api.refreshToken).mockRejectedValue( new ApiRequestError(401, { code: '401', message: 'Unauthorized' }), ) }) it('refreshes the access token successfully on mount', async () => { vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse) const { result } = renderHook(() => useAuth(), { wrapper }) expect(result.current.isLoading).toBe(true) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.isAuthenticated).toBe(true) expect(result.current.accessToken).toBe('token-abc') expect(api.setAccessToken).toHaveBeenCalledWith('token-abc') }) it('logs the user out when the mount refresh fails with 401', async () => { vi.mocked(api.refreshToken).mockRejectedValue( new ApiRequestError(401, { code: '401', message: 'Unauthorized' }), ) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.isAuthenticated).toBe(false) expect(result.current.accessToken).toBeNull() }) it('also lands in a logged-out state when the mount refresh fails with a non-401 error', async () => { vi.mocked(api.refreshToken).mockRejectedValue(new Error('network down')) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.isAuthenticated).toBe(false) expect(result.current.accessToken).toBeNull() }) it('login updates state and stores the access token', async () => { vi.mocked(api.login).mockResolvedValue(tokenResponse) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) await act(async () => { await result.current.login({ email: 'a@b.com', password: 'secret' }) }) expect(api.login).toHaveBeenCalledWith({ email: 'a@b.com', password: 'secret' }) expect(api.setAccessToken).toHaveBeenCalledWith('token-abc') expect(result.current.isAuthenticated).toBe(true) expect(result.current.accessToken).toBe('token-abc') }) it('register updates state and stores the access token', async () => { vi.mocked(api.register).mockResolvedValue(tokenResponse) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isLoading).toBe(false)) await act(async () => { await result.current.register({ email: 'a@b.com', password: 'secret', displayName: 'Anna', }) }) expect(api.register).toHaveBeenCalledWith({ email: 'a@b.com', password: 'secret', displayName: 'Anna', }) expect(result.current.isAuthenticated).toBe(true) expect(result.current.accessToken).toBe('token-abc') }) it('logout clears state and the stored access token', async () => { vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse) vi.mocked(api.logout).mockResolvedValue(undefined) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isAuthenticated).toBe(true)) await act(async () => { await result.current.logout() }) expect(api.logout).toHaveBeenCalled() expect(api.setAccessToken).toHaveBeenCalledWith(null) expect(result.current.isAuthenticated).toBe(false) expect(result.current.accessToken).toBeNull() }) it('clears local state even when the server logout call rejects (S3 — offline logout)', async () => { vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse) vi.mocked(api.logout).mockRejectedValue(new Error('network down')) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isAuthenticated).toBe(true)) // The rejection still propagates to the caller (Layout decides how to // handle a failed best-effort server call) — but local state must // already be cleared by the time it does. await act(async () => { await expect(result.current.logout()).rejects.toThrow('network down') }) expect(api.setAccessToken).toHaveBeenCalledWith(null) expect(result.current.isAuthenticated).toBe(false) expect(result.current.accessToken).toBeNull() }) it('logs the user out when apiFetch reports a failed refresh-and-retry (S1 interceptor wiring)', async () => { vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => expect(result.current.isAuthenticated).toBe(true)) // Simulate services/api.ts's apiFetch invoking the handler AuthContext // registered via api.setUnauthorizedHandler after a mid-session 401's // refresh-and-retry gives up. const registeredHandler = vi.mocked(api.setUnauthorizedHandler).mock.calls.find( (call) => call[0] !== null, )?.[0] expect(registeredHandler).toBeInstanceOf(Function) act(() => { registeredHandler?.() }) expect(result.current.isAuthenticated).toBe(false) expect(result.current.accessToken).toBeNull() }) it('throws when useAuth is used outside an AuthProvider', () => { expect(() => renderHook(() => useAuth())).toThrow( 'useAuth must be used inside ', ) }) })