987 lines
31 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|