diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts new file mode 100644 index 0000000..418caa7 --- /dev/null +++ b/__tests__/lib/tasks-status-update.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + task: { + update: vi.fn(), + findMany: vi.fn(), + }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' + +const mockPrisma = prisma as unknown as { + task: { + update: ReturnType + findMany: ReturnType + } + story: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() + // Pass-through: $transaction(run) just calls run with the mocked prisma client. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) + }) +}) + +const TASK_BASE = { + id: 'task-1', + title: 'Task', + story_id: 'story-1', + implementation_plan: null, +} + +describe('updateTaskStatusWithStoryPromotion', () => { + it('promotes story to DONE when last sibling task transitions to DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'DONE' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe('promoted') + expect(result.storyId).toBe('story-1') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + + it('does not promote when story is already DONE (idempotent)', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('does not promote when not all siblings are DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'DONE' }, + { status: 'IN_PROGRESS' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'IN_PROGRESS' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe('demoted') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'IN_SPRINT' }, + }) + }) + + it('does not demote when story is not DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('updates the task regardless of story-status change', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'IN_PROGRESS' }, + select: expect.any(Object), + }) + }) + + it('uses the provided transaction client when passed', async () => { + const tx = { + task: { update: vi.fn(), findMany: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + } + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) + + expect(result.storyStatusChange).toBe('promoted') + // $transaction should NOT be called when caller already provides a tx. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(tx.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +}) diff --git a/lib/tasks-status-update.ts b/lib/tasks-status-update.ts new file mode 100644 index 0000000..ca273ca --- /dev/null +++ b/lib/tasks-status-update.ts @@ -0,0 +1,72 @@ +import type { Prisma, TaskStatus } from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export type StoryStatusChange = 'promoted' | 'demoted' | null + +export interface UpdateTaskStatusResult { + task: { + id: string + title: string + status: TaskStatus + story_id: string + implementation_plan: string | null + } + storyStatusChange: StoryStatusChange + storyId: string +} + +// Update task.status atomically and auto-promote/demote the parent story: +// - All sibling tasks DONE → story.status = DONE +// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT +// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog", +// which is a sprint-management action, not a status side-effect. +export async function updateTaskStatusWithStoryPromotion( + taskId: string, + newStatus: TaskStatus, + client?: Prisma.TransactionClient, +): Promise { + const run = async (tx: Prisma.TransactionClient): Promise => { + const task = await tx.task.update({ + where: { id: taskId }, + data: { status: newStatus }, + select: { + id: true, + title: true, + status: true, + story_id: true, + implementation_plan: true, + }, + }) + + const siblings = await tx.task.findMany({ + where: { story_id: task.story_id }, + select: { status: true }, + }) + const allDone = siblings.every((s) => s.status === 'DONE') + + const story = await tx.story.findUniqueOrThrow({ + where: { id: task.story_id }, + select: { status: true }, + }) + + let storyStatusChange: StoryStatusChange = null + if (newStatus === 'DONE' && allDone && story.status !== 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'DONE' }, + }) + storyStatusChange = 'promoted' + } else if (newStatus !== 'DONE' && story.status === 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'IN_SPRINT' }, + }) + storyStatusChange = 'demoted' + } + + return { task, storyStatusChange, storyId: task.story_id } + } + + if (client) return run(client) + return prisma.$transaction(run) +}