BMAD-METHOD/test/unit/notifications/slack-notifier.test.js

471 lines
14 KiB
JavaScript

/**
* Tests for SlackNotifier - Slack webhook integration
*
* Tests cover:
* - Slack block templates for all event types
* - Dynamic color and title functions
* - Payload building with blocks and attachments
* - Enable/disable behavior
* - Custom message sending
* - Webhook error handling
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
SlackNotifier,
SLACK_TEMPLATES
} from '../../../src/modules/bmm/lib/notifications/slack-notifier.js';
// Mock global fetch
global.fetch = vi.fn();
describe('SlackNotifier', () => {
beforeEach(() => {
vi.resetAllMocks();
global.fetch.mockResolvedValue({ ok: true });
});
// ============ SLACK_TEMPLATES Tests ============
describe('SLACK_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'
];
for (const type of expectedTypes) {
expect(SLACK_TEMPLATES[type]).toBeDefined();
expect(SLACK_TEMPLATES[type].title).toBeTruthy();
expect(SLACK_TEMPLATES[type].blocks).toBeInstanceOf(Function);
}
});
it('should generate blocks for feedback_round_opened', () => {
const data = {
document_type: 'prd',
document_key: 'user-auth',
version: 1,
deadline: '2026-01-15',
stakeholder_count: 5,
document_url: 'https://example.com/doc'
};
const blocks = SLACK_TEMPLATES.feedback_round_opened.blocks(data);
expect(blocks).toBeInstanceOf(Array);
expect(blocks.length).toBeGreaterThan(0);
// Check header block
const header = blocks.find(b => b.type === 'header');
expect(header).toBeDefined();
expect(header.text.text).toContain('Feedback');
// Check section with fields
const section = blocks.find(b => b.type === 'section' && b.fields);
expect(section).toBeDefined();
// Check actions block
const actions = blocks.find(b => b.type === 'actions');
expect(actions).toBeDefined();
expect(actions.elements[0].url).toBe('https://example.com/doc');
});
it('should have static color values where appropriate', () => {
expect(SLACK_TEMPLATES.feedback_round_opened.color).toBe('#36a64f');
expect(SLACK_TEMPLATES.document_blocked.color).toBe('#dc3545');
expect(SLACK_TEMPLATES.reminder.color).toBe('#ffc107');
});
it('should have dynamic color for signoff_received', () => {
expect(typeof SLACK_TEMPLATES.signoff_received.color).toBe('function');
const approvedColor = SLACK_TEMPLATES.signoff_received.color({ decision: 'approved' });
const blockedColor = SLACK_TEMPLATES.signoff_received.color({ decision: 'blocked' });
expect(approvedColor).toBe('#28a745'); // Green
expect(blockedColor).toBe('#dc3545'); // Red
});
it('should have dynamic title for signoff_received', () => {
expect(typeof SLACK_TEMPLATES.signoff_received.title).toBe('function');
const title = SLACK_TEMPLATES.signoff_received.title({
emoji: '✅',
user: 'alice'
});
expect(title).toContain('✅');
expect(title).toContain('alice');
});
it('should handle optional note in signoff_received blocks', () => {
const dataWithNote = {
emoji: '✅📝',
user: 'bob',
decision: 'approved',
document_type: 'prd',
document_key: 'test',
progress_current: 2,
progress_total: 3,
note: 'Minor concern noted',
review_url: 'https://example.com'
};
const dataWithoutNote = { ...dataWithNote, note: null };
const blocksWithNote = SLACK_TEMPLATES.signoff_received.blocks(dataWithNote);
const blocksWithoutNote = SLACK_TEMPLATES.signoff_received.blocks(dataWithoutNote);
// With note should have more blocks
expect(blocksWithNote.length).toBeGreaterThan(blocksWithoutNote.length);
});
it('should truncate long summaries in feedback_submitted', () => {
const longSummary = 'A'.repeat(300);
const data = {
user: 'alice',
document_type: 'prd',
document_key: 'test',
feedback_type: 'concern',
section: 'FR-1',
summary: longSummary,
feedback_url: 'https://example.com'
};
const blocks = SLACK_TEMPLATES.feedback_submitted.blocks(data);
const summaryBlock = blocks.find(b =>
b.type === 'section' && b.text?.text?.startsWith('>')
);
expect(summaryBlock.text.text.length).toBeLessThan(250);
expect(summaryBlock.text.text).toContain('...');
});
});
// ============ Constructor Tests ============
describe('constructor', () => {
it('should initialize with webhook URL', () => {
const notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#prd-updates'
});
expect(notifier.webhookUrl).toBe('https://hooks.slack.com/services/xxx');
expect(notifier.channel).toBe('#prd-updates');
expect(notifier.enabled).toBe(true);
});
it('should use default values', () => {
const notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx'
});
expect(notifier.username).toBe('PRD Crowdsource Bot');
expect(notifier.iconEmoji).toBe(':clipboard:');
});
it('should be disabled without webhook URL', () => {
const notifier = new SlackNotifier({});
expect(notifier.enabled).toBe(false);
});
});
// ============ isEnabled Tests ============
describe('isEnabled', () => {
it('should return true when webhook configured', () => {
const notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx'
});
expect(notifier.isEnabled()).toBe(true);
});
it('should return false when not configured', () => {
const notifier = new SlackNotifier({});
expect(notifier.isEnabled()).toBe(false);
});
});
// ============ send Tests ============
describe('send', () => {
let notifier;
beforeEach(() => {
notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#prd-updates'
});
});
it('should return error when not enabled', async () => {
const disabledNotifier = new SlackNotifier({});
const result = await disabledNotifier.send('feedback_round_opened', {});
expect(result.success).toBe(false);
expect(result.error).toContain('not enabled');
});
it('should return error for unknown event type', async () => {
const result = await notifier.send('unknown_event', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Unknown notification event type');
});
it('should send webhook with correct payload', async () => {
const data = {
document_type: 'prd',
document_key: 'user-auth',
version: 1,
deadline: '2026-01-15',
stakeholder_count: 5,
document_url: 'https://example.com/doc'
};
const result = await notifier.send('feedback_round_opened', data);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith(
'https://hooks.slack.com/services/xxx',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
);
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.channel).toBe('#prd-updates');
expect(payload.username).toBe('PRD Crowdsource Bot');
expect(payload.attachments).toHaveLength(1);
expect(payload.attachments[0].color).toBe('#36a64f');
expect(payload.attachments[0].blocks).toBeInstanceOf(Array);
expect(result.success).toBe(true);
expect(result.channel).toBe('slack');
});
it('should handle webhook error', async () => {
global.fetch.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error'
});
const result = await notifier.send('document_approved', {
document_type: 'prd',
document_key: 'test',
title: 'Test',
version: 1,
approval_count: 3,
stakeholder_count: 3,
document_url: 'https://example.com'
});
expect(result.success).toBe(false);
expect(result.error).toContain('500');
});
it('should use custom channel from options', async () => {
await notifier.send('reminder', {
document_type: 'prd',
document_key: 'test',
action_needed: 'feedback',
deadline: '2026-01-15',
time_remaining: '2 days',
document_url: 'https://example.com'
}, { channel: '#urgent-prd' });
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.channel).toBe('#urgent-prd');
});
});
// ============ sendCustom Tests ============
describe('sendCustom', () => {
let notifier;
beforeEach(() => {
notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#general'
});
});
it('should return error when not enabled', async () => {
const disabledNotifier = new SlackNotifier({});
const result = await disabledNotifier.sendCustom('Hello');
expect(result.success).toBe(false);
expect(result.error).toContain('not enabled');
});
it('should send custom message', async () => {
const result = await notifier.sendCustom('Custom notification message');
expect(global.fetch).toHaveBeenCalledTimes(1);
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.text).toBe('Custom notification message');
expect(payload.channel).toBe('#general');
expect(result.success).toBe(true);
});
it('should allow channel override', async () => {
await notifier.sendCustom('Test', { channel: '#testing' });
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.channel).toBe('#testing');
});
it('should handle webhook error', async () => {
global.fetch.mockRejectedValue(new Error('Network error'));
const result = await notifier.sendCustom('Test');
expect(result.success).toBe(false);
expect(result.error).toBe('Network error');
});
});
// ============ _buildPayload Tests ============
describe('_buildPayload', () => {
let notifier;
beforeEach(() => {
notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#default',
username: 'TestBot',
iconEmoji: ':robot:'
});
});
it('should build payload with static color', () => {
const template = SLACK_TEMPLATES.feedback_round_opened;
const data = {
document_type: 'prd',
document_key: 'test',
version: 1,
deadline: '2026-01-15',
stakeholder_count: 3,
document_url: 'https://example.com'
};
const payload = notifier._buildPayload(template, data, {});
expect(payload.channel).toBe('#default');
expect(payload.username).toBe('TestBot');
expect(payload.icon_emoji).toBe(':robot:');
expect(payload.text).toBe('📣 Feedback Round Open');
expect(payload.attachments[0].color).toBe('#36a64f');
});
it('should build payload with dynamic color', () => {
const template = SLACK_TEMPLATES.signoff_received;
const data = {
emoji: '🚫',
user: 'alice',
decision: 'blocked',
document_type: 'prd',
document_key: 'test',
progress_current: 2,
progress_total: 5,
review_url: 'https://example.com'
};
const payload = notifier._buildPayload(template, data, {});
expect(payload.attachments[0].color).toBe('#dc3545'); // Red for blocked
});
it('should build payload with dynamic title', () => {
const template = SLACK_TEMPLATES.signoff_received;
const data = {
emoji: '✅',
user: 'bob',
decision: 'approved',
document_type: 'prd',
document_key: 'test',
progress_current: 3,
progress_total: 3,
review_url: 'https://example.com'
};
const payload = notifier._buildPayload(template, data, {});
expect(payload.text).toContain('bob');
expect(payload.attachments[0].fallback).toContain('bob');
});
});
// ============ Integration Tests ============
describe('integration', () => {
let notifier;
beforeEach(() => {
notifier = new SlackNotifier({
webhookUrl: 'https://hooks.slack.com/services/xxx',
channel: '#prd-notifications'
});
});
it('should send document_blocked notification', async () => {
await notifier.send('document_blocked', {
document_type: 'prd',
document_key: 'payments-v2',
user: 'legal-team',
reason: 'Compliance review required',
feedback_url: 'https://example.com/feedback/123'
});
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.attachments[0].color).toBe('#dc3545');
expect(payload.attachments[0].blocks).toBeInstanceOf(Array);
// Find blocking reason in blocks
const reasonBlock = payload.attachments[0].blocks.find(
b => b.type === 'section' && b.text?.text?.includes('Compliance')
);
expect(reasonBlock).toBeDefined();
});
it('should send synthesis_complete notification', async () => {
await notifier.send('synthesis_complete', {
document_type: 'prd',
document_key: 'user-auth',
old_version: 1,
new_version: 2,
feedback_count: 12,
conflicts_resolved: 3,
summary: 'Incorporated 12 feedback items including session timeout resolution',
document_url: 'https://example.com/doc'
});
const payload = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(payload.attachments[0].color).toBe('#9932cc'); // Purple
expect(payload.text).toContain('Synthesis Complete');
});
});
});