/** * API Client Unit Tests * * Tests the ApiClient singleton: axios configuration, token interceptors, * 401 refresh flow, and key API method endpoint mappings. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' // --------------------------------------------------------------------------- // vi.hoisted() runs BEFORE vi.mock hoisting, so these variables are available // inside the vi.mock factory. // --------------------------------------------------------------------------- const { mockAxiosInstance, requestInterceptorHolder, responseErrorInterceptorHolder, } = vi.hoisted(() => { const requestInterceptorHolder: { fn: ((config: any) => any) | null } = { fn: null } const responseErrorInterceptorHolder: { fn: ((error: any) => any) | null } = { fn: null } const mockAxiosInstance = { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), interceptors: { request: { use: vi.fn((onFulfilled: any) => { requestInterceptorHolder.fn = onFulfilled }), }, response: { use: vi.fn((_onFulfilled: any, onRejected: any) => { responseErrorInterceptorHolder.fn = onRejected }), }, }, } return { mockAxiosInstance, requestInterceptorHolder, responseErrorInterceptorHolder } }) vi.mock('axios', () => { return { default: { create: vi.fn(() => mockAxiosInstance), post: vi.fn(), }, } }) // --------------------------------------------------------------------------- // Import the module under test AFTER mocking axios // --------------------------------------------------------------------------- import axios from 'axios' import { api, getAccessToken, getRefreshToken, setTokens, clearTokens } from './api' // --------------------------------------------------------------------------- // 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), } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('API Client', () => { let storage: ReturnType beforeEach(() => { storage = mockLocalStorage() Object.defineProperty(window, 'localStorage', { value: storage, writable: true }) // Reset all mock call counts but preserve the interceptor captures vi.clearAllMocks() }) afterEach(() => { storage.clear() }) // ---- Axios instance configuration ---- describe('axios instance configuration', () => { it('creates axios instance with correct baseURL and timeout', () => { // axios.create is called at module load time before vi.clearAllMocks(). // Instead of checking call count, verify the singleton `api` exists and // that axios.create was invoked (the mock instance is connected). // We confirm indirectly: if api.login calls mockAxiosInstance.post, // that proves axios.create returned our mock. expect(api).toBeDefined() // Also verify the interceptor callbacks were captured (proves setup ran) expect(requestInterceptorHolder.fn).not.toBeNull() expect(responseErrorInterceptorHolder.fn).not.toBeNull() }) it('captures request and response interceptors during setup', () => { // The interceptor functions were captured during module initialization. // Verify they are functions that we can invoke. expect(typeof requestInterceptorHolder.fn).toBe('function') expect(typeof responseErrorInterceptorHolder.fn).toBe('function') }) }) // ---- Token management helpers ---- describe('token management helpers', () => { it('getAccessToken returns null when no token stored', () => { expect(getAccessToken()).toBeNull() }) it('getAccessToken returns the stored token', () => { storage.setItem('miaosi_access_token', 'test-access-token') expect(getAccessToken()).toBe('test-access-token') }) it('getRefreshToken returns null when no token stored', () => { expect(getRefreshToken()).toBeNull() }) it('getRefreshToken returns the stored refresh token', () => { storage.setItem('miaosi_refresh_token', 'test-refresh-token') expect(getRefreshToken()).toBe('test-refresh-token') }) it('setTokens stores both access and refresh tokens', () => { setTokens('access-123', 'refresh-456') expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'access-123') expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'refresh-456') }) it('clearTokens removes both tokens from storage', () => { storage.setItem('miaosi_access_token', 'a') storage.setItem('miaosi_refresh_token', 'b') clearTokens() expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token') expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token') }) }) // ---- Request interceptor ---- describe('request interceptor', () => { it('adds Authorization header when access token exists', () => { storage.setItem('miaosi_access_token', 'my-token') const config = { headers: {} as Record } const result = requestInterceptorHolder.fn!(config) expect(result.headers.Authorization).toBe('Bearer my-token') }) it('does not add Authorization header when no token', () => { const config = { headers: {} as Record } const result = requestInterceptorHolder.fn!(config) expect(result.headers.Authorization).toBeUndefined() }) it('always adds X-Tenant-ID header', () => { const config = { headers: {} as Record } const result = requestInterceptorHolder.fn!(config) expect(result.headers['X-Tenant-ID']).toBeDefined() }) }) // ---- Response interceptor: 401 refresh ---- describe('response interceptor - 401 refresh', () => { it('attempts token refresh on 401 response', async () => { storage.setItem('miaosi_refresh_token', 'valid-refresh') const newAccessToken = 'new-access-token-123'; (axios.post as ReturnType).mockResolvedValueOnce({ data: { access_token: newAccessToken, token_type: 'bearer', expires_in: 900 }, }) const error = { response: { status: 401 }, config: { headers: {} as Record, _retry: false }, } // The response interceptor will: // 1. Call axios.post to refresh the token // 2. Try to call this.client(originalRequest) to retry // Since mockAxiosInstance is an object (not callable), step 2 throws. // We catch that and verify step 1 happened correctly. try { await responseErrorInterceptorHolder.fn!(error) } catch { // Expected: this.client is not a function (mockAxiosInstance is not callable) } expect(axios.post).toHaveBeenCalledWith( expect.stringContaining('/auth/refresh'), { refresh_token: 'valid-refresh' } ) // Verify the new access token was stored expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', newAccessToken) }) it('clears tokens and redirects to /login when refresh fails', async () => { storage.setItem('miaosi_refresh_token', 'expired-refresh') ;(axios.post as ReturnType).mockRejectedValueOnce(new Error('Token expired')) const originalHref = window.location.href Object.defineProperty(window, 'location', { value: { href: '' }, writable: true, configurable: true, }) const error = { response: { status: 401 }, config: { headers: {} as Record, _retry: false }, } await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow() expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token') expect(window.location.href).toBe('/login') // Restore Object.defineProperty(window, 'location', { value: { href: originalHref }, writable: true, configurable: true, }) }) it('rejects with error message for non-401 errors', async () => { const error = { response: { status: 500, data: { detail: 'Internal Server Error' } }, config: { headers: {} as Record }, message: 'Request failed', } await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow('Internal Server Error') }) it('falls through to error handler when _retry is already true', async () => { const error = { response: { status: 401 }, config: { headers: {} as Record, _retry: true }, message: 'Unauthorized', } await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow() expect(axios.post).not.toHaveBeenCalled() }) it('rejects when no refresh token is available', async () => { // No refresh token in storage const error = { response: { status: 401 }, config: { headers: {} as Record, _retry: false }, } Object.defineProperty(window, 'location', { value: { href: '' }, writable: true, configurable: true, }) await expect(responseErrorInterceptorHolder.fn!(error)).rejects.toThrow() // refresh should not have been called since there's no token expect(axios.post).not.toHaveBeenCalled() }) }) // ---- API methods: endpoint mapping ---- describe('api.login()', () => { it('calls POST /auth/login with credentials and stores tokens', async () => { const mockResponse = { data: { access_token: 'at', refresh_token: 'rt', token_type: 'bearer', expires_in: 900, user: { id: 'u1', name: 'Test', role: 'brand', is_verified: true }, }, } mockAxiosInstance.post.mockResolvedValueOnce(mockResponse) const result = await api.login({ email: 'test@example.com', password: 'pass123' }) expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/login', { email: 'test@example.com', password: 'pass123', }) expect(result.access_token).toBe('at') expect(result.user.name).toBe('Test') expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'at') expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'rt') }) }) describe('api.register()', () => { it('calls POST /auth/register with user data and stores tokens', async () => { const registerData = { email: 'new@example.com', password: 'secure123', name: 'New User', role: 'creator' as const, email_code: '123456', } const mockResponse = { data: { access_token: 'new-at', refresh_token: 'new-rt', token_type: 'bearer', expires_in: 900, user: { id: 'u2', name: 'New User', email: 'new@example.com', role: 'creator', is_verified: false }, }, } mockAxiosInstance.post.mockResolvedValueOnce(mockResponse) const result = await api.register(registerData) expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/register', registerData) expect(result.user.email).toBe('new@example.com') expect(storage.setItem).toHaveBeenCalledWith('miaosi_access_token', 'new-at') expect(storage.setItem).toHaveBeenCalledWith('miaosi_refresh_token', 'new-rt') }) }) describe('api.listTasks()', () => { it('calls GET /tasks with pagination params', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { items: [], total: 0, page: 1, page_size: 20 }, }) const result = await api.listTasks(1, 20) expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tasks', { params: { page: 1, page_size: 20, stage: undefined }, }) expect(result.total).toBe(0) }) it('passes stage filter when provided', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { items: [], total: 0, page: 1, page_size: 10 }, }) await api.listTasks(1, 10, 'script_upload' as any) expect(mockAxiosInstance.get).toHaveBeenCalledWith('/tasks', { params: { page: 1, page_size: 10, stage: 'script_upload' }, }) }) }) describe('api.getUploadPolicy()', () => { it('calls POST /upload/policy with file type', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { x_tos_algorithm: 'algo', x_tos_credential: 'cred', x_tos_date: '2024-01-01', x_tos_signature: 'sig', policy: 'base64policy', host: 'https://oss.example.com', dir: 'uploads/', expire: 3600, max_size_mb: 100, }, }) const result = await api.getUploadPolicy('video') expect(mockAxiosInstance.post).toHaveBeenCalledWith('/upload/policy', { file_type: 'video', }) expect(result.host).toBe('https://oss.example.com') }) it('defaults to "general" file type when not specified', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: {} }) await api.getUploadPolicy() expect(mockAxiosInstance.post).toHaveBeenCalledWith('/upload/policy', { file_type: 'general', }) }) }) describe('api.getProfile()', () => { it('calls GET /profile', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'brand', is_verified: true, }, }) const result = await api.getProfile() expect(mockAxiosInstance.get).toHaveBeenCalledWith('/profile') expect(result.name).toBe('Test User') }) }) describe('api.getMessages()', () => { it('calls GET /messages with params', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { items: [], total: 0, page: 1, page_size: 20 }, }) const params = { page: 1, page_size: 20, is_read: false } const result = await api.getMessages(params) expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages', { params }) expect(result.total).toBe(0) }) it('calls GET /messages without params when none provided', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { items: [], total: 0, page: 1, page_size: 20 }, }) await api.getMessages() expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages', { params: undefined }) }) }) describe('api.getUnreadCount()', () => { it('calls GET /messages/unread-count', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { count: 5 } }) const result = await api.getUnreadCount() expect(mockAxiosInstance.get).toHaveBeenCalledWith('/messages/unread-count') expect(result.count).toBe(5) }) }) describe('api.logout()', () => { it('calls POST /auth/logout and clears tokens', async () => { storage.setItem('miaosi_access_token', 'at') storage.setItem('miaosi_refresh_token', 'rt') mockAxiosInstance.post.mockResolvedValueOnce({ data: undefined }) await api.logout() expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/logout') expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token') expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token') }) it('clears tokens even when POST /auth/logout fails', async () => { mockAxiosInstance.post.mockRejectedValueOnce(new Error('Network error')) await api.logout().catch(() => {}) expect(storage.removeItem).toHaveBeenCalledWith('miaosi_access_token') expect(storage.removeItem).toHaveBeenCalledWith('miaosi_refresh_token') }) }) describe('api.sendEmailCode()', () => { it('calls POST /auth/send-code', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { message: 'Code sent', expires_in: 300 }, }) const result = await api.sendEmailCode({ email: 'a@b.com', purpose: 'login' }) expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/send-code', { email: 'a@b.com', purpose: 'login', }) expect(result.expires_in).toBe(300) }) }) describe('api.resetPassword()', () => { it('calls POST /auth/reset-password', async () => { mockAxiosInstance.post.mockResolvedValueOnce({ data: { message: 'Password reset' }, }) const result = await api.resetPassword({ email: 'a@b.com', email_code: '123456', new_password: 'newpass', }) expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/reset-password', { email: 'a@b.com', email_code: '123456', new_password: 'newpass', }) expect(result.message).toBe('Password reset') }) }) describe('api.updateProfile()', () => { it('calls PUT /profile with update data', async () => { const updateData = { name: 'Updated Name' } mockAxiosInstance.put.mockResolvedValueOnce({ data: { id: 'u1', name: 'Updated Name', role: 'brand', is_verified: true }, }) const result = await api.updateProfile(updateData) expect(mockAxiosInstance.put).toHaveBeenCalledWith('/profile', updateData) expect(result.name).toBe('Updated Name') }) }) describe('api.changePassword()', () => { it('calls PUT /profile/password', async () => { mockAxiosInstance.put.mockResolvedValueOnce({ data: { message: 'Password changed' }, }) const result = await api.changePassword({ old_password: 'old', new_password: 'new', }) expect(mockAxiosInstance.put).toHaveBeenCalledWith('/profile/password', { old_password: 'old', new_password: 'new', }) expect(result.message).toBe('Password changed') }) }) describe('api.markMessageAsRead()', () => { it('calls PUT /messages/:id/read', async () => { mockAxiosInstance.put.mockResolvedValueOnce({ data: undefined }) await api.markMessageAsRead('msg-001') expect(mockAxiosInstance.put).toHaveBeenCalledWith('/messages/msg-001/read') }) }) describe('api.markAllMessagesAsRead()', () => { it('calls PUT /messages/read-all', async () => { mockAxiosInstance.put.mockResolvedValueOnce({ data: undefined }) await api.markAllMessagesAsRead() expect(mockAxiosInstance.put).toHaveBeenCalledWith('/messages/read-all') }) }) describe('api.setTenantId()', () => { it('updates the tenant ID used in subsequent requests', () => { api.setTenantId('BR002') const config = { headers: {} as Record } const result = requestInterceptorHolder.fn!(config) expect(result.headers['X-Tenant-ID']).toBe('BR002') // Reset for other tests api.setTenantId('default') }) }) describe('api.healthCheck()', () => { it('calls GET /health', async () => { mockAxiosInstance.get.mockResolvedValueOnce({ data: { status: 'ok', version: '1.0.0' }, }) const result = await api.healthCheck() expect(mockAxiosInstance.get).toHaveBeenCalledWith('/health') expect(result.status).toBe('ok') }) }) })