Files
uptime-monitoring/test/channels.spec.ts
T
2026-08-31 21:22:29 +07:00

144 lines
6.2 KiB
TypeScript

import { applyD1Migrations, type D1Migration } from 'cloudflare:test';
import { env } from 'cloudflare:workers';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { dispatchNotification } from '../src/worker/notifications/dispatch';
import { discordProvider, slackProvider, telegramProvider, webhookProvider } from '../src/worker/notifications/providers';
import { parseChannelInput } from '../src/worker/routes/channels';
const event = {
kind: 'down' as const,
monitor: { id: 7, name: 'API <prod>', url: 'https://api.example.com' },
incidentId: null,
title: 'API is down',
body: 'Requests are failing.',
statusCode: 500,
error: 'Expected <200>',
at: new Date('2026-08-28T03:25:00Z'),
};
async function insertChannel(name: string, config: string, overrides = '') {
const now = Date.now();
const result = await env.DB.prepare(
`INSERT INTO notification_channels (name, type, config, enabled, notify_manual, created_at, updated_at)
VALUES (?, 'webhook', ?, 1, 1, ?, ?) ${overrides}`,
)
.bind(name, config, now, now)
.run();
return Number(result.meta.last_row_id);
}
describe('notification channels', () => {
beforeAll(async () => {
const testEnv = env as Env & { TEST_MIGRATIONS: D1Migration[] };
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(async () => {
await env.DB.batch([
env.DB.prepare('DELETE FROM ai_events'),
env.DB.prepare('DELETE FROM notification_deliveries'),
env.DB.prepare('DELETE FROM notification_channel_monitors'),
env.DB.prepare('DELETE FROM notification_channels'),
env.DB.prepare('DELETE FROM incident_monitors'),
env.DB.prepare('DELETE FROM incidents'),
env.DB.prepare('DELETE FROM monitors'),
]);
const now = Date.now();
await env.DB.prepare(
`INSERT INTO monitors (id, name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled,
retry_count, failure_threshold, consecutive_failures, last_ok, created_at, updated_at)
VALUES (7, 'API', 'https://api.example.com', 'GET', 200, 300, 10000, 1, 1, 0, 1, 0, 1, ?, ?)`,
)
.bind(now, now)
.run();
});
afterEach(() => vi.unstubAllGlobals());
it('formats provider-specific payloads and preserves the raw webhook payload', () => {
const webhook = webhookProvider.format({ url: 'https://hooks.example.com' }, event);
expect(JSON.parse(webhook.body)).toEqual({
event: 'down',
monitor: event.monitor,
statusCode: 500,
error: 'Expected <200>',
at: '2026-08-28T03:25:00.000Z',
});
const testWebhook = webhookProvider.format(
{ url: 'https://hooks.example.com' },
{ ...event, kind: 'test', monitor: null, incidentId: null, title: 'Upwatch test', body: null, statusCode: 200, error: null },
);
expect(JSON.parse(testWebhook.body).monitor).toEqual({ id: 0, name: 'Upwatch test', url: 'https://example.com/health' });
expect(JSON.parse(slackProvider.format({ url: 'https://hooks.slack.com/test' }, event).body).attachments[0].color).toBe('#dc2626');
expect(JSON.parse(discordProvider.format({ url: 'https://discord.com/api/webhooks/test' }, event).body).embeds[0]).toMatchObject({
color: 14427686,
timestamp: '2026-08-28T03:25:00.000Z',
});
const telegram = telegramProvider.format({ botToken: '123:abc', chatId: '-10' }, event);
expect(telegram.url).toBe('https://api.telegram.org/bot123:abc/sendMessage');
expect(JSON.parse(telegram.body).text).toContain('API &lt;prod&gt;');
});
it('rejects private channel URLs and unknown providers', () => {
expect(
parseChannelInput({
name: 'Local',
type: 'webhook',
config: { url: 'http://127.0.0.1/x' },
enabled: true,
notifyManual: true,
monitorIds: [],
}),
).toMatchObject({ ok: false });
expect(
parseChannelInput({ name: 'Unknown', type: 'email', config: {}, enabled: true, notifyManual: true, monitorIds: [] }),
).toMatchObject({ ok: false });
});
it('fans out, records delivery, and retries a server failure once', async () => {
await insertChannel('All services', '{"url":"https://hooks.example.test/events"}');
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(null, { status: 500 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await dispatchNotification(env, event);
expect(fetchMock).toHaveBeenCalledTimes(2);
const delivery = await env.DB.prepare('SELECT ok, status_code, attempts FROM notification_deliveries').first<{
ok: number;
status_code: number;
attempts: number;
}>();
expect(delivery).toEqual({ ok: 1, status_code: 204, attempts: 2 });
});
it('does not retry a 400 response', async () => {
await insertChannel('All services', '{"url":"https://hooks.example.test/events"}');
const fetchMock = vi.fn(async () => new Response(null, { status: 400 }));
vi.stubGlobal('fetch', fetchMock);
await dispatchNotification(env, event);
expect(fetchMock).toHaveBeenCalledTimes(1);
const delivery = await env.DB.prepare('SELECT ok, error, attempts FROM notification_deliveries').first();
expect(delivery).toEqual({ ok: 0, error: 'HTTP 400', attempts: 1 });
});
it('honors monitor assignments and alertsEnabled', async () => {
const channelId = await insertChannel('Other service', '{"url":"https://hooks.example.test/events"}');
const now = Date.now();
await env.DB.prepare(
`INSERT INTO monitors (id, name, url, method, expected_status, interval_seconds, timeout_ms, enabled, alerts_enabled,
retry_count, failure_threshold, consecutive_failures, created_at, updated_at)
VALUES (8, 'Web', 'https://web.example.com', 'GET', 200, 300, 10000, 1, 1, 0, 1, 0, ?, ?)`,
)
.bind(now, now)
.run();
await env.DB.prepare('INSERT INTO notification_channel_monitors (channel_id, monitor_id) VALUES (?, 8)').bind(channelId).run();
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
await dispatchNotification(env, event);
expect(fetchMock).not.toHaveBeenCalled();
await env.DB.prepare('UPDATE monitors SET alerts_enabled = 0 WHERE id = 7').run();
await env.DB.prepare('DELETE FROM notification_channel_monitors').run();
await dispatchNotification(env, event);
expect(fetchMock).not.toHaveBeenCalled();
});
});