mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(notifications): add ntfy as a first-class notification channel
Adds ntfy.sh (and self-hosted instances) as a new push notification channel with full parity to the existing webhook channel. - Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig, resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/ Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per event), SSRF guard via existing checkSsrf + createPinnedDispatcher - notificationPreferencesService: ntfy added to NotifChannel union, IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels, ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface - notificationService: per-user ntfy dispatch after webhook block; admin-scoped ntfy via getAdminGlobalPref for version_available events - Routes: POST /api/notifications/test-ntfy with saved-token fallback - authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS, masked + encrypted on read/write - settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS - Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method - i18n: full English strings; English placeholders in 14 other locales - Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests, MSW handler for test-ntfy endpoint
This commit is contained in:
@@ -458,3 +458,72 @@ describe('send() — channel failure resilience', () => {
|
||||
expect(countAllNotifications()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ntfy dispatch ─────────────────────────────────────────────────────────────
|
||||
|
||||
function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void {
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic);
|
||||
}
|
||||
|
||||
function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void {
|
||||
setAppSetting(testDb, 'admin_ntfy_topic', topic);
|
||||
}
|
||||
|
||||
describe('send() — ntfy channel dispatch', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true, text: async () => '' });
|
||||
});
|
||||
|
||||
it('NTFY-SVCB-001 — ntfy fires when channel active and user has topic configured', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserNtfyTopic(user.id);
|
||||
setNotificationChannels(testDb, 'ntfy');
|
||||
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number;
|
||||
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
|
||||
|
||||
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
|
||||
expect(ntfyCalls.length).toBeGreaterThan(0);
|
||||
// Header-based API: metadata in headers, body = plain text
|
||||
expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // trip_invite = high priority
|
||||
expect(ntfyCalls[0][1].headers['Tags']).toContain('loudspeaker');
|
||||
});
|
||||
|
||||
it('NTFY-SVCB-002 — ntfy skips when channel not in active channels', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setUserNtfyTopic(user.id);
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
fetchMock.mockClear();
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
|
||||
|
||||
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
|
||||
expect(ntfyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
setNotificationChannels(testDb, 'ntfy');
|
||||
// No ntfy_topic set, but no admin_ntfy_server either — resolveNtfyUrl returns null
|
||||
|
||||
fetchMock.mockClear();
|
||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
|
||||
|
||||
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
|
||||
expect(ntfyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('NTFY-SVCB-004 — admin-scoped version_available fires admin ntfy topic', async () => {
|
||||
createAdmin(testDb);
|
||||
setAdminNtfyTopic();
|
||||
setNotificationChannels(testDb, 'none');
|
||||
|
||||
fetchMock.mockClear();
|
||||
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } });
|
||||
|
||||
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
|
||||
expect(ntfyCalls.length).toBeGreaterThan(0);
|
||||
expect(ntfyCalls[0][1].headers['Priority']).toBe('4'); // version_available = high priority
|
||||
expect(ntfyCalls[0][1].headers['Tags']).toContain('package');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user