471 lines
14 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
});
|