@digitalnodecom/node-red-contrib-analyzer
Version:
A Node-RED global service that monitors function nodes for debugging artifacts and performance issues. Features real-time quality metrics, Vue.js dashboard, and comprehensive code analysis.
463 lines (378 loc) • 17 kB
JavaScript
const SlackNotifier = require('../../lib/monitoring/slack-notifier');
// Mock fetch globally
global.fetch = jest.fn();
// Mock Node-RED
const mockRED = {
settings: {
uiPort: 1880,
httpNodeRoot: '/test'
},
nodes: {
eachNode: jest.fn()
}
};
describe('SlackNotifier', () => {
let slackNotifier;
let mockFallbackCallback;
beforeEach(() => {
// Arrange - Reset mocks before each test
jest.clearAllMocks();
fetch.mockClear();
mockFallbackCallback = jest.fn();
slackNotifier = new SlackNotifier('https://hooks.slack.com/test', mockRED);
});
describe('Constructor', () => {
test('should initialize with webhook URL and RED instance', () => {
// Arrange
const webhookUrl = 'https://hooks.slack.com/test';
// Act
const notifier = new SlackNotifier(webhookUrl, mockRED);
// Assert
expect(notifier.webhookUrl).toBe(webhookUrl);
expect(notifier.RED).toBe(mockRED);
});
});
describe('sendMessage', () => {
test('should send message to Slack when webhook URL is provided', async () => {
// Arrange
const message = 'Test message';
const expectedPayload = {
text: message,
username: 'Node-RED Queue Monitor',
icon_emoji: ':warning:'
};
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendMessage(message);
// Assert
expect(fetch).toHaveBeenCalledWith(
'https://hooks.slack.com/test',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(expectedPayload)
})
);
});
test('should call fallback callback when no webhook URL provided', async () => {
// Arrange
const message = 'Test message';
const notifierWithoutUrl = new SlackNotifier('', mockRED);
// Act
await notifierWithoutUrl.sendMessage(message, mockFallbackCallback);
// Assert
expect(mockFallbackCallback).toHaveBeenCalledWith(message);
expect(fetch).not.toHaveBeenCalled();
});
test('should call fallback callback when fetch fails', async () => {
// Arrange
const message = 'Test message';
const errorMessage = 'Network error';
fetch.mockRejectedValueOnce(new Error(errorMessage));
// Act
await slackNotifier.sendMessage(message, mockFallbackCallback);
// Assert
expect(mockFallbackCallback).toHaveBeenCalledWith(message);
expect(fetch).toHaveBeenCalled();
});
test('should not throw error when no fallback callback provided and fetch fails', async () => {
// Arrange
const message = 'Test message';
fetch.mockRejectedValueOnce(new Error('Network error'));
// Act & Assert
await expect(slackNotifier.sendMessage(message)).resolves.not.toThrow();
});
});
describe('sendQueueAlert', () => {
test('should format and send queue alert message correctly', async () => {
// Arrange
const alerts = {
'queue1': {
queueName: 'TestQueue1',
flowName: 'TestFlow',
queueLength: 15,
timestamp: Date.now()
},
'queue2': {
queueName: 'TestQueue2',
flowName: 'TestFlow',
queueLength: 10,
timestamp: Date.now()
}
};
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendQueueAlert(alerts);
// Assert
expect(fetch).toHaveBeenCalledWith(
'https://hooks.slack.com/test',
expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: expect.stringContaining('Queue Alert Summary - 2 Queues Need Attention')
})
);
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('Total Items in Queues:** 25');
expect(payload.text).toContain('TestQueue1: 15 items');
expect(payload.text).toContain('TestQueue2: 10 items');
});
test('should handle single queue alert', async () => {
// Arrange
const alerts = {
'queue1': {
queueName: 'SingleQueue',
flowName: 'TestFlow',
queueLength: 5,
timestamp: Date.now()
}
};
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendQueueAlert(alerts);
// Assert
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('1 Queue Need Attention');
expect(payload.text).toContain('Total Items in Queues:** 5');
});
test('should group alerts by flow name', async () => {
// Arrange
const alerts = {
'queue1': {
queueName: 'Queue1',
flowName: 'Flow1',
queueLength: 5,
timestamp: Date.now()
},
'queue2': {
queueName: 'Queue2',
flowName: 'Flow2',
queueLength: 10,
timestamp: Date.now()
}
};
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendQueueAlert(alerts);
// Assert
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('Flow: Flow1');
expect(payload.text).toContain('Flow: Flow2');
});
test('should use fallback callback when no webhook URL', async () => {
// Arrange
const alerts = { 'queue1': { queueName: 'Test', flowName: 'Test', queueLength: 5 } };
const notifierWithoutUrl = new SlackNotifier('', mockRED);
// Act
await notifierWithoutUrl.sendQueueAlert(alerts, mockFallbackCallback);
// Assert
expect(mockFallbackCallback).toHaveBeenCalledWith(expect.stringContaining('Queue Alert Summary'));
expect(fetch).not.toHaveBeenCalled();
});
});
describe('sendCodeAnalysisAlert', () => {
test('should format and send code analysis alert correctly', async () => {
// Arrange
const flowId = 'flow123';
const totalIssues = 5;
const nodesWithIssues = 2;
// Mock flow name lookup
mockRED.nodes.eachNode.mockImplementation((callback) => {
callback({ type: 'tab', id: flowId, label: 'TestFlow' });
callback({
type: 'function',
z: flowId,
name: 'TestNode1',
id: 'node1',
func: 'return; node.warn("test");',
_debugIssues: [
{ type: 'top-level-return', message: 'Remove return' },
{ type: 'node-warn', message: 'Remove warn' }
]
});
callback({
type: 'function',
z: flowId,
name: 'TestNode2',
id: 'node2',
func: '// TODO: implement',
_debugIssues: [
{ type: 'todo-comment', message: 'Resolve TODO' }
]
});
});
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendCodeAnalysisAlert(flowId, totalIssues, nodesWithIssues);
// Assert
expect(fetch).toHaveBeenCalledWith(
'https://hooks.slack.com/test',
expect.objectContaining({
method: 'POST'
})
);
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('Code Analysis Alert - 5 Issues Found');
expect(payload.text).toContain('2 function nodes in flow "TestFlow"');
expect(payload.text).toContain('TestNode1');
expect(payload.text).toContain('TestNode2');
});
test('should send performance alert with CPU and memory violations', async () => {
// Arrange
const performanceSummary = {
current: { cpu: 85.5, memory: 90.2, eventLoopLag: 15.3 },
averages: { cpu: 82.1, memory: 88.7, eventLoop: 12.5 },
alerts: [
{ type: 'cpu', threshold: 75, current: 85.5, sustainedDuration: 300000, severity: 'warning' },
{ type: 'memory', threshold: 80, current: 90.2, sustainedDuration: 300000, severity: 'warning' }
]
};
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendPerformanceAlert(performanceSummary);
// Assert
expect(fetch).toHaveBeenCalledWith(
'https://hooks.slack.com/test',
expect.objectContaining({
method: 'POST'
})
);
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('CRITICAL PERFORMANCE ALERT');
expect(payload.text).toContain('CPU usage** threshold of 75%');
expect(payload.text).toContain('memory usage** threshold of 80%');
expect(payload.text).toContain('5 minutes');
expect(payload.text).toContain('**CPU**: Identify and optimize');
expect(payload.text).toContain('**Memory**: Investigate memory leaks');
});
test('should not send performance alert when no alerts exist', async () => {
// Arrange
const performanceSummary = {
current: { cpu: 50, memory: 60, eventLoopLag: 5 },
alerts: []
};
// Act
await slackNotifier.sendPerformanceAlert(performanceSummary);
// Assert
expect(fetch).not.toHaveBeenCalled();
});
test('should handle event loop lag alerts', async () => {
// Arrange
const performanceSummary = {
current: { cpu: 50, memory: 60, eventLoopLag: 150 },
averages: { cpu: 48.2, memory: 58.9, eventLoop: 145.3 },
alerts: [
{ type: 'eventLoop', threshold: 100, current: 150, sustainedDuration: 180000, severity: 'info' }
]
};
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendPerformanceAlert(performanceSummary);
// Assert
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('event loop delay** threshold of 100ms');
expect(payload.text).toContain('3 minutes');
expect(payload.text).toContain('**Event Loop**: Eliminate blocking operations');
});
test('should handle fetch errors gracefully', async () => {
// Arrange
const mockCallback = jest.fn();
const testMessage = 'Test message';
fetch.mockRejectedValueOnce(new Error('Network error'));
// Act
await slackNotifier.sendMessage(testMessage, mockCallback);
// Assert
expect(mockCallback).toHaveBeenCalledWith(testMessage);
});
test('should categorize issues by severity level', async () => {
// Arrange
const flowId = 'flow123';
mockRED.nodes.eachNode.mockImplementation((callback) => {
callback({ type: 'tab', id: flowId, label: 'TestFlow' });
callback({
type: 'function',
z: flowId,
name: 'TestNode',
id: 'node1',
func: 'return; node.warn("debug"); const test = "test";',
_debugIssues: [
{ type: 'top-level-return', message: 'Critical issue' },
{ type: 'node-warn', message: 'Warning issue' },
{ type: 'hardcoded-test', message: 'Minor issue' }
]
});
});
fetch.mockResolvedValueOnce({ ok: true });
// Act
await slackNotifier.sendCodeAnalysisAlert(flowId, 3, 1);
// Assert
const callArgs = fetch.mock.calls[0];
const payload = JSON.parse(callArgs[1].body);
expect(payload.text).toContain('**Critical**');
expect(payload.text).toContain('**Warning**');
expect(payload.text).toContain('**Info**');
});
test('should use fallback callback when no webhook URL', async () => {
// Arrange
const notifierWithoutUrl = new SlackNotifier('', mockRED);
// Act
await notifierWithoutUrl.sendCodeAnalysisAlert('flow123', 1, 1, mockFallbackCallback);
// Assert
expect(mockFallbackCallback).toHaveBeenCalledWith(expect.stringContaining('Code Analysis Alert'));
expect(fetch).not.toHaveBeenCalled();
});
});
describe('getNodeRedUrl', () => {
test('should return environment variable URL when available', () => {
// Arrange
const envUrl = 'https://custom-node-red.com';
process.env.NODE_RED_BASE_URL = envUrl;
// Act
const url = slackNotifier.getNodeRedUrl();
// Assert
expect(url).toBe(envUrl);
// Cleanup
delete process.env.NODE_RED_BASE_URL;
});
test('should construct URL from RED settings', () => {
// Arrange
delete process.env.NODE_RED_BASE_URL;
// Act
const url = slackNotifier.getNodeRedUrl();
// Assert
expect(url).toBe('http://localhost:1880/test');
});
test('should use default URL when no settings available', () => {
// Arrange
delete process.env.NODE_RED_BASE_URL;
const notifierWithoutSettings = new SlackNotifier('https://test', { settings: {} });
// Act
const url = notifierWithoutSettings.getNodeRedUrl();
// Assert
expect(url).toBe('http://localhost:1880');
});
test('should use default port when uiPort is not set', () => {
// Arrange
delete process.env.NODE_RED_BASE_URL;
const notifierWithoutPort = new SlackNotifier('https://test', {
settings: {
httpNodeRoot: '/api'
}
});
// Act
const url = notifierWithoutPort.getNodeRedUrl();
// Assert
expect(url).toBe('http://localhost:1880/api');
});
});
});