import { apiClient } from './api-client' import { ApiResponse } from './types' // Mock fetch global.fetch = jest.fn() // Mock localStorage const localStorageMock = (() => { let store: Record = {} return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { store[key] = value.toString() }, removeItem: (key: string) => { delete store[key] }, clear: () => { store = {} }, } })() Object.defineProperty(window, 'localStorage', { value: localStorageMock, }) describe('ApiClient', () => { beforeEach(() => { ;(global.fetch as jest.Mock).mockClear() localStorage.clear() jest.clearAllMocks() }) describe('GET requests', () => { it('should make successful GET request', async () => { const mockResponse = { success: true, data: { id: 1 } } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) const result = await apiClient.get<{ id: number }>('/portfolios') expect(result).toEqual(mockResponse) expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:8000/api/portfolios', expect.objectContaining({ method: 'GET' }) ) }) it('should include Authorization header when token exists', async () => { const token = 'test-token' localStorage.setItem('auth_token', token) const mockResponse = { success: true, data: [] } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) await apiClient.get('/portfolios') expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ Authorization: `Bearer ${token}`, }), }) ) }) it('should not include Authorization header when no token exists', async () => { const mockResponse = { success: true, data: [] } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) await apiClient.get('/portfolios') const callArgs = (global.fetch as jest.Mock).mock.calls[0][1] expect(callArgs.headers.Authorization).toBeUndefined() }) it('should throw error on failed GET request', async () => { const errorMessage = 'Not Found' ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({ message: errorMessage }), }) await expect(apiClient.get('/invalid')).rejects.toThrow(errorMessage) }) }) describe('POST requests', () => { it('should make successful POST request with body', async () => { const requestBody = { email: 'test@example.com', password: 'password' } const mockResponse = { success: true, data: { token: 'jwt-token' } } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) const result = await apiClient.post('/auth/login', requestBody) expect(result).toEqual(mockResponse) expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'POST', body: JSON.stringify(requestBody), }) ) }) it('should make POST request without body', async () => { const mockResponse = { success: true, data: {} } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) await apiClient.post('/endpoint') expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'POST', body: undefined, }) ) }) it('should throw error on POST validation failure (422)', async () => { const errorData = { message: 'Validation failed', errors: { email: ['Email already exists'] }, } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 422, json: async () => errorData, }) await expect(apiClient.post('/auth/register')).rejects.toThrow( 'Validation failed' ) }) }) describe('PUT requests', () => { it('should make successful PUT request', async () => { const updateData = { name: 'Updated Name' } const mockResponse = { success: true, data: { name: 'Updated Name' } } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) const result = await apiClient.put('/portfolios/1', updateData) expect(result).toEqual(mockResponse) expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'PUT', body: JSON.stringify(updateData), }) ) }) }) describe('DELETE requests', () => { it('should make successful DELETE request', async () => { const mockResponse = { success: true } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) const result = await apiClient.delete('/portfolios/1') expect(result).toEqual(mockResponse) expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'DELETE' }) ) }) }) describe('401 Unauthorized handling', () => { it('should clear token and redirect on 401', async () => { const token = 'expired-token' localStorage.setItem('auth_token', token) const mockHref = jest.fn() Object.defineProperty(window, 'location', { value: { href: mockHref }, writable: true, }) ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ message: 'Unauthorized' }), }) try { await apiClient.get('/protected') } catch (error) { // Expected to throw } expect(localStorage.getItem('auth_token')).toBeNull() }) }) describe('Error handling', () => { it('should handle network errors', async () => { const networkError = new Error('Network request failed') ;(global.fetch as jest.Mock).mockRejectedValueOnce(networkError) await expect(apiClient.get('/endpoint')).rejects.toThrow( 'Network request failed' ) }) it('should use default error message when not provided', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({ success: false }), }) await expect(apiClient.get('/endpoint')).rejects.toThrow( 'API request failed' ) }) }) describe('File upload', () => { it('should upload FormData successfully', async () => { const token = 'test-token' localStorage.setItem('auth_token', token) const formData = new FormData() formData.append('file', new File(['test'], 'test.zip')) const mockResponse = { success: true, data: { path: '/uploads/test.zip' } } ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }) const result = await apiClient.upload('/portfolios/1/upload', formData) expect(result).toEqual(mockResponse) expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ method: 'POST', body: formData, }) ) }) it('should handle upload errors', async () => { const formData = new FormData() ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ message: 'Invalid file' }), }) await expect( apiClient.upload('/portfolios/1/upload', formData) ).rejects.toThrow('Invalid file') }) it('should handle 401 during upload', async () => { const token = 'expired-token' localStorage.setItem('auth_token', token) const formData = new FormData() ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ message: 'Unauthorized' }), }) try { await apiClient.upload('/portfolios/1/upload', formData) } catch (error) { // Expected } expect(localStorage.getItem('auth_token')).toBeNull() }) }) describe('Content-Type headers', () => { it('should set Content-Type for JSON requests', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ success: true }), }) await apiClient.post('/endpoint', { data: 'test' }) expect(global.fetch).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json', }), }) ) }) it('should not set Content-Type for file uploads', async () => { const formData = new FormData() ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ success: true }), }) await apiClient.upload('/upload', formData) const callArgs = (global.fetch as jest.Mock).mock.calls[0][1] expect(callArgs.headers['Content-Type']).toBeUndefined() }) }) describe('API URL construction', () => { it('should use NEXT_PUBLIC_API_URL environment variable', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ success: true }), }) await apiClient.get('/test') expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining('http://localhost:8000/api'), expect.any(Object) ) }) it('should properly combine API base URL with endpoint', async () => { ;(global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ success: true }), }) await apiClient.get('/portfolios/1/details') expect(global.fetch).toHaveBeenCalledWith( 'http://localhost:8000/api/portfolios/1/details', expect.any(Object) ) }) }) })