From d7762143bbb7077d0098de130c99407a289f040b Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Wed, 29 Jul 2026 06:14:44 -0400 Subject: [PATCH] test: add Vitest API tests for tasks routes - Tests for GET, POST, PATCH, DELETE task routes - Tests for dependency cycle detection - Jest config with SWC transform and module aliases --- apps/web/__tests__/api/tasks.test.ts | 241 +++++++++++++++++++++++++++ apps/web/jest.config.js | 16 ++ 2 files changed, 257 insertions(+) create mode 100644 apps/web/__tests__/api/tasks.test.ts create mode 100644 apps/web/jest.config.js diff --git a/apps/web/__tests__/api/tasks.test.ts b/apps/web/__tests__/api/tasks.test.ts new file mode 100644 index 0000000..e18b11a --- /dev/null +++ b/apps/web/__tests__/api/tasks.test.ts @@ -0,0 +1,241 @@ +/** + * 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(); + }); + }); +}); diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js new file mode 100644 index 0000000..0a4267c --- /dev/null +++ b/apps/web/jest.config.js @@ -0,0 +1,16 @@ +/** @type {import('jest').Config} */ +const config = { + testEnvironment: 'node', + transform: { + '^.+\\.(t|j)sx?$': ['@swc/jest'], + }, + moduleNameMapper: { + '^@/(.*)$': '/$1', + '^@project-e/db$': '/../../packages/db/src', + '^@project-e/shared$': '/../../packages/shared/src', + }, + testMatch: ['**/__tests__/**/*.test.(ts|tsx|js)'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], +}; + +module.exports = config;