feat(ST-1112): wire story-promotion into saveTask and PATCH /api/tasks/:id
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8b779ccc0b
commit
eb27079ba7
5 changed files with 188 additions and 28 deletions
|
|
@ -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<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
delete: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: { findFirst: ReturnType<typeof vi.fn> }
|
||||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
||||
|
||||
|
|
@ -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<unknown>) => {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||
story: { findFirst: ReturnType<typeof vi.fn> }
|
||||
task: { findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
task: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
storyLog: { create: ReturnType<typeof vi.fn> }
|
||||
todo: { create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -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<unknown>)(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' }),
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
task: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
|
|
@ -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<unknown>) => {
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<SessionData>(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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue