diff --git a/__tests__/lib/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts new file mode 100644 index 0000000..9eb3a5d --- /dev/null +++ b/__tests__/lib/sprint-conflicts.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi } from 'vitest' +import type { StoryStatus } from '@prisma/client' + +import { + getBlockingSprintMap, + isEligibleForSprint, + partitionByEligibility, +} from '@/lib/sprint-conflicts' + +function mockPrisma(stories: Array>) { + return { + story: { + findMany: vi.fn().mockResolvedValue(stories), + }, + } as unknown as Parameters[0] +} + +describe('isEligibleForSprint', () => { + it('returns true for OPEN story without sprint', () => { + expect( + isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }), + ).toBe(true) + }) + + it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => { + expect( + isEligibleForSprint({ + sprint_id: null, + status: 'IN_SPRINT' as StoryStatus, + }), + ).toBe(true) + }) + + it('returns false for DONE story without sprint', () => { + expect( + isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }), + ).toBe(false) + }) + + it('returns false when story is in any sprint (open status)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'OPEN' as StoryStatus, + }), + ).toBe(false) + }) + + it('returns false when story is in any sprint (done status)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'DONE' as StoryStatus, + }), + ).toBe(false) + }) +}) + +describe('partitionByEligibility', () => { + it('returns empty partition for empty input', async () => { + const prisma = mockPrisma([]) + const result = await partitionByEligibility(prisma, []) + expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] }) + }) + + it('classifies all eligible when stories are free + OPEN', async () => { + const prisma = mockPrisma([ + { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, + { id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null }, + ]) + const result = await partitionByEligibility(prisma, ['s1', 's2']) + expect(result.eligible).toEqual(['s1', 's2']) + expect(result.notEligible).toEqual([]) + expect(result.crossSprint).toEqual([]) + }) + + it('marks DONE stories as notEligible with reason=DONE', async () => { + const prisma = mockPrisma([ + { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.eligible).toEqual([]) + expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) + }) + + it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-other', + status: 'IN_SPRINT', + sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.crossSprint).toEqual([ + { storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' }, + ]) + expect(result.notEligible).toEqual([ + { storyId: 's1', reason: 'IN_OTHER_SPRINT' }, + ]) + expect(result.eligible).toEqual([]) + }) + + it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: null, + status: 'OPEN', + sprint: null, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.eligible).toEqual(['s1']) + }) + + it('does NOT mark crossSprint for stories in CLOSED other sprint', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-closed', + status: 'DONE', + sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.crossSprint).toEqual([]) + expect(result.notEligible).toEqual([ + { storyId: 's1', reason: 'IN_OTHER_SPRINT' }, + ]) + }) + + it('respects excludeSprintId — story in same sprint is eligible', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-active', + status: 'IN_SPRINT', + sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active') + expect(result.eligible).toEqual(['s1']) + expect(result.crossSprint).toEqual([]) + }) +}) + +describe('getBlockingSprintMap', () => { + it('returns empty map for empty input', async () => { + const prisma = mockPrisma([]) + const result = await getBlockingSprintMap(prisma, 'p1', []) + expect(result.size).toBe(0) + }) + + it('returns blocking sprint info for stories in OPEN sprints', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-x', + sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' }, + }, + ]) + const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) + expect(result.get('s1')).toEqual({ + sprintId: 'sprint-x', + sprintName: 'SP-X', + }) + }) + + it('excludes the active sprint from blocking', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-active', + sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, + }, + ]) + const result = await getBlockingSprintMap( + prisma, + 'p1', + ['s1'], + 'sprint-active', + ) + expect(result.size).toBe(0) + }) + + it('does not include CLOSED sprints (filtered at DB query level)', async () => { + // The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories + // are already filtered out before reaching this function's mapping logic. + const prisma = mockPrisma([]) + const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) + expect(result.size).toBe(0) + }) +}) diff --git a/lib/sprint-conflicts.ts b/lib/sprint-conflicts.ts new file mode 100644 index 0000000..780c4da --- /dev/null +++ b/lib/sprint-conflicts.ts @@ -0,0 +1,116 @@ +import type { Prisma, PrismaClient, StoryStatus } from '@prisma/client' + +export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT' + +export type CrossSprintBlock = { + storyId: string + sprintId: string + sprintName: string +} + +export type EligibilityPartition = { + eligible: string[] + notEligible: { storyId: string; reason: EligibilityReason }[] + crossSprint: CrossSprintBlock[] +} + +type StoryEligibilityInput = { + sprint_id: string | null + status: StoryStatus +} + +export function isEligibleForSprint(story: StoryEligibilityInput): boolean { + return story.sprint_id === null && story.status !== 'DONE' +} + +type PrismaLike = Pick | Prisma.TransactionClient + +export async function partitionByEligibility( + prisma: PrismaLike, + storyIds: string[], + excludeSprintId?: string, +): Promise { + if (storyIds.length === 0) { + return { eligible: [], notEligible: [], crossSprint: [] } + } + + const stories = await prisma.story.findMany({ + where: { id: { in: storyIds } }, + select: { + id: true, + sprint_id: true, + status: true, + sprint: { select: { id: true, code: true, status: true } }, + }, + }) + + const eligible: string[] = [] + const notEligible: { storyId: string; reason: EligibilityReason }[] = [] + const crossSprint: CrossSprintBlock[] = [] + + for (const story of stories) { + const inOtherSprint = story.sprint_id !== null && story.sprint_id !== excludeSprintId + const inSameSprint = excludeSprintId !== undefined && story.sprint_id === excludeSprintId + + if (inOtherSprint) { + if (story.sprint && story.sprint.status === 'OPEN') { + crossSprint.push({ + storyId: story.id, + sprintId: story.sprint.id, + sprintName: story.sprint.code, + }) + } + notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' }) + continue + } + + if (story.status === 'DONE') { + notEligible.push({ storyId: story.id, reason: 'DONE' }) + continue + } + + if (inSameSprint) { + eligible.push(story.id) + continue + } + + eligible.push(story.id) + } + + return { eligible, notEligible, crossSprint } +} + +export async function getBlockingSprintMap( + prisma: PrismaLike, + productId: string, + storyIds: string[], + excludeSprintId?: string, +): Promise> { + const out = new Map() + if (storyIds.length === 0) return out + + const stories = await prisma.story.findMany({ + where: { + id: { in: storyIds }, + product_id: productId, + sprint_id: { not: null }, + sprint: { status: 'OPEN' }, + }, + select: { + id: true, + sprint_id: true, + sprint: { select: { id: true, code: true, status: true } }, + }, + }) + + for (const story of stories) { + if (!story.sprint) continue + if (excludeSprintId !== undefined && story.sprint.id === excludeSprintId) continue + out.set(story.id, { + sprintId: story.sprint.id, + sprintName: story.sprint.code, + }) + } + + return out +}