BMAD-METHOD/test/unit/notifications/notification-service.test.js

803 lines
23 KiB
JavaScript

/**
* Tests for NotificationService - Multi-channel notification orchestration
*
* Tests cover:
* - Channel initialization based on config
* - Event routing with default channels
* - Priority-based behavior (retry, all channels)
* - Convenience methods for specific notification types
* - Error handling and aggregation
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
NotificationService,
NOTIFICATION_EVENTS,
PRIORITY_BEHAVIOR,
} from '../../../src/modules/bmm/lib/notifications/notification-service.js';
// Mock the notifier modules
vi.mock('../../../src/modules/bmm/lib/notifications/github-notifier.js', () => ({
GitHubNotifier: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue({ success: true, channel: 'github' }),
})),
}));
vi.mock('../../../src/modules/bmm/lib/notifications/slack-notifier.js', () => ({
SlackNotifier: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue({ success: true, channel: 'slack' }),
})),
}));
vi.mock('../../../src/modules/bmm/lib/notifications/email-notifier.js', () => ({
EmailNotifier: vi.fn().mockImplementation(() => ({
send: vi.fn().mockResolvedValue({ success: true, channel: 'email' }),
})),
}));
describe('NotificationService', () => {
// ============ Constants Tests ============
describe('NOTIFICATION_EVENTS', () => {
it('should define all event types', () => {
const expectedEvents = [
'feedback_round_opened',
'feedback_submitted',
'synthesis_complete',
'signoff_requested',
'signoff_received',
'document_approved',
'document_blocked',
'reminder',
'deadline_extended',
];
for (const event of expectedEvents) {
expect(NOTIFICATION_EVENTS[event]).toBeDefined();
expect(NOTIFICATION_EVENTS[event].description).toBeTruthy();
expect(NOTIFICATION_EVENTS[event].defaultChannels).toBeInstanceOf(Array);
expect(NOTIFICATION_EVENTS[event].priority).toBeTruthy();
}
});
it('should have appropriate priorities for different events', () => {
expect(NOTIFICATION_EVENTS.document_blocked.priority).toBe('urgent');
expect(NOTIFICATION_EVENTS.signoff_requested.priority).toBe('high');
expect(NOTIFICATION_EVENTS.feedback_submitted.priority).toBe('normal');
expect(NOTIFICATION_EVENTS.deadline_extended.priority).toBe('low');
});
it('should include all channels for important events', () => {
expect(NOTIFICATION_EVENTS.feedback_round_opened.defaultChannels).toContain('github');
expect(NOTIFICATION_EVENTS.feedback_round_opened.defaultChannels).toContain('slack');
expect(NOTIFICATION_EVENTS.feedback_round_opened.defaultChannels).toContain('email');
});
it('should have minimal channels for low-priority events', () => {
expect(NOTIFICATION_EVENTS.deadline_extended.defaultChannels).toEqual(['github']);
});
});
describe('PRIORITY_BEHAVIOR', () => {
it('should define all priority levels', () => {
expect(PRIORITY_BEHAVIOR.urgent).toBeDefined();
expect(PRIORITY_BEHAVIOR.high).toBeDefined();
expect(PRIORITY_BEHAVIOR.normal).toBeDefined();
expect(PRIORITY_BEHAVIOR.low).toBeDefined();
});
it('should have retry settings based on priority', () => {
expect(PRIORITY_BEHAVIOR.urgent.retryOnFailure).toBe(true);
expect(PRIORITY_BEHAVIOR.urgent.maxRetries).toBe(3);
expect(PRIORITY_BEHAVIOR.high.retryOnFailure).toBe(true);
expect(PRIORITY_BEHAVIOR.high.maxRetries).toBe(2);
expect(PRIORITY_BEHAVIOR.normal.retryOnFailure).toBe(false);
expect(PRIORITY_BEHAVIOR.normal.maxRetries).toBe(1);
expect(PRIORITY_BEHAVIOR.low.retryOnFailure).toBe(false);
});
it('should use all channels for urgent priority', () => {
expect(PRIORITY_BEHAVIOR.urgent.allChannels).toBe(true);
expect(PRIORITY_BEHAVIOR.high.allChannels).toBe(false);
expect(PRIORITY_BEHAVIOR.normal.allChannels).toBe(false);
});
});
// ============ Constructor Tests ============
describe('constructor', () => {
it('should always initialize GitHub channel', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
expect(service.channels.github).toBeDefined();
expect(service.isChannelAvailable('github')).toBe(true);
});
it('should initialize Slack when enabled with webhook', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
slack: {
enabled: true,
webhookUrl: 'https://hooks.slack.com/xxx',
},
});
expect(service.channels.slack).toBeDefined();
expect(service.isChannelAvailable('slack')).toBe(true);
});
it('should not initialize Slack without webhook', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
slack: { enabled: true }, // No webhookUrl
});
expect(service.channels.slack).toBeUndefined();
expect(service.isChannelAvailable('slack')).toBe(false);
});
it('should initialize Email when enabled with SMTP', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
email: {
enabled: true,
smtp: { host: 'localhost' },
},
});
expect(service.channels.email).toBeDefined();
expect(service.isChannelAvailable('email')).toBe(true);
});
it('should initialize Email when enabled with API key', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
email: {
enabled: true,
apiKey: 'SG.xxx',
},
});
expect(service.channels.email).toBeDefined();
});
it('should not initialize Email without config', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
email: { enabled: true }, // No smtp or apiKey
});
expect(service.channels.email).toBeUndefined();
});
});
// ============ getAvailableChannels Tests ============
describe('getAvailableChannels', () => {
it('should return only GitHub when minimal config', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
expect(service.getAvailableChannels()).toEqual(['github']);
});
it('should return all channels when fully configured', () => {
const service = new NotificationService({
github: { owner: 'test', repo: 'test' },
slack: { enabled: true, webhookUrl: 'https://xxx' },
email: { enabled: true, smtp: { host: 'localhost' } },
});
const channels = service.getAvailableChannels();
expect(channels).toContain('github');
expect(channels).toContain('slack');
expect(channels).toContain('email');
});
});
// ============ notify Tests ============
describe('notify', () => {
let service;
let mockGithubSend;
let mockSlackSend;
let mockEmailSend;
beforeEach(() => {
mockGithubSend = vi.fn().mockResolvedValue({ success: true, channel: 'github' });
mockSlackSend = vi.fn().mockResolvedValue({ success: true, channel: 'slack' });
mockEmailSend = vi.fn().mockResolvedValue({ success: true, channel: 'email' });
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
slack: { enabled: true, webhookUrl: 'https://xxx' },
email: { enabled: true, smtp: { host: 'localhost' } },
});
service.channels.github.send = mockGithubSend;
service.channels.slack.send = mockSlackSend;
service.channels.email.send = mockEmailSend;
});
it('should throw for unknown event type', async () => {
await expect(service.notify('unknown_event', {})).rejects.toThrow('Unknown notification event type: unknown_event');
});
it('should send to default channels for event', async () => {
await service.notify('feedback_round_opened', {
document_type: 'prd',
document_key: 'test',
});
expect(mockGithubSend).toHaveBeenCalled();
expect(mockSlackSend).toHaveBeenCalled();
expect(mockEmailSend).toHaveBeenCalled();
});
it('should filter to available channels only', async () => {
// Service with only GitHub
const minimalService = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
minimalService.channels.github.send = mockGithubSend;
await minimalService.notify('feedback_round_opened', {});
expect(mockGithubSend).toHaveBeenCalled();
expect(mockSlackSend).not.toHaveBeenCalled();
expect(mockEmailSend).not.toHaveBeenCalled();
});
it('should always include GitHub as baseline', async () => {
await service.notify(
'feedback_submitted',
{
document_type: 'prd',
document_key: 'test',
},
{ channels: ['slack'] },
); // Explicitly only slack
// GitHub should still be included
expect(mockGithubSend).toHaveBeenCalled();
expect(mockSlackSend).toHaveBeenCalled();
});
it('should use all channels for urgent priority', async () => {
await service.notify('document_blocked', {
document_type: 'prd',
document_key: 'test',
user: 'security',
reason: 'Blocked',
});
// document_blocked is urgent, should use all available channels
expect(mockGithubSend).toHaveBeenCalled();
expect(mockSlackSend).toHaveBeenCalled();
expect(mockEmailSend).toHaveBeenCalled();
});
it('should respect custom channels option', async () => {
await service.notify(
'deadline_extended',
{
document_type: 'prd',
document_key: 'test',
},
{ channels: ['github', 'slack'] },
);
expect(mockGithubSend).toHaveBeenCalled();
expect(mockSlackSend).toHaveBeenCalled();
expect(mockEmailSend).not.toHaveBeenCalled();
});
it('should aggregate results from all channels', async () => {
const result = await service.notify('signoff_requested', {
document_type: 'prd',
document_key: 'test',
});
expect(result.success).toBe(true);
expect(result.eventType).toBe('signoff_requested');
expect(result.results.github).toBeDefined();
expect(result.results.slack).toBeDefined();
expect(result.results.email).toBeDefined();
});
it('should report success if any channel succeeds', async () => {
mockGithubSend.mockResolvedValue({ success: true, channel: 'github' });
mockSlackSend.mockResolvedValue({ success: false, channel: 'slack', error: 'Failed' });
mockEmailSend.mockResolvedValue({ success: false, channel: 'email', error: 'Failed' });
const result = await service.notify('feedback_round_opened', {});
expect(result.success).toBe(true);
});
it('should report failure if all channels fail', async () => {
mockGithubSend.mockResolvedValue({ success: false, error: 'Failed' });
mockSlackSend.mockResolvedValue({ success: false, error: 'Failed' });
mockEmailSend.mockResolvedValue({ success: false, error: 'Failed' });
const result = await service.notify('feedback_round_opened', {});
expect(result.success).toBe(false);
});
});
// ============ sendReminder Tests ============
describe('sendReminder', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format users as mentions', async () => {
await service.sendReminder('prd', 'user-auth', ['alice', 'bob'], {
action_needed: 'feedback',
deadline: '2026-01-15',
});
expect(notifySpy).toHaveBeenCalledWith(
'reminder',
expect.objectContaining({
mentions: '@alice @bob',
users: ['alice', 'bob'],
document_type: 'prd',
document_key: 'user-auth',
}),
);
});
});
// ============ notifyFeedbackRoundOpened Tests ============
describe('notifyFeedbackRoundOpened', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format document data correctly', async () => {
await service.notifyFeedbackRoundOpened(
{
type: 'prd',
key: 'user-auth',
title: 'User Authentication',
version: 1,
url: 'https://example.com/doc',
reviewIssue: 100,
},
['alice', 'bob', 'charlie'],
'2026-01-15',
);
expect(notifySpy).toHaveBeenCalledWith(
'feedback_round_opened',
expect.objectContaining({
document_type: 'prd',
document_key: 'user-auth',
title: 'User Authentication',
version: 1,
deadline: '2026-01-15',
stakeholder_count: 3,
mentions: '@alice @bob @charlie',
users: ['alice', 'bob', 'charlie'],
document_url: 'https://example.com/doc',
review_issue: 100,
}),
);
});
});
// ============ notifyFeedbackSubmitted Tests ============
describe('notifyFeedbackSubmitted', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format feedback data correctly', async () => {
await service.notifyFeedbackSubmitted(
{
submittedBy: 'security',
type: 'concern',
section: 'FR-3',
summary: 'Security vulnerability identified',
issueNumber: 42,
url: 'https://example.com/issues/42',
},
{
type: 'prd',
key: 'payments',
owner: 'product-owner',
reviewIssue: 100,
},
);
expect(notifySpy).toHaveBeenCalledWith(
'feedback_submitted',
expect.objectContaining({
document_type: 'prd',
document_key: 'payments',
user: 'security',
feedback_type: 'concern',
section: 'FR-3',
feedback_issue: 42,
}),
expect.objectContaining({
notifyOnly: ['product-owner'],
}),
);
});
});
// ============ notifySynthesisComplete Tests ============
describe('notifySynthesisComplete', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format synthesis data correctly', async () => {
await service.notifySynthesisComplete(
{
type: 'prd',
key: 'user-auth',
url: 'https://example.com/doc',
reviewIssue: 100,
},
{
oldVersion: 1,
newVersion: 2,
feedbackCount: 12,
conflictsResolved: 3,
summary: 'Incorporated security feedback and clarified auth flow',
},
);
expect(notifySpy).toHaveBeenCalledWith(
'synthesis_complete',
expect.objectContaining({
document_type: 'prd',
document_key: 'user-auth',
old_version: 1,
new_version: 2,
feedback_count: 12,
conflicts_resolved: 3,
summary: expect.stringContaining('security feedback'),
}),
);
});
});
// ============ notifySignoffRequested Tests ============
describe('notifySignoffRequested', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format signoff request correctly', async () => {
await service.notifySignoffRequested(
{
type: 'prd',
key: 'payments',
title: 'Payments V2',
version: 2,
url: 'https://example.com/doc',
signoffUrl: 'https://example.com/signoff',
reviewIssue: 200,
},
['alice', 'bob', 'charlie'],
'2026-01-20',
{ minimum_approvals: 2 },
);
expect(notifySpy).toHaveBeenCalledWith(
'signoff_requested',
expect.objectContaining({
document_type: 'prd',
document_key: 'payments',
title: 'Payments V2',
version: 2,
deadline: '2026-01-20',
approvals_needed: 2,
mentions: '@alice @bob @charlie',
users: ['alice', 'bob', 'charlie'],
}),
);
});
it('should calculate approvals_needed from stakeholder count when not specified', async () => {
await service.notifySignoffRequested(
{
type: 'prd',
key: 'test',
title: 'Test',
version: 1,
},
['a', 'b', 'c', 'd', 'e'],
'2026-01-20',
{}, // No minimum_approvals
);
expect(notifySpy).toHaveBeenCalledWith(
'signoff_requested',
expect.objectContaining({
approvals_needed: 3, // ceil(5 * 0.5) = 3
}),
);
});
});
// ============ notifySignoffReceived Tests ============
describe('notifySignoffReceived', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format approved signoff correctly', async () => {
await service.notifySignoffReceived(
{
user: 'alice',
decision: 'approved',
note: null,
},
{
type: 'prd',
key: 'test',
reviewIssue: 100,
reviewUrl: 'https://example.com/issues/100',
},
{ current: 2, total: 3 },
);
expect(notifySpy).toHaveBeenCalledWith(
'signoff_received',
expect.objectContaining({
document_type: 'prd',
document_key: 'test',
user: 'alice',
decision: 'approved',
emoji: '✅',
progress_current: 2,
progress_total: 3,
}),
);
});
it('should format blocked signoff with correct emoji', async () => {
await service.notifySignoffReceived(
{
user: 'security',
decision: 'blocked',
note: 'Security concern',
},
{
type: 'prd',
key: 'test',
reviewIssue: 100,
},
{ current: 1, total: 3 },
);
expect(notifySpy).toHaveBeenCalledWith(
'signoff_received',
expect.objectContaining({
decision: 'blocked',
emoji: '🚫',
note: 'Security concern',
}),
);
});
it('should format approved-with-note signoff correctly', async () => {
await service.notifySignoffReceived(
{
user: 'bob',
decision: 'approved-with-note',
note: 'Minor concern',
},
{
type: 'prd',
key: 'test',
reviewIssue: 100,
},
{ current: 2, total: 3 },
);
expect(notifySpy).toHaveBeenCalledWith(
'signoff_received',
expect.objectContaining({
emoji: '✅📝',
note: 'Minor concern',
}),
);
});
});
// ============ notifyDocumentApproved Tests ============
describe('notifyDocumentApproved', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format approval data correctly', async () => {
await service.notifyDocumentApproved(
{
type: 'prd',
key: 'user-auth',
title: 'User Authentication',
version: 2,
url: 'https://example.com/doc',
},
3,
3,
);
expect(notifySpy).toHaveBeenCalledWith(
'document_approved',
expect.objectContaining({
document_type: 'prd',
document_key: 'user-auth',
title: 'User Authentication',
version: 2,
approval_count: 3,
stakeholder_count: 3,
document_url: 'https://example.com/doc',
}),
);
});
});
// ============ notifyDocumentBlocked Tests ============
describe('notifyDocumentBlocked', () => {
let service;
let notifySpy;
beforeEach(() => {
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
notifySpy = vi.spyOn(service, 'notify').mockResolvedValue({ success: true });
});
it('should format block data correctly', async () => {
await service.notifyDocumentBlocked(
{
type: 'prd',
key: 'payments',
},
{
user: 'legal',
reason: 'GDPR compliance review required',
feedbackIssue: 42,
feedbackUrl: 'https://example.com/issues/42',
},
);
expect(notifySpy).toHaveBeenCalledWith(
'document_blocked',
expect.objectContaining({
document_type: 'prd',
document_key: 'payments',
user: 'legal',
reason: 'GDPR compliance review required',
feedback_issue: 42,
feedback_url: 'https://example.com/issues/42',
}),
);
});
});
// ============ Retry Logic Tests ============
describe('retry logic', () => {
let service;
let mockGithubSend;
beforeEach(() => {
mockGithubSend = vi.fn();
service = new NotificationService({
github: { owner: 'test', repo: 'test' },
});
service.channels.github.send = mockGithubSend;
});
it('should retry on failure for urgent priority', async () => {
let attempts = 0;
mockGithubSend.mockImplementation(() => {
attempts++;
if (attempts < 3) {
return Promise.resolve({ success: false, error: 'Temporary failure' });
}
return Promise.resolve({ success: true, channel: 'github' });
});
const result = await service.notify('document_blocked', {
document_type: 'prd',
document_key: 'test',
user: 'blocker',
reason: 'Issue',
});
expect(result.success).toBe(true);
expect(mockGithubSend).toHaveBeenCalledTimes(3);
}, 10000);
it('should not retry for normal priority', async () => {
mockGithubSend.mockResolvedValue({ success: false, error: 'Failed' });
const result = await service.notify('deadline_extended', {
document_type: 'prd',
document_key: 'test',
});
expect(result.results.github.success).toBe(false);
expect(mockGithubSend).toHaveBeenCalledTimes(1);
});
});
});