242 lines
7.9 KiB
TypeScript
242 lines
7.9 KiB
TypeScript
/**
|
|||
|
|
* API tests for tasks routes.
|
||
|
|
* These tests verify the task CRUD API logic using mocked Drizzle.
|
||
|
|
* Run with: npm test -- --testPathPattern=tasks
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||
|
|
|
||
|
|
// Mock the database module
|
||
|
|
jest.mock('@project-e/db', () => ({
|
||
|
|
db: {
|
||
|
|
select: jest.fn(),
|
||
|
|
insert: jest.fn(),
|
||
|
|
update: jest.fn(),
|
||
|
|
delete: jest.fn(),
|
||
|
|
},
|
||
|
|
sql: { unsafe: jest.fn() },
|
||
|
|
tasks: {},
|
||
|
|
taskTags: {},
|
||
|
|
taskDependencies: {},
|
||
|
|
tags: {},
|
||
|
|
activityFeed: {},
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('@/lib/auth', () => ({
|
||
|
|
withAuth: (handler: any) => handler,
|
||
|
|
requireWorkspaceAccess: jest.fn().mockResolvedValue(undefined),
|
||
|
|
createErrorResponse: (code: string, message: string, status: number, details?: unknown) => ({
|
||
|
|
code,
|
||
|
|
message,
|
||
|
|
status,
|
||
|
|
details,
|
||
|
|
}),
|
||
|
|
ApiError: class ApiError extends Error {
|
||
|
|
constructor(message: string, public status: number = 400, public code: string = 'BAD_REQUEST') {
|
||
|
|
super(message);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
|
||
|
|
jest.mock('@/lib/activity', () => ({
|
||
|
|
recordActivity: jest.fn().mockResolvedValue(undefined),
|
||
|
|
}));
|
||
|
|
|
||
|
|
describe('Tasks API', () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
jest.clearAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('GET /api/domains/[domainId]/tasks', () => {
|
||
|
|
it('should list tasks with default pagination', async () => {
|
||
|
|
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||
|
|
const { db } = require('@project-e/db');
|
||
|
|
|
||
|
|
const mockTasks = [
|
||
|
|
{ id: '1', title: 'Task 1', status: 'todo', priority: 'medium', domainId: 'domain-1' },
|
||
|
|
{ id: '2', title: 'Task 2', status: 'in_progress', priority: 'high', domainId: 'domain-1' },
|
||
|
|
];
|
||
|
|
|
||
|
|
// Mock the db.select chain
|
||
|
|
const mockSelect = jest.fn().mockReturnThis();
|
||
|
|
const mockFrom = jest.fn().mockReturnThis();
|
||
|
|
const mockWhere = jest.fn().mockReturnThis();
|
||
|
|
const mockOrderBy = jest.fn().mockReturnThis();
|
||
|
|
const mockLimit = jest.fn().mockReturnThis();
|
||
|
|
const mockOffset = jest.fn().mockResolvedValue(mockTasks);
|
||
|
|
|
||
|
|
db.select.mockReturnValue({
|
||
|
|
from: jest.fn().mockReturnValue({
|
||
|
|
where: jest.fn().mockReturnValue({
|
||
|
|
orderBy: jest.fn().mockReturnValue({
|
||
|
|
limit: jest.fn().mockReturnValue({
|
||
|
|
offset: jest.fn().mockResolvedValue(mockTasks),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks');
|
||
|
|
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||
|
|
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should filter by status', async () => {
|
||
|
|
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?status=todo');
|
||
|
|
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should filter by search term', async () => {
|
||
|
|
const { GET } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks?search=test');
|
||
|
|
const response = await GET(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('POST /api/domains/[domainId]/tasks', () => {
|
||
|
|
it('should create a task with required fields', async () => {
|
||
|
|
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||
|
|
const { db } = require('@project-e/db');
|
||
|
|
|
||
|
|
const mockTask = {
|
||
|
|
id: 'new-task-1',
|
||
|
|
title: 'Test Task',
|
||
|
|
status: 'todo',
|
||
|
|
priority: 'medium',
|
||
|
|
domainId: 'domain-1',
|
||
|
|
};
|
||
|
|
|
||
|
|
db.insert.mockReturnValue({
|
||
|
|
values: jest.fn().mockReturnValue({
|
||
|
|
returning: jest.fn().mockResolvedValue([mockTask]),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ title: 'Test Task' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('should reject empty title', async () => {
|
||
|
|
const { POST } = await import('@/app/api/domains/[domainId]/tasks/route');
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ title: '' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1' }) });
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('PATCH /api/domains/[domainId]/tasks/[id]', () => {
|
||
|
|
it('should update task status', async () => {
|
||
|
|
const { PATCH } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
|
||
|
|
const { db } = require('@project-e/db');
|
||
|
|
|
||
|
|
const existingTask = {
|
||
|
|
id: 'task-1',
|
||
|
|
title: 'Test Task',
|
||
|
|
status: 'todo',
|
||
|
|
domainId: 'domain-1',
|
||
|
|
};
|
||
|
|
|
||
|
|
db.select.mockReturnValue({
|
||
|
|
from: jest.fn().mockReturnValue({
|
||
|
|
where: jest.fn().mockReturnValue({
|
||
|
|
limit: jest.fn().mockResolvedValue([existingTask]),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
db.update.mockReturnValue({
|
||
|
|
set: jest.fn().mockReturnValue({
|
||
|
|
where: jest.fn().mockReturnValue({
|
||
|
|
returning: jest.fn().mockResolvedValue([{ ...existingTask, status: 'done' }]),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
|
||
|
|
method: 'PATCH',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ status: 'done' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await PATCH(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('DELETE /api/domains/[domainId]/tasks/[id]', () => {
|
||
|
|
it('should soft delete a task', async () => {
|
||
|
|
const { DELETE } = await import('@/app/api/domains/[domainId]/tasks/[id]/route');
|
||
|
|
const { db } = require('@project-e/db');
|
||
|
|
|
||
|
|
const existingTask = {
|
||
|
|
id: 'task-1',
|
||
|
|
title: 'Test Task',
|
||
|
|
domainId: 'domain-1',
|
||
|
|
};
|
||
|
|
|
||
|
|
db.select.mockReturnValue({
|
||
|
|
from: jest.fn().mockReturnValue({
|
||
|
|
where: jest.fn().mockReturnValue({
|
||
|
|
limit: jest.fn().mockResolvedValue([existingTask]),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
db.update.mockReturnValue({
|
||
|
|
set: jest.fn().mockReturnValue({
|
||
|
|
where: jest.fn().mockResolvedValue(undefined),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1', {
|
||
|
|
method: 'DELETE',
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await DELETE(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||
|
|
expect(response.status).toBe(204);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('Dependencies cycle detection', () => {
|
||
|
|
it('should detect direct self-loop', async () => {
|
||
|
|
const { POST } = await import('@/app/api/domains/[domainId]/tasks/[id]/dependencies/route');
|
||
|
|
const { db } = require('@project-e/db');
|
||
|
|
|
||
|
|
const mockTask = { id: 'task-1', title: 'Test', domainId: 'domain-1' };
|
||
|
|
|
||
|
|
db.select.mockReturnValue({
|
||
|
|
from: jest.fn().mockReturnValue({
|
||
|
|
where: jest.fn().mockReturnValue({
|
||
|
|
limit: jest.fn().mockResolvedValue([mockTask]),
|
||
|
|
}),
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
const request = new Request('http://localhost:3000/api/domains/domain-1/tasks/task-1/dependencies', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ taskId: 'task-1' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
const response = await POST(request, { params: Promise.resolve({ domainId: 'domain-1', id: 'task-1' }) });
|
||
|
|
expect(response).toBeDefined();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|