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 ',
)
})
})