import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/prisma', () => ({ prisma: { task: { findFirst: vi.fn(), update: vi.fn(), findMany: vi.fn(), }, story: { findUniqueOrThrow: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), }, })) vi.mock('@/lib/api-auth', () => ({ authenticateApiRequest: vi.fn(), })) import { prisma } from '@/lib/prisma' import { authenticateApiRequest } from '@/lib/api-auth' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' const mockPrisma = prisma as unknown as { task: { findFirst: ReturnType update: ReturnType findMany: ReturnType } story: { findUniqueOrThrow: ReturnType update: ReturnType } $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType function makeTask(overrides: { userId?: string; membersLength?: number } = {}) { const { userId = 'user-1', membersLength = 0 } = overrides return { id: 'task-1', story: { product: { user_id: userId, members: Array.from({ length: membersLength }, (_, i) => ({ id: `member-${i}` })), }, }, } } function makeRequest(body: unknown, taskId = 'task-1'): [Request, { params: Promise<{ id: string }> }] { return [ new Request(`http://localhost/api/tasks/${taskId}`, { method: 'PATCH', headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, body: JSON.stringify(body), }), { params: Promise.resolve({ id: taskId }) }, ] } describe('PATCH /api/tasks/:id', () => { beforeEach(() => { vi.clearAllMocks() mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) mockPrisma.task.findFirst.mockResolvedValue(makeTask()) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: null, title: 'Task', story_id: 'story-1', }) // Default sibling state: only this task, already DONE → no story-promotion mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) // Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly. mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { return run(prisma) }) }) // TC-T-06 it('returns 422 for invalid status value', async () => { const res = await patchTask(...makeRequest({ status: 'INVALID' })) expect(res.status).toBe(422) }) // TC-T-07 it('returns 422 when body has no recognized fields', async () => { const res = await patchTask(...makeRequest({})) expect(res.status).toBe(422) }) it('returns 422 when body has only unrecognized fields', async () => { const res = await patchTask(...makeRequest({ unknown_field: 'value' })) expect(res.status).toBe(422) }) // TC-T-08 it('updates status only and returns 200 with updated task', async () => { mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS', implementation_plan: null }) const res = await patchTask(...makeRequest({ status: 'in_progress' })) const data = await res.json() expect(res.status).toBe(200) expect(data).toMatchObject({ id: 'task-1', status: 'in_progress' }) expect(mockPrisma.task.update).toHaveBeenCalledWith( expect.objectContaining({ data: { status: 'IN_PROGRESS' }, }) ) }) // TC-T-09 it('updates implementation_plan only and returns 200', async () => { const plan = 'Step 1: implement. Step 2: test.' mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'TO_DO', implementation_plan: plan }) const res = await patchTask(...makeRequest({ implementation_plan: plan })) const data = await res.json() expect(res.status).toBe(200) expect(data.implementation_plan).toBe(plan) expect(mockPrisma.task.update).toHaveBeenCalledWith( expect.objectContaining({ data: { implementation_plan: plan }, }) ) }) // TC-T-10 it('updates both status and implementation_plan and returns 200', async () => { const plan = 'Full plan here.' // First update writes the implementation_plan; second is the helper's status write. mockPrisma.task.update .mockResolvedValueOnce({ id: 'task-1', status: 'TO_DO', implementation_plan: plan }) .mockResolvedValueOnce({ id: 'task-1', title: 'Task', status: 'DONE', story_id: 'story-1', implementation_plan: plan, }) const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan })) const data = await res.json() expect(res.status).toBe(200) expect(data).toMatchObject({ status: 'done', implementation_plan: plan }) // implementation_plan written via direct update; status written via helper update. expect(mockPrisma.task.update).toHaveBeenCalledWith( expect.objectContaining({ data: { implementation_plan: plan } }), ) expect(mockPrisma.task.update).toHaveBeenCalledWith( expect.objectContaining({ data: { status: 'DONE' } }), ) }) // TC-T-11 it('allows update when user is a team member (not product owner)', async () => { mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) mockPrisma.task.findFirst.mockResolvedValue(makeTask({ userId: 'user-2', membersLength: 1 })) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: null }) const res = await patchTask(...makeRequest({ status: 'done' })) expect(res.status).toBe(200) }) it('the three patchable status values are accepted (review is rejected)', async () => { for (const apiStatus of ['todo', 'in_progress', 'done'] as const) { const dbStatus = { todo: 'TO_DO', in_progress: 'IN_PROGRESS', done: 'DONE' }[apiStatus] mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: dbStatus, implementation_plan: null }) const res = await patchTask(...makeRequest({ status: apiStatus })) expect(res.status).toBe(200) } const reviewRes = await patchTask(...makeRequest({ status: 'review' })) expect(reviewRes.status).toBe(422) }) it('promotes story to DONE when last sibling task transitions to DONE', async () => { mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: null, title: 'Task', story_id: 'story-1', }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) const res = await patchTask(...makeRequest({ status: 'done' })) expect(res.status).toBe(200) expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'DONE' }, }) }) it('returns 400 for malformed JSON', async () => { const req = new Request('http://localhost/api/tasks/task-1', { method: 'PATCH', headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, body: '{not json', }) const res = await patchTask(req, { params: Promise.resolve({ id: 'task-1' }) }) expect(res.status).toBe(400) }) })