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:
Janpeter Visser 2026-04-30 00:22:58 +02:00
parent 8b779ccc0b
commit eb27079ba7
5 changed files with 188 additions and 28 deletions

View file

@ -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)

View file

@ -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' }),

View file

@ -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',