BMAD-METHOD/test/unit/crowdsource/signoff-manager.test.js

987 lines
31 KiB
JavaScript

/**
* Tests for SignoffManager - Configurable sign-off logic for PRDs and Epics
*
* Tests cover:
* - Constants and default configuration
* - Sign-off request creation
* - Sign-off submission with various decisions
* - Three threshold types: count, percentage, required_approvers
* - Status calculation with blocking logic
* - Progress tracking and summaries
* - Reminder and deadline extension
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
SignoffManager,
SIGNOFF_STATUS,
THRESHOLD_TYPES,
DEFAULT_CONFIG,
} from '../../../src/modules/bmm/lib/crowdsource/signoff-manager.js';
// Create a testable subclass that allows injecting mock implementations
class TestableSignoffManager extends SignoffManager {
constructor(githubConfig, mocks = {}) {
super(githubConfig);
this.mocks = mocks;
}
async _getIssue(issueNumber) {
if (this.mocks.getIssue) {
return this.mocks.getIssue(issueNumber);
}
throw new Error('Mock not provided for _getIssue');
}
async _updateIssue(issueNumber, updates) {
if (this.mocks.updateIssue) {
return this.mocks.updateIssue(issueNumber, updates);
}
throw new Error('Mock not provided for _updateIssue');
}
async _addComment(issueNumber, body) {
if (this.mocks.addComment) {
return this.mocks.addComment(issueNumber, body);
}
throw new Error('Mock not provided for _addComment');
}
}
describe('SignoffManager', () => {
// ============ Constants Tests ============
describe('SIGNOFF_STATUS', () => {
it('should define all status values', () => {
expect(SIGNOFF_STATUS.pending).toBe('signoff:pending');
expect(SIGNOFF_STATUS.approved).toBe('signoff:approved');
expect(SIGNOFF_STATUS.approved_with_note).toBe('signoff:approved-with-note');
expect(SIGNOFF_STATUS.blocked).toBe('signoff:blocked');
});
});
describe('THRESHOLD_TYPES', () => {
it('should define all threshold types', () => {
expect(THRESHOLD_TYPES.count).toBe('count');
expect(THRESHOLD_TYPES.percentage).toBe('percentage');
expect(THRESHOLD_TYPES.required_approvers).toBe('required_approvers');
});
});
describe('DEFAULT_CONFIG', () => {
it('should have sensible defaults', () => {
expect(DEFAULT_CONFIG.threshold_type).toBe('count');
expect(DEFAULT_CONFIG.minimum_approvals).toBe(2);
expect(DEFAULT_CONFIG.approval_percentage).toBe(66);
expect(DEFAULT_CONFIG.required).toEqual([]);
expect(DEFAULT_CONFIG.optional).toEqual([]);
expect(DEFAULT_CONFIG.minimum_optional).toBe(0);
expect(DEFAULT_CONFIG.allow_blocks).toBe(true);
expect(DEFAULT_CONFIG.block_threshold).toBe(1);
});
});
// ============ Constructor Tests ============
describe('constructor', () => {
it('should initialize with github config', () => {
const manager = new SignoffManager({
owner: 'test-org',
repo: 'test-repo',
});
expect(manager.owner).toBe('test-org');
expect(manager.repo).toBe('test-repo');
});
});
// ============ requestSignoff Tests ============
describe('requestSignoff', () => {
let manager;
let mockAddComment;
beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { addComment: mockAddComment });
});
it('should create sign-off request with stakeholder checklist', async () => {
const result = await manager.requestSignoff({
documentKey: 'prd:user-auth',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie'],
deadline: '2026-01-15',
});
expect(mockAddComment).toHaveBeenCalledTimes(1);
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('✍️ Sign-off Requested');
expect(comment).toContain('`prd:user-auth`');
expect(comment).toContain('PRD');
expect(comment).toContain('2026-01-15');
expect(comment).toContain('@alice');
expect(comment).toContain('@bob');
expect(comment).toContain('@charlie');
expect(comment).toContain('⏳ Pending');
expect(result.reviewIssueNumber).toBe(100);
expect(result.stakeholders).toHaveLength(3);
expect(result.status).toBe('signoff_requested');
});
it('should merge custom config with defaults', async () => {
const result = await manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie', 'dave', 'eve'],
deadline: '2026-01-15',
config: {
minimum_approvals: 5,
block_threshold: 2,
},
});
expect(result.config.minimum_approvals).toBe(5);
expect(result.config.block_threshold).toBe(2);
// Default values preserved
expect(result.config.threshold_type).toBe('count');
expect(result.config.allow_blocks).toBe(true);
});
it('should format threshold description for count type', async () => {
await manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie'],
deadline: '2026-01-15',
config: { threshold_type: 'count', minimum_approvals: 2 },
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('2 approval(s) required');
});
it('should format threshold description for percentage type', async () => {
await manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie'],
deadline: '2026-01-15',
config: { threshold_type: 'percentage', approval_percentage: 75 },
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('75% must approve');
});
it('should format threshold description for required_approvers type', async () => {
await manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob', 'charlie', 'dave'],
deadline: '2026-01-15',
config: {
threshold_type: 'required_approvers',
required: ['alice', 'bob'],
optional: ['charlie', 'dave'],
minimum_optional: 1,
},
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('Required: alice, bob');
expect(comment).toContain('1 optional');
});
it('should include sign-off instructions', async () => {
await manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob'],
deadline: '2026-01-15',
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('/signoff approve');
expect(comment).toContain('/signoff approve-note');
expect(comment).toContain('/signoff block');
});
it('should validate count threshold against stakeholder list', async () => {
await expect(
manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob'],
deadline: '2026-01-15',
config: { threshold_type: 'count', minimum_approvals: 5 },
}),
).rejects.toThrow('minimum_approvals (5) cannot exceed stakeholder count (2)');
});
it('should validate required approvers are in stakeholder list', async () => {
await expect(
manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['alice', 'bob'],
deadline: '2026-01-15',
config: {
threshold_type: 'required_approvers',
required: ['alice', 'charlie'], // charlie not in stakeholders
},
}),
).rejects.toThrow('All required approvers must be in stakeholder list');
});
it('should handle @ prefix in stakeholder names', async () => {
await manager.requestSignoff({
documentKey: 'prd:test',
documentType: 'prd',
reviewIssueNumber: 100,
stakeholders: ['@alice', '@bob'],
deadline: '2026-01-15',
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('@alice');
expect(comment).toContain('@bob');
expect(comment).not.toContain('@@'); // Should not double the @
});
});
// ============ submitSignoff Tests ============
describe('submitSignoff', () => {
let manager;
let mockAddComment;
let mockGetIssue;
let mockUpdateIssue;
beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({});
mockGetIssue = vi.fn().mockResolvedValue({
labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }],
});
mockUpdateIssue = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager(
{ owner: 'test-org', repo: 'test-repo' },
{
addComment: mockAddComment,
getIssue: mockGetIssue,
updateIssue: mockUpdateIssue,
},
);
});
it('should submit approved sign-off', async () => {
const result = await manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:user-auth',
documentType: 'prd',
user: 'alice',
decision: 'approved',
});
expect(mockAddComment).toHaveBeenCalledTimes(1);
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('✅');
expect(comment).toContain('@alice');
expect(comment).toContain('Approved');
expect(result.decision).toBe('approved');
expect(result.user).toBe('alice');
expect(result.timestamp).toBeDefined();
});
it('should submit approved with note', async () => {
await manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
user: 'bob',
decision: 'approved_with_note',
note: 'Please update docs before implementation',
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('✅📝');
expect(comment).toContain('Approved with Note');
expect(comment).toContain('Please update docs before implementation');
});
it('should submit blocked sign-off with reason', async () => {
await manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
user: 'security',
decision: 'blocked',
note: 'Security review required',
feedbackIssueNumber: 42,
});
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('🚫');
expect(comment).toContain('Blocked');
expect(comment).toContain('Security review required');
expect(comment).toContain('#42');
});
it('should add signoff label to issue', async () => {
await manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
user: 'alice',
decision: 'approved',
});
expect(mockUpdateIssue).toHaveBeenCalledTimes(1);
const updateCall = mockUpdateIssue.mock.calls[0];
expect(updateCall[0]).toBe(100);
expect(updateCall[1].labels).toContain('signoff-alice-approved');
});
it('should replace existing signoff label for user', async () => {
mockGetIssue.mockResolvedValue({
labels: [
{ name: 'type:prd-review' },
{ name: 'signoff-alice-pending' }, // Previous status
],
});
await manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
user: 'alice',
decision: 'approved',
});
const updateCall = mockUpdateIssue.mock.calls[0];
expect(updateCall[1].labels).not.toContain('signoff-alice-pending');
expect(updateCall[1].labels).toContain('signoff-alice-approved');
});
it('should normalize user name for label', async () => {
await manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
user: '@alice',
decision: 'approved',
});
const updateCall = mockUpdateIssue.mock.calls[0];
expect(updateCall[1].labels).toContain('signoff-alice-approved');
});
it('should throw error for invalid decision', async () => {
await expect(
manager.submitSignoff({
reviewIssueNumber: 100,
documentKey: 'prd:test',
documentType: 'prd',
user: 'alice',
decision: 'invalid',
}),
).rejects.toThrow('Invalid decision: invalid');
});
});
// ============ getSignoffs Tests ============
describe('getSignoffs', () => {
let manager;
let mockGetIssue;
beforeEach(() => {
mockGetIssue = vi.fn();
manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { getIssue: mockGetIssue });
});
it('should parse signoff labels from issue', async () => {
mockGetIssue.mockResolvedValue({
labels: [
{ name: 'type:prd-review' },
{ name: 'signoff-alice-approved' },
{ name: 'signoff-bob-approved-with-note' },
{ name: 'signoff-charlie-blocked' },
{ name: 'signoff-dave-pending' },
],
});
const signoffs = await manager.getSignoffs(100);
expect(signoffs).toHaveLength(4);
expect(signoffs).toContainEqual({
user: 'alice',
status: 'approved',
label: 'signoff-alice-approved',
});
expect(signoffs).toContainEqual({
user: 'bob',
status: 'approved_with_note',
label: 'signoff-bob-approved-with-note',
});
expect(signoffs).toContainEqual({
user: 'charlie',
status: 'blocked',
label: 'signoff-charlie-blocked',
});
expect(signoffs).toContainEqual({
user: 'dave',
status: 'pending',
label: 'signoff-dave-pending',
});
});
it('should return empty array when no signoff labels', async () => {
mockGetIssue.mockResolvedValue({
labels: [{ name: 'type:prd-review' }, { name: 'review-status:signoff' }],
});
const signoffs = await manager.getSignoffs(100);
expect(signoffs).toHaveLength(0);
});
it('should ignore non-signoff labels', async () => {
mockGetIssue.mockResolvedValue({
labels: [{ name: 'signoff-alice-approved' }, { name: 'priority:high' }, { name: 'type:prd-feedback' }],
});
const signoffs = await manager.getSignoffs(100);
expect(signoffs).toHaveLength(1);
expect(signoffs[0].user).toBe('alice');
});
});
// ============ calculateStatus Tests - Count Threshold ============
describe('calculateStatus - count threshold', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should return approved when minimum approvals reached', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
];
const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved');
expect(status.message).toContain('Minimum approvals reached');
});
it('should return pending when more approvals needed', () => {
const signoffs = [{ user: 'alice', status: 'approved' }];
const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('pending');
expect(status.needed).toBe(1);
expect(status.message).toContain('Need 1 more approval');
});
it('should count approved_with_note as approval', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved_with_note' },
];
const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2 };
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved');
});
it('should return blocked when block threshold reached', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'blocked' },
];
const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 1 };
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('blocked');
expect(status.blockers).toContain('bob');
});
it('should not block when allow_blocks is false', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
{ user: 'charlie', status: 'blocked' },
];
const stakeholders = ['alice', 'bob', 'charlie'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, allow_blocks: false };
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved'); // Blocks ignored
});
it('should respect higher block_threshold', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
{ user: 'charlie', status: 'blocked' },
];
const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = { ...DEFAULT_CONFIG, minimum_approvals: 2, block_threshold: 2 };
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved'); // Only 1 block, threshold is 2
});
});
// ============ calculateStatus Tests - Percentage Threshold ============
describe('calculateStatus - percentage threshold', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should return approved when percentage threshold met', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
];
const stakeholders = ['alice', 'bob', 'charlie']; // 2/3 = 66.67%
const config = {
...DEFAULT_CONFIG,
threshold_type: 'percentage',
approval_percentage: 66,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved');
expect(status.message).toContain('67%');
expect(status.message).toContain('66%');
});
it('should return pending when percentage not met', () => {
const signoffs = [{ user: 'alice', status: 'approved' }];
const stakeholders = ['alice', 'bob', 'charlie', 'dave']; // 1/4 = 25%
const config = {
...DEFAULT_CONFIG,
threshold_type: 'percentage',
approval_percentage: 50,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('pending');
expect(status.current_percent).toBe(25);
expect(status.needed_percent).toBe(50);
expect(status.needed).toBe(1); // Need 1 more to reach 50%
});
it('should calculate correctly for 100% threshold', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
];
const stakeholders = ['alice', 'bob', 'charlie'];
const config = {
...DEFAULT_CONFIG,
threshold_type: 'percentage',
approval_percentage: 100,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('pending');
expect(status.needed).toBe(1);
});
});
// ============ calculateStatus Tests - Required Approvers Threshold ============
describe('calculateStatus - required_approvers threshold', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should return approved when all required + minimum optional approved', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
{ user: 'charlie', status: 'approved' },
];
const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = {
...DEFAULT_CONFIG,
threshold_type: 'required_approvers',
required: ['alice', 'bob'],
optional: ['charlie', 'dave'],
minimum_optional: 1,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved');
expect(status.message).toContain('All required + minimum optional');
});
it('should return pending when required approver missing', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'charlie', status: 'approved' },
];
const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = {
...DEFAULT_CONFIG,
threshold_type: 'required_approvers',
required: ['alice', 'bob'],
optional: ['charlie', 'dave'],
minimum_optional: 1,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('pending');
expect(status.missing_required).toContain('bob');
expect(status.message).toContain('bob');
});
it('should return pending when optional threshold not met', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
// No optional approvers
];
const stakeholders = ['alice', 'bob', 'charlie', 'dave'];
const config = {
...DEFAULT_CONFIG,
threshold_type: 'required_approvers',
required: ['alice', 'bob'],
optional: ['charlie', 'dave'],
minimum_optional: 1,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('pending');
expect(status.optional_needed).toBe(1);
expect(status.message).toContain('optional approver');
});
it('should handle @ prefix in required list', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
];
const stakeholders = ['@alice', '@bob'];
const config = {
...DEFAULT_CONFIG,
threshold_type: 'required_approvers',
required: ['@alice', '@bob'],
optional: [],
minimum_optional: 0,
};
const status = manager.calculateStatus(signoffs, stakeholders, config);
expect(status.status).toBe('approved');
});
});
// ============ isApproved Tests ============
describe('isApproved', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should return true when approved', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
];
const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], {
...DEFAULT_CONFIG,
minimum_approvals: 2,
});
expect(approved).toBe(true);
});
it('should return false when pending', () => {
const signoffs = [{ user: 'alice', status: 'approved' }];
const approved = manager.isApproved(signoffs, ['alice', 'bob', 'charlie'], {
...DEFAULT_CONFIG,
minimum_approvals: 2,
});
expect(approved).toBe(false);
});
it('should return false when blocked', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'blocked' },
];
const approved = manager.isApproved(signoffs, ['alice', 'bob'], {
...DEFAULT_CONFIG,
minimum_approvals: 1,
});
expect(approved).toBe(false);
});
});
// ============ getProgressSummary Tests ============
describe('getProgressSummary', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should calculate progress summary', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved_with_note' },
{ user: 'charlie', status: 'blocked' },
];
const stakeholders = ['alice', 'bob', 'charlie', 'dave', 'eve'];
const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG);
expect(summary.total_stakeholders).toBe(5);
expect(summary.approved_count).toBe(2);
expect(summary.blocked_count).toBe(1);
expect(summary.pending_count).toBe(2);
expect(summary.pending_users).toContain('dave');
expect(summary.pending_users).toContain('eve');
expect(summary.progress_percent).toBe(40); // 2/5 = 40%
});
it('should include status info from calculateStatus', () => {
const signoffs = [
{ user: 'alice', status: 'approved' },
{ user: 'bob', status: 'approved' },
];
const stakeholders = ['alice', 'bob', 'charlie'];
const summary = manager.getProgressSummary(signoffs, stakeholders, {
...DEFAULT_CONFIG,
minimum_approvals: 2,
});
expect(summary.status).toBe('approved');
expect(summary.message).toBeDefined();
});
it('should handle @ prefix in stakeholder names', () => {
const signoffs = [{ user: 'alice', status: 'approved' }];
const stakeholders = ['@alice', '@bob'];
const summary = manager.getProgressSummary(signoffs, stakeholders, DEFAULT_CONFIG);
expect(summary.pending_users).toContain('@bob');
expect(summary.pending_count).toBe(1);
});
});
// ============ sendReminder Tests ============
describe('sendReminder', () => {
let manager;
let mockAddComment;
beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { addComment: mockAddComment });
});
it('should send reminder to pending users', async () => {
const result = await manager.sendReminder(100, ['alice', 'bob'], '2026-01-15');
expect(mockAddComment).toHaveBeenCalledTimes(1);
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('⏰ Reminder');
expect(comment).toContain('@alice');
expect(comment).toContain('@bob');
expect(comment).toContain('2026-01-15');
expect(result.reminded).toEqual(['alice', 'bob']);
expect(result.deadline).toBe('2026-01-15');
});
it('should handle @ prefix in user names', async () => {
await manager.sendReminder(100, ['@charlie'], '2026-01-20');
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('@charlie');
expect(comment).not.toContain('@@');
});
});
// ============ extendDeadline Tests ============
describe('extendDeadline', () => {
let manager;
let mockAddComment;
beforeEach(() => {
mockAddComment = vi.fn().mockResolvedValue({});
manager = new TestableSignoffManager({ owner: 'test-org', repo: 'test-repo' }, { addComment: mockAddComment });
});
it('should post deadline extension comment', async () => {
const result = await manager.extendDeadline(100, '2026-01-20');
expect(mockAddComment).toHaveBeenCalledTimes(1);
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('📅 Deadline Extended');
expect(comment).toContain('2026-01-20');
expect(result.reviewIssueNumber).toBe(100);
expect(result.newDeadline).toBe('2026-01-20');
});
it('should include reason when provided', async () => {
await manager.extendDeadline(100, '2026-01-25', 'Holiday period');
const comment = mockAddComment.mock.calls[0][1];
expect(comment).toContain('Holiday period');
});
});
// ============ Private Method Tests ============
describe('_getDecisionEmoji', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should return correct emoji for each decision', () => {
expect(manager._getDecisionEmoji('approved')).toBe('✅');
expect(manager._getDecisionEmoji('approved_with_note')).toBe('✅📝');
expect(manager._getDecisionEmoji('blocked')).toBe('🚫');
expect(manager._getDecisionEmoji('pending')).toBe('⏳');
expect(manager._getDecisionEmoji('unknown')).toBe('⏳');
});
});
describe('_getDecisionText', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should return correct text for each decision', () => {
expect(manager._getDecisionText('approved')).toBe('Approved');
expect(manager._getDecisionText('approved_with_note')).toBe('Approved with Note');
expect(manager._getDecisionText('blocked')).toBe('Blocked');
expect(manager._getDecisionText('pending')).toBe('Pending');
});
});
describe('_formatThreshold', () => {
let manager;
beforeEach(() => {
manager = new SignoffManager({ owner: 'test', repo: 'test' });
});
it('should format count threshold', () => {
const config = { threshold_type: 'count', minimum_approvals: 3 };
expect(manager._formatThreshold(config)).toBe('3 approval(s) required');
});
it('should format percentage threshold', () => {
const config = { threshold_type: 'percentage', approval_percentage: 75 };
expect(manager._formatThreshold(config)).toBe('75% must approve');
});
it('should format required_approvers threshold', () => {
const config = {
threshold_type: 'required_approvers',
required: ['alice', 'bob'],
minimum_optional: 2,
};
expect(manager._formatThreshold(config)).toBe('Required: alice, bob + 2 optional');
});
it('should return Unknown for invalid threshold type', () => {
const config = { threshold_type: 'invalid' };
expect(manager._formatThreshold(config)).toBe('Unknown');
});
});
// ============ Error Handling Tests ============
describe('error handling', () => {
it('should throw when GitHub methods not implemented', async () => {
const manager = new SignoffManager({ owner: 'test', repo: 'test' });
await expect(manager._getIssue(1)).rejects.toThrow('_getIssue must be implemented by caller via GitHub MCP');
await expect(manager._addComment(1, 'test')).rejects.toThrow('_addComment must be implemented by caller via GitHub MCP');
});
it('should throw for unknown threshold type in calculateStatus', () => {
const manager = new SignoffManager({ owner: 'test', repo: 'test' });
expect(() => {
manager.calculateStatus([], ['alice'], { threshold_type: 'invalid' });
}).toThrow('Unknown threshold type: invalid');
});
});
});