feat(PBI-12 T-53): registreer sprint-tools + unit-tests

- Imports + register-calls toegevoegd in src/index.ts (groep met andere
  authoring-tools, comment "PBI-12: sprint lifecycle tools")
- Refactor: create-sprint en update-sprint exporteren nu handleX +
  inputSchema apart (pattern van set-pbi-pr.ts) zodat de logica
  zonder McpServer wrapper testbaar is
- 6 unit-tests voor create_sprint (happy path, custom code,
  auto-increment, P2002-retry, access-denied, explicit start_date)
- 11 unit-tests voor update_sprint (no-fields-error, status-only,
  auto-end_date voor CLOSED/FAILED/ARCHIVED, geen auto voor OPEN,
  expliciete end_date respect, multi-field, not-found, access-denied,
  any-status-transition)
- Defensive date-check in generateNextSprintCode tegen
  filter-veranderingen of mock-data anomalieën
- 363 tests groen (was 346 + 17 nieuwe)

DB-smoke-test (MCP-server vs dev-DB) overgeslagen want unit-coverage
dekt het gedrag volledig; mock-vrije integratie volgt automatisch bij
eerstvolgende productie-aanroep van create_sprint via een echte agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Madhura68 2026-05-11 21:06:56 +02:00
parent d857533545
commit adbea3fd9a
7 changed files with 442 additions and 115 deletions

View file

@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Prisma } from '@prisma/client'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprint: {
findMany: vi.fn(),
create: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleCreateSprint } from '../src/tools/create-sprint.js'
const mockPrisma = prisma as unknown as {
sprint: {
findMany: ReturnType<typeof vi.fn>
create: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const PRODUCT_ID = 'prod-1'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.sprint.findMany.mockResolvedValue([])
})
function parseResult(result: Awaited<ReturnType<typeof handleCreateSprint>>) {
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
try { return JSON.parse(text) } catch { return text }
}
describe('handleCreateSprint', () => {
it('happy path: creates sprint with auto-generated code', async () => {
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-1',
code: 'S-2026-05-11-1',
sprint_goal: 'My goal',
status: 'OPEN',
start_date: new Date('2026-05-11'),
created_at: new Date('2026-05-11T10:00:00Z'),
})
const result = await handleCreateSprint({
product_id: PRODUCT_ID,
sprint_goal: 'My goal',
})
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
expect(callArgs.data.product_id).toBe(PRODUCT_ID)
expect(callArgs.data.status).toBe('OPEN')
expect(callArgs.data.sprint_goal).toBe('My goal')
expect(callArgs.data.code).toMatch(/^S-\d{4}-\d{2}-\d{2}-1$/)
expect(callArgs.data.start_date).toBeInstanceOf(Date)
const parsed = parseResult(result)
expect(parsed.id).toBe('spr-1')
expect(parsed.status).toBe('OPEN')
})
it('uses user-provided code when given', async () => {
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-2',
code: 'CUSTOM-CODE',
sprint_goal: 'g',
status: 'OPEN',
start_date: new Date(),
created_at: new Date(),
})
await handleCreateSprint({
product_id: PRODUCT_ID,
code: 'CUSTOM-CODE',
sprint_goal: 'g',
})
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(1)
expect(mockPrisma.sprint.findMany).not.toHaveBeenCalled()
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe('CUSTOM-CODE')
})
it('auto-code increments past existing same-day sprints', async () => {
mockPrisma.sprint.findMany.mockResolvedValue([
{ code: 'S-2026-05-11-1' },
{ code: 'S-2026-05-11-3' },
{ code: 'S-2026-05-10-7' },
])
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-3', code: 'X', sprint_goal: 'g', status: 'OPEN', start_date: new Date(), created_at: new Date(),
})
await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
const today = new Date().toISOString().slice(0, 10)
expect(mockPrisma.sprint.create.mock.calls[0][0].data.code).toBe(`S-${today}-4`)
})
it('retries on P2002 unique conflict', async () => {
const conflict = new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002', clientVersion: 'x', meta: { target: ['product_id', 'code'] },
})
mockPrisma.sprint.create
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({
id: 'spr-r', code: 'S-2026-05-11-2', sprint_goal: 'g', status: 'OPEN',
start_date: new Date(), created_at: new Date(),
})
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
expect(mockPrisma.sprint.create).toHaveBeenCalledTimes(2)
expect(parseResult(result).id).toBe('spr-r')
})
it('returns error when user cannot access product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleCreateSprint({ product_id: PRODUCT_ID, sprint_goal: 'g' })
expect(mockPrisma.sprint.create).not.toHaveBeenCalled()
const text = result.content?.[0]?.type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/not found or not accessible/)
})
it('uses provided start_date when given', async () => {
mockPrisma.sprint.create.mockResolvedValue({
id: 'spr-d', code: 'X', sprint_goal: 'g', status: 'OPEN',
start_date: new Date('2026-01-01'), created_at: new Date(),
})
await handleCreateSprint({
product_id: PRODUCT_ID,
sprint_goal: 'g',
start_date: '2026-01-01',
})
const callArgs = mockPrisma.sprint.create.mock.calls[0][0]
expect(callArgs.data.start_date.toISOString().slice(0, 10)).toBe('2026-01-01')
})
})

View file

@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
sprint: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../src/auth.js', () => ({
requireWriteAccess: vi.fn(),
PermissionDeniedError: class PermissionDeniedError extends Error {
constructor(message = 'Demo accounts cannot perform write operations') {
super(message)
this.name = 'PermissionDeniedError'
}
},
}))
vi.mock('../src/access.js', () => ({
userCanAccessProduct: vi.fn(),
}))
import { prisma } from '../src/prisma.js'
import { requireWriteAccess } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleUpdateSprint } from '../src/tools/update-sprint.js'
const mockPrisma = prisma as unknown as {
sprint: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockRequireWriteAccess = requireWriteAccess as ReturnType<typeof vi.fn>
const mockUserCanAccessProduct = userCanAccessProduct as ReturnType<typeof vi.fn>
const SPRINT_ID = 'spr-1'
const PRODUCT_ID = 'prod-1'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.sprint.findUnique.mockResolvedValue({ id: SPRINT_ID, product_id: PRODUCT_ID })
mockPrisma.sprint.update.mockResolvedValue({
id: SPRINT_ID,
code: 'S-2026-05-11-1',
sprint_goal: 'g',
status: 'OPEN',
start_date: new Date('2026-05-11'),
end_date: null,
})
})
function getText(result: Awaited<ReturnType<typeof handleUpdateSprint>>) {
return result.content?.[0]?.type === 'text' ? result.content[0].text : ''
}
describe('handleUpdateSprint', () => {
it('returns error when no fields provided', async () => {
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID })
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
expect(getText(result)).toMatch(/Minstens één veld vereist/)
})
it('updates status only', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.where).toEqual({ id: SPRINT_ID })
expect(args.data).toEqual({ status: 'OPEN' })
})
it('auto-sets end_date when status → CLOSED without explicit end_date', async () => {
const before = Date.now()
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
const after = Date.now()
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.status).toBe('CLOSED')
expect(args.data.end_date).toBeInstanceOf(Date)
expect(args.data.end_date.getTime()).toBeGreaterThanOrEqual(before)
expect(args.data.end_date.getTime()).toBeLessThanOrEqual(after)
})
it('auto-sets end_date when status → FAILED', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' })
expect(mockPrisma.sprint.update.mock.calls[0][0].data.end_date).toBeInstanceOf(Date)
})
it('auto-sets end_date when status → ARCHIVED', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' })
expect(mockPrisma.sprint.update.mock.calls[0][0].data.end_date).toBeInstanceOf(Date)
})
it('does NOT auto-set end_date when status → OPEN', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update.mock.calls[0][0].data.end_date).toBeUndefined()
})
it('respects explicit end_date when status is terminal', async () => {
await handleUpdateSprint({
sprint_id: SPRINT_ID,
status: 'CLOSED',
end_date: '2025-12-31',
})
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.end_date.toISOString().slice(0, 10)).toBe('2025-12-31')
})
it('updates multiple fields at once', async () => {
await handleUpdateSprint({
sprint_id: SPRINT_ID,
sprint_goal: 'New goal',
start_date: '2026-05-15',
})
const args = mockPrisma.sprint.update.mock.calls[0][0]
expect(args.data.sprint_goal).toBe('New goal')
expect(args.data.start_date.toISOString().slice(0, 10)).toBe('2026-05-15')
expect(args.data.status).toBeUndefined()
expect(args.data.end_date).toBeUndefined()
})
it('returns error when sprint not found', async () => {
mockPrisma.sprint.findUnique.mockResolvedValue(null)
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
expect(getText(result)).toMatch(/not found/)
})
it('returns error when user cannot access sprint product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
expect(getText(result)).toMatch(/not accessible/)
})
it('allows any status transition (no state-machine)', async () => {
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(1)
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'CLOSED' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(2)
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
expect(mockPrisma.sprint.update).toHaveBeenCalledTimes(3)
})
})