492 lines
14 KiB
JavaScript
492 lines
14 KiB
JavaScript
/**
|
|
* Tests for GitHubNotifier - Baseline notification via GitHub @mentions
|
|
*
|
|
* Tests cover:
|
|
* - Notification templates for all event types
|
|
* - Template rendering with variable substitution
|
|
* - Conditional rendering ({{#if}})
|
|
* - Array rendering ({{#each}})
|
|
* - Comment and issue creation
|
|
* - Error handling
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { GitHubNotifier, NOTIFICATION_TEMPLATES } from '../../../src/modules/bmm/lib/notifications/github-notifier.js';
|
|
|
|
describe('GitHubNotifier', () => {
|
|
// ============ NOTIFICATION_TEMPLATES Tests ============
|
|
|
|
describe('NOTIFICATION_TEMPLATES', () => {
|
|
it('should define all required event types', () => {
|
|
const expectedTypes = [
|
|
'feedback_round_opened',
|
|
'feedback_submitted',
|
|
'synthesis_complete',
|
|
'signoff_requested',
|
|
'signoff_received',
|
|
'document_approved',
|
|
'document_blocked',
|
|
'reminder',
|
|
'deadline_extended',
|
|
];
|
|
|
|
for (const type of expectedTypes) {
|
|
expect(NOTIFICATION_TEMPLATES[type]).toBeDefined();
|
|
expect(NOTIFICATION_TEMPLATES[type].subject).toBeTruthy();
|
|
expect(NOTIFICATION_TEMPLATES[type].template).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('should have placeholders in templates', () => {
|
|
const template = NOTIFICATION_TEMPLATES.feedback_round_opened.template;
|
|
|
|
expect(template).toContain('{{mentions}}');
|
|
expect(template).toContain('{{document_type}}');
|
|
expect(template).toContain('{{document_key}}');
|
|
expect(template).toContain('{{deadline}}');
|
|
});
|
|
|
|
it('should have conditional blocks in relevant templates', () => {
|
|
const template = NOTIFICATION_TEMPLATES.signoff_received.template;
|
|
|
|
expect(template).toContain('{{#if note}}');
|
|
expect(template).toContain('{{/if}}');
|
|
});
|
|
});
|
|
|
|
// ============ Constructor Tests ============
|
|
|
|
describe('constructor', () => {
|
|
it('should initialize with config', () => {
|
|
const mockGithub = { addIssueComment: vi.fn() };
|
|
const notifier = new GitHubNotifier({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
github: mockGithub,
|
|
});
|
|
|
|
expect(notifier.owner).toBe('test-org');
|
|
expect(notifier.repo).toBe('test-repo');
|
|
expect(notifier.github).toBe(mockGithub);
|
|
});
|
|
});
|
|
|
|
// ============ send Tests ============
|
|
|
|
describe('send', () => {
|
|
let notifier;
|
|
let mockGithub;
|
|
|
|
beforeEach(() => {
|
|
mockGithub = {
|
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
|
|
createIssue: vi.fn().mockResolvedValue({ number: 456 }),
|
|
};
|
|
|
|
notifier = new GitHubNotifier({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
github: mockGithub,
|
|
});
|
|
});
|
|
|
|
it('should throw for unknown event type', async () => {
|
|
await expect(notifier.send('unknown_event', {})).rejects.toThrow('Unknown notification event type: unknown_event');
|
|
});
|
|
|
|
it('should post comment when issueNumber provided', async () => {
|
|
const result = await notifier.send(
|
|
'feedback_round_opened',
|
|
{
|
|
mentions: '@alice @bob',
|
|
document_type: 'prd',
|
|
document_key: 'user-auth',
|
|
version: 1,
|
|
deadline: '2026-01-15',
|
|
document_url: 'https://example.com/doc',
|
|
},
|
|
{ issueNumber: 100 },
|
|
);
|
|
|
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
|
expect(mockGithub.addIssueComment).toHaveBeenCalledWith({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
issue_number: 100,
|
|
body: expect.stringContaining('Feedback Round Open'),
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.channel).toBe('github');
|
|
expect(result.type).toBe('comment');
|
|
expect(result.commentId).toBe(123);
|
|
});
|
|
|
|
it('should create issue when createIssue option provided', async () => {
|
|
const result = await notifier.send(
|
|
'document_approved',
|
|
{
|
|
document_type: 'prd',
|
|
document_key: 'user-auth',
|
|
title: 'User Authentication',
|
|
version: 2,
|
|
approval_count: 5,
|
|
stakeholder_count: 5,
|
|
document_url: 'https://example.com/doc',
|
|
},
|
|
{ createIssue: true, labels: ['notification', 'approved'] },
|
|
);
|
|
|
|
expect(mockGithub.createIssue).toHaveBeenCalledTimes(1);
|
|
expect(mockGithub.createIssue).toHaveBeenCalledWith({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
title: expect.stringContaining('Document Approved'),
|
|
body: expect.stringContaining('User Authentication'),
|
|
labels: ['notification', 'approved'],
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.type).toBe('issue');
|
|
expect(result.issueNumber).toBe(456);
|
|
});
|
|
|
|
it('should use review_issue from data when no options specified', async () => {
|
|
await notifier.send('feedback_submitted', {
|
|
user: 'alice',
|
|
document_type: 'prd',
|
|
document_key: 'test',
|
|
feedback_type: 'concern',
|
|
section: 'FR-3',
|
|
summary: 'Security issue found',
|
|
feedback_issue: 42,
|
|
feedback_url: 'https://example.com/feedback/42',
|
|
review_issue: 100,
|
|
});
|
|
|
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
|
expect(mockGithub.addIssueComment.mock.calls[0][0].issue_number).toBe(100);
|
|
});
|
|
|
|
it('should return message when no target specified', async () => {
|
|
const result = await notifier.send('deadline_extended', {
|
|
document_type: 'prd',
|
|
document_key: 'test',
|
|
old_deadline: '2026-01-10',
|
|
new_deadline: '2026-01-20',
|
|
document_url: 'https://example.com/doc',
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.message).toBeTruthy();
|
|
expect(result.note).toContain('No target issue specified');
|
|
});
|
|
|
|
it('should handle GitHub API error', async () => {
|
|
mockGithub.addIssueComment.mockRejectedValue(new Error('API rate limit'));
|
|
|
|
const result = await notifier.send(
|
|
'reminder',
|
|
{
|
|
mentions: '@alice',
|
|
document_type: 'prd',
|
|
document_key: 'test',
|
|
action_needed: 'feedback',
|
|
deadline: '2026-01-15',
|
|
time_remaining: '2 days',
|
|
document_url: 'https://example.com/doc',
|
|
},
|
|
{ issueNumber: 100 },
|
|
);
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe('API rate limit');
|
|
});
|
|
});
|
|
|
|
// ============ sendReminder Tests ============
|
|
|
|
describe('sendReminder', () => {
|
|
let notifier;
|
|
let mockGithub;
|
|
|
|
beforeEach(() => {
|
|
mockGithub = {
|
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
|
|
};
|
|
|
|
notifier = new GitHubNotifier({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
github: mockGithub,
|
|
});
|
|
});
|
|
|
|
it('should format mentions and send reminder', async () => {
|
|
await notifier.sendReminder(100, ['alice', 'bob'], {
|
|
document_type: 'prd',
|
|
document_key: 'test',
|
|
action_needed: 'sign-off',
|
|
deadline: '2026-01-15',
|
|
time_remaining: '24 hours',
|
|
document_url: 'https://example.com/doc',
|
|
});
|
|
|
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
|
|
|
expect(body).toContain('@alice @bob');
|
|
expect(body).toContain('Reminder');
|
|
expect(body).toContain('sign-off');
|
|
});
|
|
});
|
|
|
|
// ============ notifyStakeholders Tests ============
|
|
|
|
describe('notifyStakeholders', () => {
|
|
let notifier;
|
|
let mockGithub;
|
|
|
|
beforeEach(() => {
|
|
mockGithub = {
|
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
|
|
};
|
|
|
|
notifier = new GitHubNotifier({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
github: mockGithub,
|
|
});
|
|
});
|
|
|
|
it('should format mentions and post message', async () => {
|
|
await notifier.notifyStakeholders(['alice', 'bob', 'charlie'], 'Please review the updated document', 100);
|
|
|
|
expect(mockGithub.addIssueComment).toHaveBeenCalledTimes(1);
|
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
|
|
|
expect(body).toContain('@alice @bob @charlie');
|
|
expect(body).toContain('Please review the updated document');
|
|
});
|
|
});
|
|
|
|
// ============ _renderTemplate Tests ============
|
|
|
|
describe('_renderTemplate', () => {
|
|
let notifier;
|
|
|
|
beforeEach(() => {
|
|
notifier = new GitHubNotifier({
|
|
owner: 'test',
|
|
repo: 'test',
|
|
github: {},
|
|
});
|
|
});
|
|
|
|
it('should replace simple variables', () => {
|
|
const template = 'Hello {{name}}, welcome to {{place}}!';
|
|
const result = notifier._renderTemplate(template, {
|
|
name: 'Alice',
|
|
place: 'Wonderland',
|
|
});
|
|
|
|
expect(result).toBe('Hello Alice, welcome to Wonderland!');
|
|
});
|
|
|
|
it('should keep placeholder when variable not found', () => {
|
|
const template = 'Hello {{name}}, your id is {{id}}';
|
|
const result = notifier._renderTemplate(template, { name: 'Bob' });
|
|
|
|
expect(result).toBe('Hello Bob, your id is {{id}}');
|
|
});
|
|
|
|
it('should handle conditional blocks - true', () => {
|
|
const template = 'Start{{#if show}} visible{{/if}} end';
|
|
const result = notifier._renderTemplate(template, { show: true });
|
|
|
|
expect(result).toBe('Start visible end');
|
|
});
|
|
|
|
it('should handle conditional blocks - false', () => {
|
|
const template = 'Start{{#if show}} hidden{{/if}} end';
|
|
const result = notifier._renderTemplate(template, { show: false });
|
|
|
|
expect(result).toBe('Start end');
|
|
});
|
|
|
|
it('should handle conditional blocks - undefined', () => {
|
|
const template = 'Start{{#if show}} hidden{{/if}} end';
|
|
const result = notifier._renderTemplate(template, {});
|
|
|
|
expect(result).toBe('Start end');
|
|
});
|
|
|
|
it('should handle each blocks with objects', () => {
|
|
const template = 'Items:{{#each items}} {{name}}={{value}};{{/each}}';
|
|
const result = notifier._renderTemplate(template, {
|
|
items: [
|
|
{ name: 'a', value: 1 },
|
|
{ name: 'b', value: 2 },
|
|
],
|
|
});
|
|
|
|
expect(result).toBe('Items: a=1; b=2;');
|
|
});
|
|
|
|
it('should handle each blocks with primitives', () => {
|
|
const template = 'List:{{#each items}} {{this}}{{/each}}';
|
|
const result = notifier._renderTemplate(template, {
|
|
items: ['apple', 'banana', 'cherry'],
|
|
});
|
|
|
|
expect(result).toBe('List: apple banana cherry');
|
|
});
|
|
|
|
it('should handle each with @index', () => {
|
|
const template = '{{#each items}}{{@index}}.{{this}} {{/each}}';
|
|
const result = notifier._renderTemplate(template, {
|
|
items: ['a', 'b', 'c'],
|
|
});
|
|
|
|
expect(result).toBe('0.a 1.b 2.c ');
|
|
});
|
|
|
|
it('should handle each with non-array', () => {
|
|
const template = 'Items:{{#each items}} item{{/each}}';
|
|
const result = notifier._renderTemplate(template, {
|
|
items: 'not an array',
|
|
});
|
|
|
|
expect(result).toBe('Items:');
|
|
});
|
|
|
|
it('should handle complex template', () => {
|
|
const template = `
|
|
## {{title}}
|
|
|
|
**From:** @{{user}}
|
|
**Status:** {{status}}
|
|
|
|
{{#if note}}
|
|
**Note:** {{note}}
|
|
{{/if}}
|
|
|
|
Items:
|
|
{{#each items}}
|
|
- {{name}}: {{value}}
|
|
{{/each}}
|
|
`;
|
|
|
|
const result = notifier._renderTemplate(template, {
|
|
title: 'Test',
|
|
user: 'alice',
|
|
status: 'approved',
|
|
note: 'Great work!',
|
|
items: [
|
|
{ name: 'Item 1', value: 'Value 1' },
|
|
{ name: 'Item 2', value: 'Value 2' },
|
|
],
|
|
});
|
|
|
|
expect(result).toContain('## Test');
|
|
expect(result).toContain('@alice');
|
|
expect(result).toContain('approved');
|
|
expect(result).toContain('Great work!');
|
|
expect(result).toContain('Item 1: Value 1');
|
|
expect(result).toContain('Item 2: Value 2');
|
|
});
|
|
});
|
|
|
|
// ============ Integration Tests ============
|
|
|
|
describe('integration', () => {
|
|
let notifier;
|
|
let mockGithub;
|
|
|
|
beforeEach(() => {
|
|
mockGithub = {
|
|
addIssueComment: vi.fn().mockResolvedValue({ id: 123 }),
|
|
createIssue: vi.fn().mockResolvedValue({ number: 456 }),
|
|
};
|
|
|
|
notifier = new GitHubNotifier({
|
|
owner: 'test-org',
|
|
repo: 'test-repo',
|
|
github: mockGithub,
|
|
});
|
|
});
|
|
|
|
it('should send feedback_round_opened notification', async () => {
|
|
await notifier.send(
|
|
'feedback_round_opened',
|
|
{
|
|
mentions: '@alice @bob @charlie',
|
|
document_type: 'prd',
|
|
document_key: 'user-auth',
|
|
version: 1,
|
|
deadline: '2026-01-15',
|
|
document_url: 'https://github.com/org/repo/docs/prd/user-auth.md',
|
|
},
|
|
{ issueNumber: 100 },
|
|
);
|
|
|
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
|
|
|
expect(body).toContain('📣 Feedback Round Open');
|
|
expect(body).toContain('@alice @bob @charlie');
|
|
expect(body).toContain('prd:user-auth');
|
|
expect(body).toContain('v1');
|
|
expect(body).toContain('2026-01-15');
|
|
});
|
|
|
|
it('should send signoff_received notification with note', async () => {
|
|
await notifier.send(
|
|
'signoff_received',
|
|
{
|
|
emoji: '✅📝',
|
|
user: 'security-lead',
|
|
decision: 'Approved with Note',
|
|
document_type: 'prd',
|
|
document_key: 'payments',
|
|
progress_current: 3,
|
|
progress_total: 5,
|
|
note: 'Please update PCI compliance section before implementation',
|
|
review_issue: 200,
|
|
review_url: 'https://github.com/org/repo/issues/200',
|
|
},
|
|
{ issueNumber: 200 },
|
|
);
|
|
|
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
|
|
|
expect(body).toContain('✅📝');
|
|
expect(body).toContain('@security-lead');
|
|
expect(body).toContain('Approved with Note');
|
|
expect(body).toContain('3/5');
|
|
expect(body).toContain('PCI compliance');
|
|
});
|
|
|
|
it('should send document_blocked notification', async () => {
|
|
await notifier.send(
|
|
'document_blocked',
|
|
{
|
|
document_type: 'prd',
|
|
document_key: 'data-migration',
|
|
user: 'legal',
|
|
reason: 'GDPR compliance review required before proceeding',
|
|
feedback_issue: 42,
|
|
feedback_url: 'https://github.com/org/repo/issues/42',
|
|
},
|
|
{ issueNumber: 100 },
|
|
);
|
|
|
|
const body = mockGithub.addIssueComment.mock.calls[0][0].body;
|
|
|
|
expect(body).toContain('🚫 Document Blocked');
|
|
expect(body).toContain('@legal');
|
|
expect(body).toContain('GDPR compliance');
|
|
expect(body).toContain('#42');
|
|
});
|
|
});
|
|
});
|