video-compliance-ai/frontend/contexts/AuthContext.test.tsx
Your Name a2f6f82e15 chore: CI/CD + 前端测试 + 安全加固 + 限流完善
- 新增 .gitlab-ci.yml (lint/test/build 三阶段)
- 新增前端测试: taskStageMapper (109), api.ts (36), AuthContext (16)
- 修复旧测试: Sidebar 导航文案、MobileLayout padding 值
- python-jose → PyJWT 消除 ecdsa CVE 漏洞
- 限流中间件增加 5 个敏感端点精细限流 + 标准限流头

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 11:25:29 +08:00

521 lines
16 KiB
TypeScript

/**
* 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<string, string> = {}
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 }) => (
<AuthProvider>{children}</AuthProvider>
)
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AuthContext', () => {
let storage: ReturnType<typeof mockLocalStorage>
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()
})
})
})