/** * AuthContext Unit Tests * * Tests the AuthProvider, useAuth hook, and the exported USE_MOCK flag. * Covers: initial state, login (mock + real), logout, register, and * restoration of user from localStorage on mount. */ import React from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' // --------------------------------------------------------------------------- // Use vi.hoisted to create mock functions before vi.mock hoisting // --------------------------------------------------------------------------- const { mockApiLogin, mockApiRegister, mockClearTokens, mockGetAccessToken } = vi.hoisted(() => ({ mockApiLogin: vi.fn(), mockApiRegister: vi.fn(), mockClearTokens: vi.fn(), mockGetAccessToken: vi.fn(() => null), })) vi.mock('@/lib/api', () => ({ api: { login: mockApiLogin, register: mockApiRegister, }, clearTokens: mockClearTokens, getAccessToken: mockGetAccessToken, setTokens: vi.fn(), })) // --------------------------------------------------------------------------- // Import AuthContext after mocks are set up // --------------------------------------------------------------------------- import { AuthProvider, useAuth, USE_MOCK } from './AuthContext' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function mockLocalStorage() { const store: Record = {} return { getItem: vi.fn((key: string) => store[key] ?? null), setItem: vi.fn((key: string, value: string) => { store[key] = value }), removeItem: vi.fn((key: string) => { delete store[key] }), clear: vi.fn(() => { Object.keys(store).forEach((k) => delete store[k]) }), get length() { return Object.keys(store).length }, key: vi.fn((index: number) => Object.keys(store)[index] ?? null), } } const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ) // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('AuthContext', () => { let storage: ReturnType beforeEach(() => { storage = mockLocalStorage() Object.defineProperty(window, 'localStorage', { value: storage, writable: true }) vi.clearAllMocks() // Default: no stored token mockGetAccessToken.mockReturnValue(null) }) afterEach(() => { storage.clear() }) // ---- USE_MOCK flag ---- describe('USE_MOCK export', () => { it('is exported as a boolean', () => { expect(typeof USE_MOCK).toBe('boolean') }) it('is true in test/development environment or based on env var', () => { // vitest runs with NODE_ENV=test. The AuthContext checks for // NODE_ENV === 'development' or NEXT_PUBLIC_USE_MOCK === 'true'. // We verify the export exists and is a boolean. expect([true, false]).toContain(USE_MOCK) }) }) // ---- Initial state ---- describe('initial state', () => { it('starts with user as null', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(result.current.user).toBeNull() }) it('starts with isAuthenticated as false', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(result.current.isAuthenticated).toBe(false) }) it('starts with isLoading true, then becomes false', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) }) }) // ---- Restoring user from localStorage ---- describe('restoring user from localStorage', () => { it('restores user when token and stored user exist', async () => { const storedUser = { id: 'u1', name: 'Restored User', email: 'restored@test.com', role: 'brand', is_verified: true, } mockGetAccessToken.mockReturnValue('valid-token') storage.setItem('miaosi_user', JSON.stringify(storedUser)) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(result.current.user).toEqual(storedUser) expect(result.current.isAuthenticated).toBe(true) }) it('does not restore user when token is missing', async () => { const storedUser = { id: 'u1', name: 'No Token', role: 'brand', is_verified: true } storage.setItem('miaosi_user', JSON.stringify(storedUser)) mockGetAccessToken.mockReturnValue(null) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(result.current.user).toBeNull() expect(result.current.isAuthenticated).toBe(false) }) it('clears invalid JSON from localStorage gracefully', async () => { mockGetAccessToken.mockReturnValue('some-token') storage.setItem('miaosi_user', 'not-valid-json{{{') const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(mockClearTokens).toHaveBeenCalled() expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user') expect(result.current.user).toBeNull() }) }) // ---- Login ---- describe('login', () => { // When USE_MOCK is true, the AuthProvider uses mock login logic. // When false, it calls api.login(). if (!USE_MOCK) { it('calls api.login and sets user on success (real API path)', async () => { const loginResponse = { access_token: 'at', refresh_token: 'rt', token_type: 'bearer', expires_in: 900, user: { id: 'u1', name: 'API User', email: 'api@test.com', role: 'brand', is_verified: true, }, } mockApiLogin.mockResolvedValueOnce(loginResponse) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let loginResult: { success: boolean; error?: string } await act(async () => { loginResult = await result.current.login({ email: 'api@test.com', password: 'pass123', }) }) expect(loginResult!.success).toBe(true) expect(result.current.user?.name).toBe('API User') expect(result.current.isAuthenticated).toBe(true) expect(storage.setItem).toHaveBeenCalledWith( 'miaosi_user', expect.stringContaining('API User') ) }) it('returns error on api.login failure (real API path)', async () => { mockApiLogin.mockRejectedValueOnce(new Error('Invalid credentials')) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let loginResult: { success: boolean; error?: string } await act(async () => { loginResult = await result.current.login({ email: 'bad@test.com', password: 'wrong', }) }) expect(loginResult!.success).toBe(false) expect(loginResult!.error).toBe('Invalid credentials') expect(result.current.user).toBeNull() }) } if (USE_MOCK) { it('logs in with mock user for known email (mock path)', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let loginResult: { success: boolean; error?: string } await act(async () => { loginResult = await result.current.login({ email: 'creator@demo.com', password: 'demo123', }) }) expect(loginResult!.success).toBe(true) expect(result.current.user?.email).toBe('creator@demo.com') expect(result.current.isAuthenticated).toBe(true) }) it('returns error for unknown email in mock mode', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let loginResult: { success: boolean; error?: string } await act(async () => { loginResult = await result.current.login({ email: 'unknown@demo.com', password: 'demo123', }) }) expect(loginResult!.success).toBe(false) expect(loginResult!.error).toBeDefined() }) it('returns error for wrong password in mock mode', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let loginResult: { success: boolean; error?: string } await act(async () => { loginResult = await result.current.login({ email: 'creator@demo.com', password: 'wrongpassword', }) }) expect(loginResult!.success).toBe(false) expect(loginResult!.error).toBe('密码错误') }) it('allows email code login for known user in mock mode', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let loginResult: { success: boolean; error?: string } await act(async () => { loginResult = await result.current.login({ email: 'brand@demo.com', email_code: '123456', }) }) expect(loginResult!.success).toBe(true) expect(result.current.user?.email).toBe('brand@demo.com') expect(result.current.user?.role).toBe('brand') }) } }) // ---- Register ---- describe('register', () => { const registerData = { email: 'new@example.com', password: 'secure123', name: 'New User', role: 'creator' as const, email_code: '123456', } if (!USE_MOCK) { it('calls api.register and sets user on success (real API path)', async () => { const registerResponse = { access_token: 'at', refresh_token: 'rt', token_type: 'bearer', expires_in: 900, user: { id: 'u2', name: 'New User', email: 'new@example.com', role: 'creator', is_verified: false, }, } mockApiRegister.mockResolvedValueOnce(registerResponse) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let registerResult: { success: boolean; error?: string } await act(async () => { registerResult = await result.current.register(registerData) }) expect(registerResult!.success).toBe(true) expect(result.current.user?.name).toBe('New User') expect(result.current.isAuthenticated).toBe(true) }) it('returns error on api.register failure', async () => { mockApiRegister.mockRejectedValueOnce(new Error('Email already exists')) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let registerResult: { success: boolean; error?: string } await act(async () => { registerResult = await result.current.register(registerData) }) expect(registerResult!.success).toBe(false) expect(registerResult!.error).toBe('Email already exists') }) } if (USE_MOCK) { it('creates a mock user on register in mock mode', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) let registerResult: { success: boolean; error?: string } await act(async () => { registerResult = await result.current.register(registerData) }) expect(registerResult!.success).toBe(true) expect(result.current.user?.email).toBe('new@example.com') expect(result.current.user?.name).toBe('New User') expect(result.current.user?.role).toBe('creator') expect(result.current.isAuthenticated).toBe(true) }) } }) // ---- Logout ---- describe('logout', () => { it('clears user state, tokens, and localStorage', async () => { const storedUser = { id: 'u1', name: 'Logged In', email: 'in@test.com', role: 'brand', is_verified: true, } mockGetAccessToken.mockReturnValue('valid-token') storage.setItem('miaosi_user', JSON.stringify(storedUser)) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(result.current.user).not.toBeNull() expect(result.current.isAuthenticated).toBe(true) act(() => { result.current.logout() }) expect(result.current.user).toBeNull() expect(result.current.isAuthenticated).toBe(false) expect(mockClearTokens).toHaveBeenCalled() expect(storage.removeItem).toHaveBeenCalledWith('miaosi_user') }) }) // ---- switchRole ---- describe('switchRole', () => { it('updates the user role and persists to localStorage', async () => { const storedUser = { id: 'u1', name: 'Multi Role', email: 'multi@test.com', role: 'brand', is_verified: true, } mockGetAccessToken.mockReturnValue('valid-token') storage.setItem('miaosi_user', JSON.stringify(storedUser)) const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) expect(result.current.user?.role).toBe('brand') act(() => { result.current.switchRole('agency') }) expect(result.current.user?.role).toBe('agency') // Check localStorage was updated with new role const storedCalls = storage.setItem.mock.calls.filter( ([key]: [string, string]) => key === 'miaosi_user' ) const lastStored = JSON.parse(storedCalls[storedCalls.length - 1][1]) expect(lastStored.role).toBe('agency') }) it('does nothing when no user is logged in', async () => { const { result } = renderHook(() => useAuth(), { wrapper }) await waitFor(() => { expect(result.current.isLoading).toBe(false) }) act(() => { result.current.switchRole('creator') }) expect(result.current.user).toBeNull() }) }) // ---- useAuth outside provider ---- describe('useAuth outside AuthProvider', () => { it('throws an error when used outside AuthProvider', () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => { renderHook(() => useAuth()) }).toThrow('useAuth must be used within an AuthProvider') consoleSpy.mockRestore() }) }) })