Files
TREK/server/tests/unit/nest/channel-registry.test.ts
T
Maurice ac96644508 refactor(server): fold the notifications domain out of services/
Four files taken apart rather than moved, because they only worked as a knot.

The mailer becomes its own leaf module. AuthService sends the password-reset
mail while NotificationsModule imports AuthModule for the demo gate in
NotificationsMcp - a mailer inside the notifications domain would have closed a
hard cycle that only a forwardRef could open again. Webhook and ntfy stay with
notifications; auth has no business importing them.

The channel registry stays a module singleton and does NOT become a provider.
PluginRuntimeService pushes its channel getter in at onModuleInit, and
notifications.bridge.ts builds its own NotificationsService for the scheduler
crons and the memories senders. A provider would hand the bridge a second, empty
registry - every plugin channel would then go quiet with no error anywhere.
CHOVR-015 drives that seam end to end: a channel published through the runtime,
delivered through the bridge. Nothing pinned it before; the existing cases all
set the source themselves.

The built-ins no longer self-register by side-effect import. They used to exist
only because notificationPreferencesService happened to `import
'./notifications/builtins'`; removing that line would have silenced email,
webhook and ntfy without a failure. They are built from the injected transports
and registered from NotificationsService's constructor, so both instances have
them.

No cached nodemailer transport: MailerService.createTransport re-reads the SMTP
config and skipTlsVerify per send, exactly as the three inline call sites did,
so an admin changing them does not have to restart.

Preferences become a provider over DatabaseService. Eight places built
NotificationsService by hand, so a tests/helpers/notifications.ts factory keeps
the next constructor parameter a one-file diff instead of an eight-file one.
2026-08-08 12:09:56 +02:00

128 lines
5.2 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,
type ChannelMessage,
type ExternalChannel,
} from '../../../src/nest/notifications/channel-registry';
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);
});
});