* feat(PBI-12 T-51): voeg create_sprint tool toe
Maakt een sprint aan met status=OPEN. Code auto-gegenereerd als
S-{YYYY-MM-DD}-{N} per product per datum als niet meegegeven, met retry
bij race-conflict op @@unique([product_id, code]). Volgt create-pbi.ts
template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-12 T-52): voeg update_sprint tool toe
Generieke update voor status, sprint_goal, start_date en end_date.
Géén state-machine validatie — last-write-wins. Bij status →
CLOSED/FAILED/ARCHIVED zonder expliciete end_date wordt end_date
automatisch op vandaag gezet. Minimaal één veld vereist (handmatige check
in handler i.p.v. zod-refine want McpServer.inputSchema accepteert geen
ZodEffects).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
* chore: untrack .claude/worktrees gitlinks + ignore pad
Per ongeluk in adbea3f meegenomen via 'git add -A'; deze embedded worktree-
clones horen niet in de repo. Ook .gitignore aangevuld zodat dit niet
opnieuw gebeurt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-12): update_sprint zet completed_at op CLOSED — parity met cascade
Codex-review op #47: bij status → CLOSED werd alleen end_date gezet, niet
completed_at. Dat is divergeert van src/lib/tasks-status-update.ts dat
completed_at = new Date() zet bij automatische sluiting via task-status-
cascade. Reporting en UI die op completed_at filteren zagen handmatig
gesloten sprints als 'never completed'.
Fix:
- update_sprint zet nu data.completed_at = new Date() wanneer status === 'CLOSED'
- FAILED/ARCHIVED raken completed_at NIET (parity met bestaand patroon)
- Test-coverage uitgebreid:
- CLOSED zet end_date EN completed_at
- FAILED zet end_date, completed_at blijft undefined
- ARCHIVED zet end_date, completed_at blijft undefined
- OPEN zet noch end_date noch completed_at
- Expliciete end_date wordt gerespecteerd, completed_at wordt nog steeds gezet
- Tool description vermeldt nu de completed_at-side-effect
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Madhura68 <ID+Madhura68@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
174 lines
6.1 KiB
TypeScript
174 lines
6.1 KiB
TypeScript
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,
|
|
completed_at: 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 AND completed_at 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)
|
|
expect(args.data.completed_at).toBeInstanceOf(Date)
|
|
expect(args.data.completed_at.getTime()).toBeGreaterThanOrEqual(before)
|
|
expect(args.data.completed_at.getTime()).toBeLessThanOrEqual(after)
|
|
})
|
|
|
|
it('auto-sets end_date when status → FAILED, but does NOT set completed_at', async () => {
|
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'FAILED' })
|
|
|
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
expect(args.data.end_date).toBeInstanceOf(Date)
|
|
expect(args.data.completed_at).toBeUndefined()
|
|
})
|
|
|
|
it('auto-sets end_date when status → ARCHIVED, but does NOT set completed_at', async () => {
|
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'ARCHIVED' })
|
|
|
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
expect(args.data.end_date).toBeInstanceOf(Date)
|
|
expect(args.data.completed_at).toBeUndefined()
|
|
})
|
|
|
|
it('still sets completed_at when status → CLOSED even with explicit end_date', 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')
|
|
expect(args.data.completed_at).toBeInstanceOf(Date)
|
|
})
|
|
|
|
it('does NOT auto-set end_date or completed_at when status → OPEN', async () => {
|
|
await handleUpdateSprint({ sprint_id: SPRINT_ID, status: 'OPEN' })
|
|
|
|
const args = mockPrisma.sprint.update.mock.calls[0][0]
|
|
expect(args.data.end_date).toBeUndefined()
|
|
expect(args.data.completed_at).toBeUndefined()
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|