* 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>
129 lines
4.4 KiB
TypeScript
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)
|
|
})
|
|
})
|