Skip to content

Commit 396bda5

Browse files
authored
Merge pull request #69 from distributed-nerd/test/multi-channel-notification-delivery-e2e
test(listener): add e2e multi-channel notification delivery coverage
2 parents e26c6fd + 527d819 commit 396bda5

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { xdr } from '@stellar/stellar-sdk';
2+
import * as StellarSDK from '@stellar/stellar-sdk';
3+
import { DiscordNotificationService } from '../services/discord-notification';
4+
import { NotificationRetryQueue } from '../services/notification-retry-queue';
5+
import { DiscordConfig, ContractConfig } from '../types';
6+
7+
/**
8+
* End-to-end tests for notification delivery across multiple supported channels.
9+
*
10+
* These tests wire together the *real* delivery path used in production by
11+
* EventSubscriber.processEvent: each channel owns a real
12+
* DiscordNotificationService and a real NotificationRetryQueue, and the only
13+
* thing mocked is the outbound `fetch` (the network boundary).
14+
*
15+
* "Channel" here maps to a distinct Discord webhook destination — different
16+
* webhook URLs deliver to different Discord channels (e.g. #alerts, #ops,
17+
* #audit), which is how a single deployment fans an event out to several
18+
* destinations.
19+
*/
20+
21+
jest.mock('../utils/logger', () => ({
22+
__esModule: true,
23+
default: {
24+
info: jest.fn(),
25+
warn: jest.fn(),
26+
error: jest.fn(),
27+
},
28+
}));
29+
30+
const logger = jest.requireMock('../utils/logger').default;
31+
32+
/**
33+
* A routable fetch mock. Each registered URL gets a handler that receives the
34+
* (zero-based) attempt number for that URL, so a channel can be made to fail
35+
* the first N times and then recover — exactly what retry assertions need.
36+
*/
37+
type FetchHandler = (attempt: number) => Partial<Response> | Promise<Partial<Response>>;
38+
39+
function createFetchRouter() {
40+
const handlers = new Map<string, FetchHandler>();
41+
const callCounts = new Map<string, number>();
42+
const bodies = new Map<string, unknown[]>();
43+
44+
const okResponse = (): Partial<Response> => ({ ok: true, status: 204, statusText: 'No Content' });
45+
46+
const fetchMock = jest.fn(async (url: string, init?: RequestInit) => {
47+
const attempt = callCounts.get(url) ?? 0;
48+
callCounts.set(url, attempt + 1);
49+
50+
const recorded = bodies.get(url) ?? [];
51+
recorded.push(JSON.parse(String(init?.body ?? '{}')));
52+
bodies.set(url, recorded);
53+
54+
const handler = handlers.get(url);
55+
const response = handler ? await handler(attempt) : okResponse();
56+
return response as Response;
57+
});
58+
59+
return {
60+
fetchMock,
61+
/** Register per-channel network behaviour keyed by webhook URL. */
62+
setHandler(url: string, handler: FetchHandler) {
63+
handlers.set(url, handler);
64+
},
65+
callsTo(url: string): number {
66+
return callCounts.get(url) ?? 0;
67+
},
68+
bodiesTo(url: string): any[] {
69+
return bodies.get(url) ?? [];
70+
},
71+
};
72+
}
73+
74+
interface Channel {
75+
name: string;
76+
config: DiscordConfig;
77+
service: DiscordNotificationService;
78+
retryQueue: NotificationRetryQueue;
79+
}
80+
81+
function failedFetch(status: number, statusText: string, body = 'error'): Partial<Response> {
82+
return {
83+
ok: false,
84+
status,
85+
statusText,
86+
text: () => Promise.resolve(body),
87+
};
88+
}
89+
90+
function createMockEvent(
91+
overrides: Partial<StellarSDK.rpc.Api.EventResponse> = {}
92+
): StellarSDK.rpc.Api.EventResponse {
93+
return {
94+
id: 'event-e2e-1',
95+
type: 'contract',
96+
ledger: 5000,
97+
ledgerClosedAt: '2026-06-18T00:00:00Z',
98+
transactionIndex: 1,
99+
operationIndex: 0,
100+
inSuccessfulContractCall: true,
101+
txHash: 'tx-e2e-abc',
102+
topic: [xdr.ScVal.scvSymbol('task_created')],
103+
value: xdr.ScVal.scvString('bounty #42 opened'),
104+
...overrides,
105+
} as StellarSDK.rpc.Api.EventResponse;
106+
}
107+
108+
const contractConfig: ContractConfig = {
109+
address: 'CDNJ3YJ5F4U5YF4O5U6Y7I8U9Y0U1I2O3P4I5U6Y7I8',
110+
events: ['task_created'],
111+
};
112+
113+
describe('Multi-channel notification delivery (e2e)', () => {
114+
let router: ReturnType<typeof createFetchRouter>;
115+
let channels: Channel[];
116+
117+
// Retry queue timings kept tiny so fake timers advance quickly.
118+
const retryOptions = { baseDelayMs: 100, maxRetries: 3, processIntervalMs: 50 };
119+
120+
function buildChannel(name: string): Channel {
121+
const config: DiscordConfig = {
122+
webhookUrl: `https://discord.com/api/webhooks/${name}/token`,
123+
webhookId: name,
124+
};
125+
const service = new DiscordNotificationService(config);
126+
const retryQueue = new NotificationRetryQueue(
127+
(event, cc, requestId) => service.sendEventNotification(event, cc, requestId),
128+
retryOptions
129+
);
130+
return { name, config, service, retryQueue };
131+
}
132+
133+
/**
134+
* Mirrors EventSubscriber.processEvent, fanned out across every channel:
135+
* attempt immediate delivery, and on failure hand the event to that
136+
* channel's retry queue. Returns the per-channel immediate-delivery result.
137+
*/
138+
async function deliverToAllChannels(
139+
event: StellarSDK.rpc.Api.EventResponse,
140+
requestId?: string
141+
): Promise<Record<string, boolean>> {
142+
const results: Record<string, boolean> = {};
143+
for (const channel of channels) {
144+
const delivered = await channel.service.sendEventNotification(event, contractConfig, requestId);
145+
results[channel.name] = delivered;
146+
if (!delivered) {
147+
logger.warn('Channel delivery failed, adding to retry queue', {
148+
requestId,
149+
channel: channel.name,
150+
eventId: event.id,
151+
});
152+
channel.retryQueue.enqueue(event, contractConfig, requestId);
153+
}
154+
}
155+
return results;
156+
}
157+
158+
beforeEach(() => {
159+
jest.clearAllMocks();
160+
router = createFetchRouter();
161+
global.fetch = router.fetchMock as unknown as typeof fetch;
162+
channels = [buildChannel('alerts'), buildChannel('ops'), buildChannel('audit')];
163+
});
164+
165+
afterEach(() => {
166+
channels.forEach((channel) => channel.retryQueue.stop());
167+
});
168+
169+
describe('fan-out delivery', () => {
170+
it('delivers a single event to every configured channel', async () => {
171+
const results = await deliverToAllChannels(createMockEvent(), 'req-fanout');
172+
173+
expect(results).toEqual({ alerts: true, ops: true, audit: true });
174+
for (const channel of channels) {
175+
expect(router.callsTo(channel.config.webhookUrl)).toBe(1);
176+
}
177+
});
178+
179+
it('routes each channel its own payload at its own webhook URL', async () => {
180+
await deliverToAllChannels(createMockEvent({ id: 'event-routing' }));
181+
182+
for (const channel of channels) {
183+
const [body] = router.bodiesTo(channel.config.webhookUrl);
184+
expect(body).toBeDefined();
185+
// Every channel sees the same logical event, addressed to its own URL.
186+
expect(body.embeds).toHaveLength(1);
187+
expect(body.embeds[0].title).toContain('task_created');
188+
}
189+
// No cross-talk: a channel only ever receives its own delivery.
190+
expect(router.fetchMock).toHaveBeenCalledTimes(channels.length);
191+
});
192+
});
193+
194+
describe('channel-specific payloads', () => {
195+
it('builds a Discord embed payload carrying the event details', async () => {
196+
await deliverToAllChannels(
197+
createMockEvent({ type: 'contract', value: xdr.ScVal.scvString('payload-check') })
198+
);
199+
200+
const [body] = router.bodiesTo(channels[0].config.webhookUrl);
201+
const embed = body.embeds[0];
202+
203+
expect(embed.title).toContain('task_created');
204+
expect(embed.color).toBe(0x00ff00); // contract events are green
205+
const contractField = embed.fields.find((f: any) => f.name === 'Contract');
206+
const valueField = embed.fields.find((f: any) => f.name === 'Value');
207+
expect(contractField.value).toContain('...'); // address is abbreviated
208+
expect(valueField.value).toBe('payload-check');
209+
});
210+
211+
it('reflects the event type in the payload color across channels', async () => {
212+
await deliverToAllChannels(
213+
createMockEvent({ id: 'event-system', type: 'system' })
214+
);
215+
216+
for (const channel of channels) {
217+
const [body] = router.bodiesTo(channel.config.webhookUrl);
218+
expect(body.embeds[0].color).toBe(0x0099ff); // system events are blue
219+
const typeField = body.embeds[0].fields.find((f: any) => f.name === 'Type');
220+
expect(typeField.value).toBe('system');
221+
}
222+
});
223+
});
224+
225+
describe('partial delivery failure', () => {
226+
it('delivers to healthy channels even when one channel fails', async () => {
227+
router.setHandler(channels[1].config.webhookUrl, () =>
228+
failedFetch(500, 'Internal Server Error')
229+
);
230+
231+
const results = await deliverToAllChannels(createMockEvent({ id: 'event-partial' }), 'req-partial');
232+
233+
expect(results).toEqual({ alerts: true, ops: false, audit: true });
234+
});
235+
236+
it('logs a meaningful error identifying the failing channel', async () => {
237+
router.setHandler(channels[1].config.webhookUrl, () =>
238+
failedFetch(429, 'Too Many Requests', 'rate limited')
239+
);
240+
241+
await deliverToAllChannels(createMockEvent({ id: 'event-logged' }), 'req-logged');
242+
243+
// The service logs the underlying webhook failure with status detail...
244+
expect(logger.error).toHaveBeenCalledWith(
245+
'Discord webhook failed',
246+
expect.objectContaining({
247+
webhookId: 'ops',
248+
status: 429,
249+
statusText: 'Too Many Requests',
250+
requestId: 'req-logged',
251+
eventId: 'event-logged',
252+
})
253+
);
254+
// ...and the fan-out records the channel being queued for retry.
255+
expect(logger.warn).toHaveBeenCalledWith(
256+
'Channel delivery failed, adding to retry queue',
257+
expect.objectContaining({ channel: 'ops', eventId: 'event-logged' })
258+
);
259+
});
260+
261+
it('logs a meaningful error when a channel throws a network error', async () => {
262+
router.setHandler(channels[2].config.webhookUrl, () => {
263+
throw new Error('ECONNRESET');
264+
});
265+
266+
const results = await deliverToAllChannels(createMockEvent({ id: 'event-network' }), 'req-net');
267+
268+
expect(results.audit).toBe(false);
269+
expect(logger.error).toHaveBeenCalledWith(
270+
'Error sending Discord notification',
271+
expect.objectContaining({ webhookId: 'audit', eventId: 'event-network' })
272+
);
273+
});
274+
});
275+
276+
describe('retry behaviour', () => {
277+
beforeEach(() => {
278+
jest.useFakeTimers();
279+
channels.forEach((channel) => channel.retryQueue.start());
280+
});
281+
282+
afterEach(() => {
283+
jest.useRealTimers();
284+
});
285+
286+
const flush = async () => {
287+
for (let i = 0; i < 5; i++) await Promise.resolve();
288+
};
289+
290+
it('retries only the failed channel and recovers without touching healthy ones', async () => {
291+
// 'ops' fails its first (immediate) attempt, then succeeds on retry.
292+
router.setHandler(channels[1].config.webhookUrl, (attempt) =>
293+
attempt === 0 ? failedFetch(503, 'Service Unavailable') : { ok: true, status: 204 }
294+
);
295+
296+
const results = await deliverToAllChannels(createMockEvent({ id: 'event-recover' }), 'req-recover');
297+
expect(results).toEqual({ alerts: true, ops: false, audit: true });
298+
expect(channels[1].retryQueue.size()).toBe(1);
299+
300+
// Advance past the base delay so the retry queue re-attempts 'ops'.
301+
jest.advanceTimersByTime(150);
302+
await flush();
303+
304+
expect(router.callsTo(channels[1].config.webhookUrl)).toBe(2);
305+
expect(channels[1].retryQueue.size()).toBe(0);
306+
// Healthy channels were delivered exactly once and never retried.
307+
expect(router.callsTo(channels[0].config.webhookUrl)).toBe(1);
308+
expect(router.callsTo(channels[2].config.webhookUrl)).toBe(1);
309+
310+
expect(logger.info).toHaveBeenCalledWith(
311+
'Retry succeeded',
312+
expect.objectContaining({ eventId: 'event-recover', attempt: 1 })
313+
);
314+
});
315+
316+
it('escalates to a permanent-failure log after exhausting retries on a channel', async () => {
317+
// 'audit' never recovers.
318+
router.setHandler(channels[2].config.webhookUrl, () => failedFetch(500, 'Internal Server Error'));
319+
320+
await deliverToAllChannels(createMockEvent({ id: 'event-dead' }), 'req-dead');
321+
expect(channels[2].retryQueue.size()).toBe(1);
322+
323+
// Drive the exponential backoff schedule: 100ms, +200ms, +400ms.
324+
jest.advanceTimersByTime(100);
325+
await flush();
326+
jest.advanceTimersByTime(200);
327+
await flush();
328+
jest.advanceTimersByTime(400);
329+
await flush();
330+
331+
// 1 immediate + 3 retries (maxRetries = 3).
332+
expect(router.callsTo(channels[2].config.webhookUrl)).toBe(4);
333+
expect(channels[2].retryQueue.size()).toBe(0);
334+
335+
expect(logger.error).toHaveBeenCalledWith(
336+
'Notification permanently failed after max retries',
337+
expect.objectContaining({ eventId: 'event-dead', totalAttempts: 3 })
338+
);
339+
});
340+
341+
it('keeps retry queues independent per channel', async () => {
342+
router.setHandler(channels[0].config.webhookUrl, (attempt) =>
343+
attempt === 0 ? failedFetch(503, 'Service Unavailable') : { ok: true, status: 204 }
344+
);
345+
router.setHandler(channels[1].config.webhookUrl, () => failedFetch(500, 'Internal Server Error'));
346+
347+
await deliverToAllChannels(createMockEvent({ id: 'event-independent' }), 'req-ind');
348+
349+
expect(channels[0].retryQueue.size()).toBe(1); // alerts: will recover
350+
expect(channels[1].retryQueue.size()).toBe(1); // ops: keeps failing
351+
expect(channels[2].retryQueue.size()).toBe(0); // audit: delivered first time
352+
353+
jest.advanceTimersByTime(150);
354+
await flush();
355+
356+
// alerts recovered and drained; ops still has a pending retry scheduled.
357+
expect(channels[0].retryQueue.size()).toBe(0);
358+
expect(channels[1].retryQueue.size()).toBe(1);
359+
});
360+
});
361+
});

0 commit comments

Comments
 (0)