/** * 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); }); }); });