scrum4me-mcp/__tests__/set-pbi-pr.test.ts
Janpeter Visser 3ce2c044c4
feat(mcp): set_pbi_pr + mark_pbi_pr_merged tools voor PBI-PR-gating (#18)
* feat(ST-mhj9f2la): add set_pbi_pr MCP tool

- Add pr_url and pr_merged_at fields to Pbi model in schema
- Implement set_pbi_pr tool: writes pr_url, clears pr_merged_at (idempotent)
- AuthZ via requireWriteAccess + userCanAccessProduct through pbi.product_id
- 10 tests: happy path, not-found, no-access, demo-denied, schema validation
- Update README tools table and bump version to 0.2.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-mhj9f2la): add mark_pbi_pr_merged MCP tool

- Implement mark_pbi_pr_merged: sets pr_merged_at = now() on a PBI
- Requires pr_url to be set; returns error if not (geen gekoppelde PR)
- Idempotent: re-calling overwrites the timestamp
- AuthZ via requireWriteAccess + userCanAccessProduct through pbi.product_id
- 6 tests: happy path, no-pr_url, idempotent, no-access, not-found, demo-denied
- Update README tools table with mark_pbi_pr_merged entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-mhj9f2la): expand README with set_pbi_pr + mark_pbi_pr_merged docs

Add full signature/input/output/error documentation sections for both
new tools, following the verify_task_against_plan pattern.
Version already bumped to 0.2.0 in earlier commit.
Tag + MCP_GIT_REF pin in scrum4me-docker to be done by maintainer after merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 16:25:53 +02:00

129 lines
4.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
pbi: {
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, PermissionDeniedError } from '../src/auth.js'
import { userCanAccessProduct } from '../src/access.js'
import { handleSetPbiPr, inputSchema } from '../src/tools/set-pbi-pr.js'
const mockPrisma = prisma as unknown as {
pbi: { 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 VALID_PR_URL = 'https://github.com/owner/repo/pull/42'
const PBI_ID = 'pbi-abc123'
const USER_ID = 'user-1'
beforeEach(() => {
vi.clearAllMocks()
mockRequireWriteAccess.mockResolvedValue({ userId: USER_ID, tokenId: 'tok-1', username: 'alice', isDemo: false })
mockPrisma.pbi.findUnique.mockResolvedValue({ product_id: 'prod-1' })
mockUserCanAccessProduct.mockResolvedValue(true)
mockPrisma.pbi.update.mockResolvedValue({})
})
describe('handleSetPbiPr', () => {
it('happy path: updates pr_url and clears pr_merged_at', async () => {
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBeFalsy()
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: PBI_ID },
data: { pr_url: VALID_PR_URL, pr_merged_at: null },
})
const text = result.content[0].type === 'text' ? result.content[0].text : ''
const parsed = JSON.parse(text)
expect(parsed).toEqual({ ok: true, pbi_id: PBI_ID, pr_url: VALID_PR_URL })
})
it('idempotent: second call with different url overwrites', async () => {
const newUrl = 'https://github.com/owner/repo/pull/99'
await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: newUrl })
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: PBI_ID },
data: { pr_url: newUrl, pr_merged_at: null },
})
})
it('returns error when PBI not found', async () => {
mockPrisma.pbi.findUnique.mockResolvedValue(null)
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(PBI_ID)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns error when user has no access to the product', async () => {
mockUserCanAccessProduct.mockResolvedValue(false)
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBe(true)
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('returns PERMISSION_DENIED for demo accounts', async () => {
mockRequireWriteAccess.mockRejectedValue(new PermissionDeniedError())
const result = await handleSetPbiPr({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(result.isError).toBe(true)
const text = result.content[0].type === 'text' ? result.content[0].text : ''
expect(text).toMatch(/PERMISSION_DENIED/)
})
})
describe('inputSchema validation', () => {
it('accepts a valid GitHub PR URL', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: VALID_PR_URL })
expect(r.success).toBe(true)
})
it('rejects a URL pointing to an issue instead of a pull', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/issues/42' })
expect(r.success).toBe(false)
})
it('rejects a non-GitHub URL', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://gitlab.com/owner/repo/pull/42' })
expect(r.success).toBe(false)
})
it('rejects a URL without a numeric PR number', () => {
const r = inputSchema.safeParse({ pbi_id: PBI_ID, pr_url: 'https://github.com/owner/repo/pull/abc' })
expect(r.success).toBe(false)
})
it('rejects an empty pbi_id', () => {
const r = inputSchema.safeParse({ pbi_id: '', pr_url: VALID_PR_URL })
expect(r.success).toBe(false)
})
})