From eb27079ba724e3c18be2dbf2fcbf0723490f5aea Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 30 Apr 2026 00:22:58 +0200 Subject: [PATCH] feat(ST-1112): wire story-promotion into saveTask and PATCH /api/tasks/:id Co-Authored-By: Claude Sonnet 4.6 --- __tests__/actions/tasks-dialog.test.ts | 57 ++++++++++++++++++++++- __tests__/api/security.test.ts | 30 ++++++++++-- __tests__/api/tasks.test.ts | 64 ++++++++++++++++++++++++-- actions/tasks.ts | 34 +++++++++----- app/api/tasks/[id]/route.ts | 31 +++++++++---- 5 files changed, 188 insertions(+), 28 deletions(-) diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts index 55c8594..877aac5 100644 --- a/__tests__/actions/tasks-dialog.test.ts +++ b/__tests__/actions/tasks-dialog.test.ts @@ -18,10 +18,14 @@ vi.mock('@/lib/prisma', () => ({ create: vi.fn(), update: vi.fn(), delete: vi.fn(), + findMany: vi.fn(), }, story: { findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), }, + $transaction: vi.fn(), }, })) @@ -35,8 +39,14 @@ const mockPrisma = prisma as unknown as { create: ReturnType update: ReturnType delete: ReturnType + findMany: ReturnType } - story: { findFirst: ReturnType } + story: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType } const mockSession = getIronSession as ReturnType @@ -58,6 +68,10 @@ const STORY = { sprint_id: 'sprint-1' } beforeEach(() => { vi.clearAllMocks() mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + // Pass-through transaction so saveTask's $transaction wrapper executes its callback inline. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) + }) }) // ─── saveTask ──────────────────────────────────────────────────────────────── @@ -114,6 +128,47 @@ describe('saveTask — edit (cross-tenant scope)', () => { }) }) +describe('saveTask — edit met status-promotie', () => { + it('promotes story naar DONE wanneer status flip naar DONE alle siblings DONE maakt', async () => { + mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS' }) + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + title: 'Test taak', + status: 'IN_PROGRESS', + story_id: 'story-1', + implementation_plan: null, + }) + // Wanneer de helper draait, gebruikt-ie tx.task.update voor de status-flip. + // Dezelfde mock vangt beide updates op; tweede return-value voor de status-update. + mockPrisma.task.update.mockResolvedValueOnce({ + id: 'task-1', + title: 'Test taak', + status: 'IN_PROGRESS', + story_id: 'story-1', + implementation_plan: null, + }).mockResolvedValueOnce({ + id: 'task-1', + title: 'Test taak', + status: 'DONE', + story_id: 'story-1', + implementation_plan: null, + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await saveTask( + { ...VALID_INPUT, status: 'DONE' }, + { taskId: 'task-1', productId: 'p-1' }, + ) + + expect(result).toMatchObject({ ok: true }) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +}) + describe('saveTask — create (cross-tenant scope)', () => { it('retourneert forbidden als story buiten scope valt', async () => { mockPrisma.story.findFirst.mockResolvedValue(null) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 3df9d88..4d37fdd 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -11,10 +11,13 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), }, task: { findFirst: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, storyLog: { create: vi.fn(), @@ -43,8 +46,16 @@ import { POST as postTodo } from '@/app/api/todos/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } sprint: { findFirst: ReturnType } - story: { findFirst: ReturnType } - task: { findFirst: ReturnType; update: ReturnType } + story: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } + task: { + findFirst: ReturnType + update: ReturnType + findMany: ReturnType + } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType @@ -85,6 +96,11 @@ function routeCtx(id: string) { beforeEach(() => { vi.clearAllMocks() + // Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes. + mockPrisma.$transaction.mockImplementation(async (run: unknown) => { + if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise)(prisma) + return run + }) }) // ─── GET /api/products ──────────────────────────────────────────────────────── @@ -386,7 +402,15 @@ describe('PATCH /api/tasks/:id', () => { id: 'task-1', story: { product: { user_id: 'user-1' } }, }) - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + title: 'Task', + status: 'DONE', + story_id: 'story-1', + implementation_plan: null, + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index b51f55b..ed0616e 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -5,7 +5,13 @@ vi.mock('@/lib/prisma', () => ({ task: { findFirst: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), }, })) @@ -18,7 +24,16 @@ 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 } + task: { + findFirst: ReturnType + update: ReturnType + findMany: ReturnType + } + story: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType @@ -55,6 +70,15 @@ describe('PATCH /api/tasks/:id', () => { 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) }) }) @@ -111,17 +135,28 @@ describe('PATCH /api/tasks/:id', () => { // TC-T-10 it('updates both status and implementation_plan and returns 200', async () => { const plan = 'Full plan here.' - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan }) + // 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: { status: 'DONE', implementation_plan: plan }, - }) + expect.objectContaining({ data: { implementation_plan: plan } }), + ) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: 'DONE' } }), ) }) @@ -146,6 +181,25 @@ describe('PATCH /api/tasks/:id', () => { 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', diff --git a/actions/tasks.ts b/actions/tasks.ts index 3cfd203..d3cc5c6 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -9,6 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -53,19 +54,30 @@ export async function saveTask( if (context.taskId) { const existing = await prisma.task.findFirst({ where: { id: context.taskId, story: { product: scope } }, + select: { id: true, status: true }, }) if (!existing) return { ok: false, code: 403, error: 'forbidden' } - const task = await prisma.task.update({ - where: { id: context.taskId }, - data: { - title, - description: description ?? null, - implementation_plan: implementation_plan ?? null, - priority, - ...(status !== undefined ? { status } : {}), - }, - select: { id: true, title: true, status: true }, + const taskId = context.taskId + const statusChanged = status !== undefined && status !== existing.status + + const task = await prisma.$transaction(async (tx) => { + const updated = await tx.task.update({ + where: { id: taskId }, + data: { + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + }, + select: { id: true, title: true, status: true }, + }) + + if (statusChanged) { + const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) + return { id: result.task.id, title: result.task.title, status: result.task.status } + } + return updated }) revalidatePath(`/products/${context.productId}/sprint`) @@ -222,7 +234,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await prisma.task.update({ where: { id }, data: { status } }) + await updateTaskStatusWithStoryPromotion(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index c183ed2..ef17ccc 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -2,6 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. @@ -82,14 +83,28 @@ export async function PATCH( } } - const updated = await prisma.task.update({ - where: { id }, - data: { - ...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }), - ...(parsed.data.implementation_plan !== undefined && { - implementation_plan: parsed.data.implementation_plan, - }), - }, + const updated = await prisma.$transaction(async (tx) => { + const planUpdate = parsed.data.implementation_plan !== undefined + ? await tx.task.update({ + where: { id }, + data: { implementation_plan: parsed.data.implementation_plan }, + select: { id: true, status: true, implementation_plan: true }, + }) + : null + + if (dbStatus !== undefined && dbStatus !== null) { + const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx) + return { + id: result.task.id, + status: result.task.status, + implementation_plan: result.task.implementation_plan, + } + } + + if (planUpdate) return planUpdate + + // Should not reach here — patchSchema rejects bodies without status or implementation_plan. + throw new Error('Geen wijzigingen') }) return Response.json({