/** * Unit tests for API client (api.js) */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { setToken, getToken, setCurrentUser, currentUser, get, post, del, put, login, checkAuth, logout, tryRefreshSession, } from '../../src/lib/api.js'; describe('API Client - Token Management', () => { beforeEach(() => { localStorage.clear(); setToken(''); }); it('should set and get token', () => { setToken('test-token-123'); expect(getToken()).toBe('test-token-123'); }); it('should store token in localStorage', () => { setToken('test-token-123'); expect(localStorage.setItem).toHaveBeenCalledWith('token', 'test-token-123'); }); it('should remove token from localStorage when cleared', () => { setToken('test-token-123'); setToken(''); expect(localStorage.removeItem).toHaveBeenCalledWith('token'); }); it('should set current user', () => { const user = { username: 'testuser', role: 'admin', pages: ['dashboard'] }; setCurrentUser(user); expect(currentUser).toEqual(user); }); }); describe('API Client - GET Requests', () => { beforeEach(() => { vi.clearAllMocks(); setToken('test-token'); }); it('should make GET request with auth header', async () => { const mockData = { result: 'success' }; global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, }); const result = await get('/test/endpoint'); expect(fetch).toHaveBeenCalledWith( '/api/test/endpoint', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token', }), }) ); expect(result).toEqual(mockData); }); it('should make GET request without auth header when no token', async () => { setToken(''); const mockData = { result: 'success' }; global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, }); await get('/test/endpoint'); expect(fetch).toHaveBeenCalledWith( '/api/test/endpoint', expect.objectContaining({ headers: expect.not.objectContaining({ Authorization: expect.anything(), }), }) ); }); it('should throw error on 404', async () => { global.fetch.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({ error: 'Not found' }), }); await expect(get('/test/endpoint')).rejects.toThrow('HTTP 404'); }); }); describe('API Client - POST Requests', () => { beforeEach(() => { vi.clearAllMocks(); setToken('test-token'); }); it('should make POST request with JSON body', async () => { const mockData = { result: 'created' }; const postData = { name: 'test', value: 123 }; global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockData, }); const result = await post('/test/endpoint', postData); expect(fetch).toHaveBeenCalledWith( '/api/test/endpoint', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json', Authorization: 'Bearer test-token', }), body: JSON.stringify(postData), }) ); expect(result).toEqual(mockData); }); }); describe('API Client - DELETE Requests', () => { beforeEach(() => { vi.clearAllMocks(); setToken('test-token'); }); it('should make DELETE request', async () => { global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ message: 'deleted' }), }); await del('/test/endpoint'); expect(fetch).toHaveBeenCalledWith( '/api/test/endpoint', expect.objectContaining({ method: 'DELETE', }) ); }); }); describe('API Client - PUT Requests', () => { beforeEach(() => { vi.clearAllMocks(); setToken('test-token'); }); it('should make PUT request with JSON body', async () => { const putData = { name: 'updated' }; global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ result: 'updated' }), }); await put('/test/endpoint', putData); expect(fetch).toHaveBeenCalledWith( '/api/test/endpoint', expect.objectContaining({ method: 'PUT', headers: expect.objectContaining({ 'Content-Type': 'application/json', }), body: JSON.stringify(putData), }) ); }); }); describe('API Client - Authentication Methods', () => { beforeEach(() => { vi.clearAllMocks(); }); it('should login with credentials', async () => { const mockResponse = { access_token: 'new-token', user: 'testuser', role: 'admin', }; global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResponse, }); const result = await login({ username: 'testuser', password: 'pass123' }); expect(fetch).toHaveBeenCalledWith( '/api/auth/login', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }) ); expect(result).toEqual(mockResponse); }); it('should check auth status', async () => { setToken('test-token'); const mockUser = { username: 'testuser', role: 'admin' }; global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockUser, }); const result = await checkAuth(); expect(fetch).toHaveBeenCalledWith('/api/auth/me', expect.anything()); expect(result).toEqual(mockUser); }); it('should logout', async () => { setToken('test-token'); global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ message: 'logged out' }), }); await logout(); expect(fetch).toHaveBeenCalledWith( '/api/auth/logout', expect.objectContaining({ method: 'POST', credentials: 'same-origin', }) ); }); }); describe('API Client - Token Refresh', () => { beforeEach(() => { vi.clearAllMocks(); setToken(''); localStorage.clear(); }); it('should refresh token from cookie', async () => { global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ access_token: 'new-token' }), }); const result = await tryRefreshSession(); expect(result).toBe(true); expect(getToken()).toBe('new-token'); }); it('should try legacy refresh token if cookie refresh fails', async () => { localStorage.getItem.mockReturnValue('legacy-refresh-token'); // First call (cookie) fails global.fetch.mockResolvedValueOnce({ ok: false, status: 401, }); // Second call (legacy) succeeds global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ access_token: 'new-token-from-legacy' }), }); const result = await tryRefreshSession(); expect(result).toBe(true); expect(getToken()).toBe('new-token-from-legacy'); expect(localStorage.removeItem).toHaveBeenCalledWith('refresh_token'); }); it('should return false if all refresh attempts fail', async () => { global.fetch.mockResolvedValue({ ok: false, status: 401, }); const result = await tryRefreshSession(); expect(result).toBe(false); }); it('should auto-refresh on 401 response', async () => { setToken('expired-token'); // First request returns 401 global.fetch.mockResolvedValueOnce({ ok: false, status: 401, }); // Refresh succeeds global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ access_token: 'refreshed-token' }), }); // Retry original request succeeds global.fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: 'success' }), }); const result = await get('/test/endpoint'); expect(result).toEqual({ data: 'success' }); expect(getToken()).toBe('refreshed-token'); }); it('should clear token on final 401', async () => { setToken('expired-token'); // First request returns 401 global.fetch.mockResolvedValueOnce({ ok: false, status: 401, }); // Refresh fails global.fetch.mockResolvedValueOnce({ ok: false, status: 401, }); // Retry original request still 401 global.fetch.mockResolvedValueOnce({ ok: false, status: 401, }); await expect(get('/test/endpoint')).rejects.toThrow('Unauthorized'); expect(getToken()).toBe(''); }); }); describe('API Client - Error Handling', () => { beforeEach(() => { vi.clearAllMocks(); setToken('test-token'); }); it('should handle network errors', async () => { global.fetch.mockRejectedValueOnce(new Error('Network error')); await expect(get('/test/endpoint')).rejects.toThrow('Network error'); }); it('should include error body in thrown error', async () => { const errorBody = { error: 'Bad request', details: 'Invalid input' }; global.fetch.mockResolvedValueOnce({ ok: false, status: 400, json: async () => errorBody, }); try { await get('/test/endpoint'); expect.fail('Should have thrown'); } catch (error) { expect(error.status).toBe(400); expect(error.body).toEqual(errorBody); } }); });