- Remove unused imports (Flag, Tag, Hash, User, FolderPlus, Check, Plus, Link, cn, formatDate, getPriorityLabel) - Remove unused variable (inbox in Sidebar) - Fix empty catch block with comment - Replace any types with proper Mock/Record types in tests - Suppress set-state-in-effect for intentional form state sync - Remove unused get parameter from zustand store
285 lines
8.6 KiB
TypeScript
285 lines
8.6 KiB
TypeScript
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<string, (...args: unknown[]) => unknown>;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.stubGlobal('fetch', vi.fn());
|
|
const mod = await import('@/lib/api');
|
|
api = mod.api as unknown as Record<string, (...args: unknown[]) => 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');
|
|
});
|
|
});
|
|
});
|