Files
TREK/server/tests/unit/nest/channel-registry.test.ts
T
Maurice 7078ac7a4d Keep the mobile FX widget off the network after unmount
Client CI was red on this branch while dev was green, and no client file had
changed. The 536 test files all passed; the job failed on three unhandled
rejections, all `window is not defined` from MDashWidgets, attributed to
whichever case happened to be running when they landed.

The widget fetches api.frankfurter.dev directly and sets state from both
handlers with no unmount guard. MSW runs with onUnhandledRequest: 'warn', so
that request was never intercepted: it left the runner, came back after the
environment was gone, and React tried to schedule an update against a torn
down tree. Load dependent by construction, which is why dev happened to pass.

Two halves, because either alone leaves the other broken. The widget now
aborts in flight work on unmount and on refetch, and treats an abort as "no
one is waiting" rather than as a failure to report. That matters outside the
runner too: this is a third party call on a mobile screen a user can leave
immediately.

The handler set gains the third party endpoints the app calls directly rather
than through /api, so no suite reaches the real host. An empty rate list is
the shape the widget already handles.

Also clears twenty more test files, each at its root cause. Two more were
passing for the wrong reason: oidc.service.test.ts built AuthService with an
AtlasService in the membership slot, undefined for userCleanup and mailer,
and the mailer stub in the tokens slot, the same shift admin.service.test.ts
had. And CAL-010 claims to serialise a flight byte for byte, but
createReservation ignores reservation_time, so the row is written with a null
time and CalendarService drops it: the recorded snapshot is truthful about
the data the case builds and silent about the flight it advertises.
2026-08-10 19:54:46 +02:00

128 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the external notification channel registry.
* Covers CHREG-001 to CHREG-008.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
listChannels,
getChannel,
registerChannel,
setPluginChannelSource,
pluginChannelId,
isPluginChannelId,
__resetChannelsForTest,
} from '../../../src/nest/notifications/channel-registry';
// The channel contract lives in notification-events; channel-registry only consumes it.
import type { ChannelMessage, ExternalChannel } from '../../../src/nest/notifications/notification-events';
import { registerBuiltinChannels } from '../../../src/nest/notifications/channels/builtins';
import type { MailerService } from '../../../src/nest/notifications/mailer/mailer.service';
import type { NtfyService } from '../../../src/nest/notifications/transports/ntfy.service';
import type { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
// The built-ins take their transports as an argument now; these cases only care
// that the three ids land in the registry with the right privileges, so the
// transports are stubs.
const stubTransports = {
mailer: { isSmtpConfigured: () => true, getUserEmail: () => null } as unknown as MailerService,
webhook: { getUserWebhookUrl: () => null, getAdminWebhookUrl: () => null } as unknown as WebhookService,
ntfy: { getUserNtfyConfig: () => null, getAdminNtfyConfig: () => ({ server: null, topic: null, token: null }) } as unknown as NtfyService,
};
function fakeChannel(id: string, over: Partial<ExternalChannel> = {}): ExternalChannel {
return {
id,
source: 'plugin',
label: id,
supportsEvent: () => true,
isConfiguredFor: () => true,
sendToUser: async () => true,
...over,
};
}
const MSG: ChannelMessage = { event: 'trip_invite', title: 't', body: 'b' };
beforeEach(() => {
__resetChannelsForTest();
registerBuiltinChannels(stubTransports);
});
afterEach(() => {
setPluginChannelSource(null);
});
describe('channelRegistry', () => {
it('CHREG-001 — the three built-in external channels are registered; in-app is not', () => {
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
expect(getChannel('inapp')).toBeUndefined();
});
it('CHREG-002 — plugin channels come from the injected source and are namespaced', () => {
setPluginChannelSource(() => [fakeChannel(pluginChannelId('gotify'))]);
expect(listChannels().map(c => c.id)).toContain('plugin:gotify');
expect(getChannel('plugin:gotify')?.source).toBe('plugin');
expect(isPluginChannelId('plugin:gotify')).toBe(true);
expect(isPluginChannelId('email')).toBe(false);
});
it('CHREG-003 — a plugin channel disappears when the runtime stops reporting it', () => {
let live = true;
setPluginChannelSource(() => (live ? [fakeChannel('plugin:gotify')] : []));
expect(getChannel('plugin:gotify')).toBeDefined();
live = false;
expect(getChannel('plugin:gotify')).toBeUndefined();
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
});
it('CHREG-004 — a throwing plugin source cannot take notifications down', () => {
setPluginChannelSource(() => {
throw new Error('runtime exploded');
});
expect(listChannels().map(c => c.id)).toEqual(['email', 'webhook', 'ntfy']);
});
it('CHREG-005 — a plugin can never claim a built-in id', () => {
// The prefix is the whole defence: an id without it is not a plugin channel id,
// and pluginChannelId() is the only way the runtime mints one.
expect(pluginChannelId('email')).toBe('plugin:email');
expect(getChannel('email')?.source).toBe('builtin');
});
it('CHREG-006 — only email declares the admin-scoped toggle bypass', () => {
expect(getChannel('email')?.bypassesActiveToggleForAdminEvents).toBe(true);
expect(getChannel('webhook')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
expect(getChannel('ntfy')?.bypassesActiveToggleForAdminEvents).toBeUndefined();
});
it('CHREG-007 — webhook and ntfy deliver the admin-global copy; email does not', () => {
expect(getChannel('webhook')?.supportsAdminGlobal).toBe(true);
expect(getChannel('ntfy')?.supportsAdminGlobal).toBe(true);
expect(getChannel('email')?.supportsAdminGlobal).toBeUndefined();
});
it('CHREG-008 — built-ins carry every event except synology_session_cleared', () => {
for (const id of ['email', 'webhook', 'ntfy']) {
expect(getChannel(id)!.supportsEvent('trip_invite')).toBe(true);
expect(getChannel(id)!.supportsEvent('version_available')).toBe(true);
expect(getChannel(id)!.supportsEvent('synology_session_cleared')).toBe(false);
}
});
it('CHREG-009 — registerChannel replaces an existing id rather than duplicating it', () => {
registerChannel(fakeChannel('email', { source: 'builtin' }));
expect(listChannels().filter(c => c.id === 'email')).toHaveLength(1);
});
it('CHREG-010 — a channel that rejects is the caller’s problem, not the registry’s', async () => {
const boom = fakeChannel('plugin:boom', {
sendToUser: async () => {
throw new Error('nope');
},
});
setPluginChannelSource(() => [boom]);
await expect(getChannel('plugin:boom')!.sendToUser(1, MSG)).rejects.toThrow('nope');
// and the registry is unharmed
expect(listChannels()).toHaveLength(4);
});
});