mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
ac96644508
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.
128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|