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>
This commit is contained in:
parent
876a7ad5d9
commit
b80264c26c
4 changed files with 744 additions and 0 deletions
137
__tests__/job-heartbeat.test.ts
Normal file
137
__tests__/job-heartbeat.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
sprintRun: { findUnique: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/auth.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../src/auth.js')>()
|
||||
return { ...original, requireWriteAccess: vi.fn() }
|
||||
})
|
||||
|
||||
import { prisma } from '../src/prisma.js'
|
||||
import { requireWriteAccess } from '../src/auth.js'
|
||||
import { registerJobHeartbeatTool } from '../src/tools/job-heartbeat.js'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
$queryRaw: ReturnType<typeof vi.fn>
|
||||
sprintRun: { findUnique: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
|
||||
const TOKEN_ID = 'tok-owner'
|
||||
|
||||
function makeServer() {
|
||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
const server = {
|
||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
||||
handler = fn
|
||||
}),
|
||||
call: (args: Record<string, unknown>) => handler(args),
|
||||
}
|
||||
registerJobHeartbeatTool(server as unknown as McpServer)
|
||||
return server
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuth.mockResolvedValue({
|
||||
userId: 'u-1',
|
||||
tokenId: TOKEN_ID,
|
||||
username: 'agent',
|
||||
isDemo: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('job_heartbeat', () => {
|
||||
it('returns 403-style error when no row matched (token mismatch / terminal)', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([])
|
||||
const server = makeServer()
|
||||
const result = (await server.call({ job_id: 'job-x' })) as {
|
||||
content: { text: string }[]
|
||||
isError?: boolean
|
||||
}
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/not found|terminal|claimed_by/i)
|
||||
})
|
||||
|
||||
it('non-SPRINT job returns ok + lease_until without sprint fields', async () => {
|
||||
const lease = new Date()
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 'job-1',
|
||||
lease_until: lease,
|
||||
kind: 'TASK_IMPLEMENTATION',
|
||||
sprint_run_id: null,
|
||||
},
|
||||
])
|
||||
const server = makeServer()
|
||||
const result = (await server.call({ job_id: 'job-1' })) as {
|
||||
content: { text: string }[]
|
||||
}
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body).toEqual({
|
||||
ok: true,
|
||||
job_id: 'job-1',
|
||||
lease_until: lease.toISOString(),
|
||||
sprint_run_status: null,
|
||||
sprint_run_pause_reason: null,
|
||||
})
|
||||
expect(mockPrisma.sprintRun.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('SPRINT job returns sprint_run_status from sibling lookup', async () => {
|
||||
const lease = new Date()
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 'job-2',
|
||||
lease_until: lease,
|
||||
kind: 'SPRINT_IMPLEMENTATION',
|
||||
sprint_run_id: 'sr-1',
|
||||
},
|
||||
])
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
||||
status: 'PAUSED',
|
||||
pause_context: { pause_reason: 'QUOTA_DEPLETED' },
|
||||
})
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({ job_id: 'job-2' })) as {
|
||||
content: { text: string }[]
|
||||
}
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body).toMatchObject({
|
||||
ok: true,
|
||||
sprint_run_status: 'PAUSED',
|
||||
sprint_run_pause_reason: 'QUOTA_DEPLETED',
|
||||
})
|
||||
})
|
||||
|
||||
it('SPRINT job tolerates missing pause_context', async () => {
|
||||
const lease = new Date()
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 'job-3',
|
||||
lease_until: lease,
|
||||
kind: 'SPRINT_IMPLEMENTATION',
|
||||
sprint_run_id: 'sr-2',
|
||||
},
|
||||
])
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
||||
status: 'RUNNING',
|
||||
pause_context: null,
|
||||
})
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({ job_id: 'job-3' })) as {
|
||||
content: { text: string }[]
|
||||
}
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body.sprint_run_status).toBe('RUNNING')
|
||||
expect(body.sprint_run_pause_reason).toBeNull()
|
||||
})
|
||||
})
|
||||
192
__tests__/update-job-status-sprint-gate.test.ts
Normal file
192
__tests__/update-job-status-sprint-gate.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
199
__tests__/update-task-execution.test.ts
Normal file
199
__tests__/update-task-execution.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
sprintTaskExecution: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/auth.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../src/auth.js')>()
|
||||
return { ...original, requireWriteAccess: vi.fn() }
|
||||
})
|
||||
|
||||
import { prisma } from '../src/prisma.js'
|
||||
import { requireWriteAccess } from '../src/auth.js'
|
||||
import { registerUpdateTaskExecutionTool } from '../src/tools/update-task-execution.js'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
sprintTaskExecution: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
|
||||
const TOKEN_ID = 'tok-owner'
|
||||
|
||||
function makeServer() {
|
||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
const server = {
|
||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
||||
handler = fn
|
||||
}),
|
||||
call: (args: Record<string, unknown>) => handler(args),
|
||||
}
|
||||
registerUpdateTaskExecutionTool(server as unknown as McpServer)
|
||||
return server
|
||||
}
|
||||
|
||||
function execRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'exec-1',
|
||||
sprint_job_id: 'job-1',
|
||||
sprint_job: {
|
||||
claimed_by_token_id: TOKEN_ID,
|
||||
status: 'CLAIMED',
|
||||
kind: 'SPRINT_IMPLEMENTATION',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuth.mockResolvedValue({
|
||||
userId: 'u-1',
|
||||
tokenId: TOKEN_ID,
|
||||
username: 'agent',
|
||||
isDemo: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('update_task_execution', () => {
|
||||
it('rejects when execution not found', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null)
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'missing',
|
||||
status: 'RUNNING',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/not found/i)
|
||||
})
|
||||
|
||||
it('rejects wrong job-kind', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({
|
||||
sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'CLAIMED', kind: 'TASK_IMPLEMENTATION' },
|
||||
}),
|
||||
)
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
status: 'RUNNING',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/SPRINT_IMPLEMENTATION/)
|
||||
})
|
||||
|
||||
it('rejects when token does not own the job', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({
|
||||
sprint_job: { claimed_by_token_id: 'other-token', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' },
|
||||
}),
|
||||
)
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
status: 'RUNNING',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/Forbidden/)
|
||||
})
|
||||
|
||||
it('rejects when job is in terminal state', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({
|
||||
sprint_job: { claimed_by_token_id: TOKEN_ID, status: 'DONE', kind: 'SPRINT_IMPLEMENTATION' },
|
||||
}),
|
||||
)
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
status: 'DONE',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/terminal/)
|
||||
})
|
||||
|
||||
it('writes started_at on RUNNING', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
||||
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
|
||||
id: 'exec-1',
|
||||
status: 'RUNNING',
|
||||
base_sha: null,
|
||||
head_sha: null,
|
||||
verify_result: null,
|
||||
verify_summary: null,
|
||||
skip_reason: null,
|
||||
started_at: new Date(),
|
||||
finished_at: null,
|
||||
})
|
||||
|
||||
const server = makeServer()
|
||||
await server.call({ execution_id: 'exec-1', status: 'RUNNING' })
|
||||
|
||||
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
|
||||
expect(updateCall.data.status).toBe('RUNNING')
|
||||
expect(updateCall.data.started_at).toBeInstanceOf(Date)
|
||||
expect(updateCall.data.finished_at).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes finished_at on DONE/FAILED/SKIPPED', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
||||
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
|
||||
id: 'exec-1',
|
||||
status: 'DONE',
|
||||
base_sha: 'sha-base',
|
||||
head_sha: 'sha-head',
|
||||
verify_result: null,
|
||||
verify_summary: null,
|
||||
skip_reason: null,
|
||||
started_at: new Date(),
|
||||
finished_at: new Date(),
|
||||
})
|
||||
|
||||
const server = makeServer()
|
||||
await server.call({
|
||||
execution_id: 'exec-1',
|
||||
status: 'DONE',
|
||||
head_sha: 'sha-head',
|
||||
})
|
||||
|
||||
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
|
||||
expect(updateCall.data.status).toBe('DONE')
|
||||
expect(updateCall.data.finished_at).toBeInstanceOf(Date)
|
||||
expect(updateCall.data.head_sha).toBe('sha-head')
|
||||
})
|
||||
|
||||
it('persists skip_reason on SKIPPED', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
||||
mockPrisma.sprintTaskExecution.update.mockResolvedValue({
|
||||
id: 'exec-1',
|
||||
status: 'SKIPPED',
|
||||
base_sha: null,
|
||||
head_sha: null,
|
||||
verify_result: null,
|
||||
verify_summary: null,
|
||||
skip_reason: 'no-op task',
|
||||
started_at: null,
|
||||
finished_at: new Date(),
|
||||
})
|
||||
|
||||
const server = makeServer()
|
||||
await server.call({
|
||||
execution_id: 'exec-1',
|
||||
status: 'SKIPPED',
|
||||
skip_reason: 'no-op task',
|
||||
})
|
||||
|
||||
const updateCall = mockPrisma.sprintTaskExecution.update.mock.calls[0][0]
|
||||
expect(updateCall.data.skip_reason).toBe('no-op task')
|
||||
expect(updateCall.data.finished_at).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
216
__tests__/verify-sprint-task.test.ts
Normal file
216
__tests__/verify-sprint-task.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../src/prisma.js', () => ({
|
||||
prisma: {
|
||||
sprintTaskExecution: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/auth.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../src/auth.js')>()
|
||||
return { ...original, requireWriteAccess: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('../src/verify/classify.js', () => ({
|
||||
classifyDiffAgainstPlan: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(),
|
||||
}))
|
||||
|
||||
import { prisma } from '../src/prisma.js'
|
||||
import { requireWriteAccess } from '../src/auth.js'
|
||||
import { classifyDiffAgainstPlan } from '../src/verify/classify.js'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { registerVerifySprintTaskTool } from '../src/tools/verify-sprint-task.js'
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
sprintTaskExecution: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockAuth = requireWriteAccess as ReturnType<typeof vi.fn>
|
||||
const mockClassify = classifyDiffAgainstPlan as ReturnType<typeof vi.fn>
|
||||
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
const TOKEN_ID = 'tok-owner'
|
||||
|
||||
function makeServer() {
|
||||
let handler: (args: Record<string, unknown>) => Promise<unknown>
|
||||
const server = {
|
||||
registerTool: vi.fn((_name: string, _meta: unknown, fn: typeof handler) => {
|
||||
handler = fn
|
||||
}),
|
||||
call: (args: Record<string, unknown>) => handler(args),
|
||||
}
|
||||
registerVerifySprintTaskTool(server as unknown as McpServer)
|
||||
return server
|
||||
}
|
||||
|
||||
function stubGitDiff(stdout: string) {
|
||||
// promisify(execFile) calls (cmd, args, opts, cb)
|
||||
mockExecFile.mockImplementation(
|
||||
(
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
_opts: unknown,
|
||||
cb: (err: null, result: { stdout: string; stderr: string }) => void,
|
||||
) => {
|
||||
cb(null, { stdout, stderr: '' })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function execRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'exec-1',
|
||||
sprint_job_id: 'job-1',
|
||||
order: 0,
|
||||
base_sha: 'sha-base',
|
||||
plan_snapshot: 'frozen plan',
|
||||
verify_required_snapshot: 'ALIGNED_OR_PARTIAL',
|
||||
verify_only_snapshot: false,
|
||||
sprint_job: {
|
||||
claimed_by_token_id: TOKEN_ID,
|
||||
status: 'CLAIMED',
|
||||
kind: 'SPRINT_IMPLEMENTATION',
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAuth.mockResolvedValue({
|
||||
userId: 'u-1',
|
||||
tokenId: TOKEN_ID,
|
||||
username: 'agent',
|
||||
isDemo: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('verify_sprint_task', () => {
|
||||
it('rejects when execution not found', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(null)
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'missing',
|
||||
worktree_path: '/tmp/wt',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/not found/i)
|
||||
})
|
||||
|
||||
it('rejects wrong token', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({
|
||||
sprint_job: { claimed_by_token_id: 'other', status: 'CLAIMED', kind: 'SPRINT_IMPLEMENTATION' },
|
||||
}),
|
||||
)
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
worktree_path: '/tmp/wt',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/Forbidden/)
|
||||
})
|
||||
|
||||
it('PARTIAL with summary returns allowed_for_done=true under ALIGNED_OR_PARTIAL', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
||||
stubGitDiff('diff --git a/x b/x\n+ change\n')
|
||||
mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'extra files' })
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
worktree_path: '/tmp/wt',
|
||||
summary: 'Refactor touched extra files for type narrowing.',
|
||||
})) as { content: { text: string }[] }
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body.result).toBe('partial')
|
||||
expect(body.allowed_for_done).toBe(true)
|
||||
expect(body.reason).toBeNull()
|
||||
})
|
||||
|
||||
it('PARTIAL without summary returns allowed_for_done=false', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(execRecord())
|
||||
stubGitDiff('diff --git a/x b/x\n')
|
||||
mockClassify.mockReturnValue({ result: 'PARTIAL', reasoning: 'r' })
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
worktree_path: '/tmp/wt',
|
||||
})) as { content: { text: string }[] }
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body.result).toBe('partial')
|
||||
expect(body.allowed_for_done).toBe(false)
|
||||
expect(body.reason).toMatch(/summary/i)
|
||||
})
|
||||
|
||||
it('DIVERGENT with strict ALIGNED returns allowed_for_done=false', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({ verify_required_snapshot: 'ALIGNED' }),
|
||||
)
|
||||
stubGitDiff('diff --git a/x b/x\n')
|
||||
mockClassify.mockReturnValue({ result: 'DIVERGENT', reasoning: 'r' })
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
worktree_path: '/tmp/wt',
|
||||
summary: 'Long enough summary describing the deviation rationale clearly.',
|
||||
})) as { content: { text: string }[] }
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body.allowed_for_done).toBe(false)
|
||||
expect(body.reason).toMatch(/ALIGNED/)
|
||||
})
|
||||
|
||||
it('auto-fills base_sha from previous DONE execution head_sha', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({ order: 1, base_sha: null }),
|
||||
)
|
||||
mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue({
|
||||
head_sha: 'prev-head-sha',
|
||||
})
|
||||
stubGitDiff('diff\n')
|
||||
mockClassify.mockReturnValue({ result: 'ALIGNED', reasoning: 'ok' })
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
worktree_path: '/tmp/wt',
|
||||
})) as { content: { text: string }[] }
|
||||
const body = JSON.parse(result.content[0].text)
|
||||
expect(body.base_sha).toBe('prev-head-sha')
|
||||
|
||||
// Persisted back to row
|
||||
const updateCalls = mockPrisma.sprintTaskExecution.update.mock.calls
|
||||
const baseShaPersist = updateCalls.find((c) => c[0].data.base_sha === 'prev-head-sha')
|
||||
expect(baseShaPersist).toBeDefined()
|
||||
})
|
||||
|
||||
it('errors when base_sha cannot be derived (no prior DONE)', async () => {
|
||||
mockPrisma.sprintTaskExecution.findUnique.mockResolvedValue(
|
||||
execRecord({ order: 2, base_sha: null }),
|
||||
)
|
||||
mockPrisma.sprintTaskExecution.findFirst.mockResolvedValue(null)
|
||||
|
||||
const server = makeServer()
|
||||
const result = (await server.call({
|
||||
execution_id: 'exec-1',
|
||||
worktree_path: '/tmp/wt',
|
||||
})) as { content: { text: string }[]; isError?: boolean }
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0].text).toMatch(/MISSING_BASE_SHA/)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue