scrum4me-mcp/__tests__/update-job-status-sprint-gate.test.ts
Madhura68 b80264c26c PBI-50 F5: tests voor SPRINT_IMPLEMENTATION-tools
- update-job-status-sprint-gate: checkSprintVerifyGate per-row
  blockers, SKIPPED-policy, finalizeSprintRunOnDone idempotentie.
- update-task-execution: token-coupling, lifecycle (RUNNING zet
  started_at, DONE/FAILED/SKIPPED zet finished_at), skip_reason.
- job-heartbeat: token-mismatch error, non-SPRINT vs SPRINT
  response-shape, tolerantie voor pause_context=null.
- verify-sprint-task: PARTIAL+summary gate-pass, PARTIAL zonder
  summary gate-fail, DIVERGENT met ALIGNED gate-fail, base_sha
  auto-fill via vorige DONE execution head_sha + persistence,
  MISSING_BASE_SHA error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:53:04 +02:00

192 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprintTaskExecution: {
findMany: vi.fn(),
},
sprintRun: {
findUnique: vi.fn(),
update: vi.fn(),
},
story: {
count: vi.fn(),
},
},
}))
import { prisma } from '../src/prisma.js'
import {
checkSprintVerifyGate,
finalizeSprintRunOnDone,
} from '../src/tools/update-job-status.js'
type MockedPrisma = {
sprintTaskExecution: { findMany: ReturnType<typeof vi.fn> }
sprintRun: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: { count: ReturnType<typeof vi.fn> }
}
const mocked = prisma as unknown as MockedPrisma
const LONG_SUMMARY = 'Refactor touched extra files for type narrowing.'
function execRow(overrides: Record<string, unknown>) {
return {
id: 'exec-' + Math.random().toString(36).slice(2, 8),
task_id: 't1',
order: 0,
status: 'DONE',
verify_result: 'ALIGNED',
verify_summary: null,
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
verify_only_snapshot: false,
task: { code: 'TASK-1', title: 'Sample task' },
...overrides,
}
}
describe('checkSprintVerifyGate', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('rejects when no executions exist (claim-bug)', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/geen SprintTaskExecution-rows/i)
})
it('blocks PENDING/RUNNING executions', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'PENDING' }),
execRow({ status: 'RUNNING' }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) {
expect(r.error).toMatch(/PENDING/)
expect(r.error).toMatch(/RUNNING/)
}
})
it('blocks FAILED executions', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'FAILED' }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/FAILED/)
})
it('blocks SKIPPED unless verify_required_snapshot=ANY', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'SKIPPED', verify_required_snapshot: 'ALIGNED' }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/SKIPPED/)
})
it('allows SKIPPED when verify_required_snapshot=ANY', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'SKIPPED', verify_required_snapshot: 'ANY' }),
])
expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true)
})
it('runs per-row gate for DONE executions', async () => {
// PARTIAL zonder summary onder ALIGNED_OR_PARTIAL → blocker
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({
status: 'DONE',
verify_result: 'PARTIAL',
verify_summary: null,
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
}),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) expect(r.error).toMatch(/DONE-gate/)
})
it('passes when all DONE rows pass per-row gate', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ verify_result: 'ALIGNED' }),
execRow({
verify_result: 'PARTIAL',
verify_summary: LONG_SUMMARY,
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
}),
])
expect((await checkSprintVerifyGate('job-x')).allowed).toBe(true)
})
it('aggregates multiple blockers in one error message', async () => {
mocked.sprintTaskExecution.findMany.mockResolvedValue([
execRow({ status: 'FAILED', task: { code: 'A', title: 'a' } }),
execRow({ status: 'PENDING', task: { code: 'B', title: 'b' } }),
])
const r = await checkSprintVerifyGate('job-x')
expect(r.allowed).toBe(false)
if (!r.allowed) {
expect(r.error).toMatch(/2 task\(s\) blokkeren/)
expect(r.error).toMatch(/A: a/)
expect(r.error).toMatch(/B: b/)
}
})
})
describe('finalizeSprintRunOnDone', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('no-op when SprintRun already DONE (idempotent)', async () => {
mocked.sprintRun.findUnique.mockResolvedValue({
id: 'sr-1',
status: 'DONE',
sprint_id: 's1',
})
await finalizeSprintRunOnDone('sr-1')
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
})
it('no-op when SprintRun does not exist', async () => {
mocked.sprintRun.findUnique.mockResolvedValue(null)
await finalizeSprintRunOnDone('sr-x')
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
})
it('no-op when stories still open', async () => {
mocked.sprintRun.findUnique.mockResolvedValue({
id: 'sr-1',
status: 'RUNNING',
sprint_id: 's1',
})
mocked.story.count.mockResolvedValue(2)
await finalizeSprintRunOnDone('sr-1')
expect(mocked.sprintRun.update).not.toHaveBeenCalled()
})
it('sets SprintRun → DONE when all stories DONE/FAILED', async () => {
mocked.sprintRun.findUnique.mockResolvedValue({
id: 'sr-1',
status: 'RUNNING',
sprint_id: 's1',
})
mocked.story.count.mockResolvedValue(0)
await finalizeSprintRunOnDone('sr-1')
expect(mocked.sprintRun.update).toHaveBeenCalledWith({
where: { id: 'sr-1' },
data: expect.objectContaining({
status: 'DONE',
finished_at: expect.any(Date),
}),
})
})
})