import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Mock } from 'vitest'; // We need to test the ApiClient class, so we import the module fresh // The api.ts uses import.meta.env.PROD which is false in test // So API_BASE = '/api' and AUTH_BASE = '' let api: Record unknown>; beforeEach(async () => { vi.resetModules(); vi.stubGlobal('fetch', vi.fn()); const mod = await import('@/lib/api'); api = mod.api as unknown as Record unknown>; }); function mockFetchResponse(data: unknown, ok = true, _status = 200) { (fetch as Mock).mockResolvedValueOnce({ ok, status: _status, json: () => Promise.resolve(data), }); } describe('ApiClient', () => { describe('login', () => { it('sends login request and returns data on success', async () => { const responseData = { token: 'abc123', user: { id: '1', email: 'test@test.com' } }; mockFetchResponse(responseData); const result = await api.login('test@test.com', 'password'); expect(fetch).toHaveBeenCalledWith('/api/auth/sign-in/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'test@test.com', password: 'password' }), credentials: 'include', }); expect(result).toEqual(responseData); }); it('throws on failed login', async () => { (fetch as Mock).mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ message: 'Invalid credentials' }), }); await expect(api.login('bad@test.com', 'wrong')).rejects.toThrow('Invalid credentials'); }); }); describe('logout', () => { it('sends POST to sign-out', async () => { mockFetchResponse({}); await api.logout(); expect(fetch).toHaveBeenCalledWith('/api/auth/sign-out', { method: 'POST', credentials: 'include', }); }); }); describe('getSession', () => { it('returns session data when authenticated', async () => { const session = { user: { id: '1', email: 'test@test.com', name: 'Test' } }; mockFetchResponse(session); const result = await api.getSession(); expect(result).toEqual(session); expect(fetch).toHaveBeenCalledWith('/api/auth/get-session', { credentials: 'include', }); }); it('returns null when not authenticated', async () => { (fetch as Mock).mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({}), }); const result = await api.getSession(); expect(result).toBeNull(); }); it('returns null on network error', async () => { (fetch as Mock).mockRejectedValueOnce(new Error('Network error')); const result = await api.getSession(); expect(result).toBeNull(); }); }); describe('Projects', () => { it('getProjects fetches project list', async () => { const projects = [{ id: '1', name: 'Project 1' }]; mockFetchResponse(projects); const result = await api.getProjects(); expect(fetch).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ headers: expect.objectContaining({ 'Content-Type': 'application/json' }), credentials: 'include', })); expect(result).toEqual(projects); }); it('createProject sends POST with data', async () => { const project = { id: '1', name: 'New Project', color: '#ff0000' }; mockFetchResponse(project); const result = await api.createProject({ name: 'New Project', color: '#ff0000' }); expect(fetch).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'New Project', color: '#ff0000' }), })); expect(result).toEqual(project); }); it('updateProject sends PATCH', async () => { const updated = { id: '1', name: 'Updated' }; mockFetchResponse(updated); const result = await api.updateProject('1', { name: 'Updated' }); expect(fetch).toHaveBeenCalledWith('/api/projects/1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ name: 'Updated' }), })); expect(result).toEqual(updated); }); it('deleteProject sends DELETE', async () => { mockFetchResponse(undefined); await api.deleteProject('1'); expect(fetch).toHaveBeenCalledWith('/api/projects/1', expect.objectContaining({ method: 'DELETE', })); }); }); describe('Tasks', () => { it('getTasks fetches tasks without params', async () => { const tasks = [{ id: '1', title: 'Task 1' }]; mockFetchResponse(tasks); const result = await api.getTasks(); expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ credentials: 'include', })); expect(result).toEqual(tasks); }); it('getTasks includes query params', async () => { const tasks = [{ id: '1', title: 'Task 1' }]; mockFetchResponse(tasks); await api.getTasks({ projectId: 'proj1', completed: false, today: true }); const calledUrl = (fetch as Mock).mock.calls[0][0] as string; expect(calledUrl).toContain('/api/tasks?'); expect(calledUrl).toContain('projectId=proj1'); expect(calledUrl).toContain('completed=false'); expect(calledUrl).toContain('today=true'); }); it('createTask sends POST', async () => { const task = { id: '1', title: 'New Task' }; mockFetchResponse(task); const result = await api.createTask({ title: 'New Task' }); expect(fetch).toHaveBeenCalledWith('/api/tasks', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: 'New Task' }), })); expect(result).toEqual(task); }); it('updateTask sends PATCH', async () => { const updated = { id: '1', title: 'Updated Task' }; mockFetchResponse(updated); const result = await api.updateTask('1', { title: 'Updated Task' }); expect(fetch).toHaveBeenCalledWith('/api/tasks/1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'Updated Task' }), })); expect(result).toEqual(updated); }); it('deleteTask sends DELETE', async () => { mockFetchResponse(undefined); await api.deleteTask('1'); expect(fetch).toHaveBeenCalledWith('/api/tasks/1', expect.objectContaining({ method: 'DELETE', })); }); }); describe('Labels', () => { it('getLabels fetches label list', async () => { const labels = [{ id: '1', name: 'Bug' }]; mockFetchResponse(labels); const result = await api.getLabels(); expect(fetch).toHaveBeenCalledWith('/api/labels', expect.objectContaining({ credentials: 'include', })); expect(result).toEqual(labels); }); it('createLabel sends POST', async () => { const label = { id: '1', name: 'Feature', color: '#00ff00' }; mockFetchResponse(label); const result = await api.createLabel({ name: 'Feature', color: '#00ff00' }); expect(fetch).toHaveBeenCalledWith('/api/labels', expect.objectContaining({ method: 'POST', body: JSON.stringify({ name: 'Feature', color: '#00ff00' }), })); expect(result).toEqual(label); }); }); describe('Auth header', () => { it('sends Authorization header when token is set', async () => { api.setToken('my-token'); mockFetchResponse([]); await api.getProjects(); expect(fetch).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ headers: expect.objectContaining({ 'Authorization': 'Bearer my-token', }), })); // Clean up api.setToken(null); }); it('does not send Authorization header when token is null', async () => { api.setToken(null); mockFetchResponse([]); await api.getProjects(); const headers = (fetch as Mock).mock.calls[0][1].headers; expect(headers['Authorization']).toBeUndefined(); }); }); describe('Error handling', () => { it('throws error with message from non-200 response', async () => { (fetch as Mock).mockResolvedValueOnce({ ok: false, json: () => Promise.resolve({ error: 'Not found' }), }); await expect(api.getProjects()).rejects.toThrow('Not found'); }); it('throws "Request failed" when error body is unparseable', async () => { (fetch as Mock).mockResolvedValueOnce({ ok: false, json: () => Promise.reject(new Error('parse error')), }); await expect(api.getProjects()).rejects.toThrow('Unknown error'); }); }); });