import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { WebContext } from '../../src/web/context.js'; import { createPushNotificationsRouter } from '../../src/web/routes/push-notifications.js'; import { ConfigSchema, type Config } from '../../src/config/index.js'; import type { PushDeliveryResult } from '../../src/services/push-notifier.js'; import { getTestConfig } from '../testUtils.js'; const servers: Array> = []; async function request(app: express.Express, path: string, init?: RequestInit): Promise { const server = app.listen(0); servers.push(server); await new Promise((resolve) => server.once('listening', resolve)); const { port } = server.address() as AddressInfo; return fetch(`http://127.0.0.1:${port}${path}`, init); } afterEach(async () => { await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); }); interface HarnessOptions { authToken?: string; sendTest?: () => Promise; withNotifier?: boolean; } function harness(options: HarnessOptions = {}) { // Deep-cloned because zod reuses one default object across parses, so two // harnesses would otherwise share the same `pushNotifications` instance. const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config; if (options.authToken) config.pushNotifications.authToken = options.authToken; const saved: Config[] = []; const sendTest = vi.fn(options.sendTest ?? (() => Promise.resolve({ delivered: true }))); const ctx = { config, runtimeConfig: config, services: { configStore: { save: (next: Config) => saved.push(next) }, ...(options.withNotifier === false ? {} : { pushNotifier: { sendTest } }), }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() }, } as unknown as WebContext; const app = express(); app.use(express.json()); app.use('/api', createPushNotificationsRouter(ctx)); return { app, config, saved, sendTest }; } function update(app: express.Express, body: unknown): Promise { return request(app, '/api/push-notifications', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); } describe('push notification settings route', () => { it('returns the settings with the image event on by default', async () => { const { app } = harness(); const body = await (await request(app, '/api/push-notifications')).json(); expect(body).toEqual({ enabled: false, serverUrl: 'https://ntfy.sh', topic: '', priority: 'default', events: { imageReceived: true, replyReceived: false, messageFailed: false, botStopped: false, rateLimited: false, sessionExpired: true, syncFailed: false, }, hasAuthToken: false, }); }); it('never returns the access token, only whether one is configured', async () => { const { app } = harness({ authToken: 'tk_secret' }); const response = await request(app, '/api/push-notifications'); const text = await response.text(); expect(text).not.toContain('tk_secret'); expect(JSON.parse(text).hasAuthToken).toBe(true); }); it('persists an edited selection in place, so the running notifier sees it', async () => { const { app, config, saved } = harness(); const settingsRef = config.pushNotifications; const eventsRef = config.pushNotifications.events; const response = await update(app, { enabled: true, serverUrl: 'https://ntfy.example.com', topic: 'redditbot-7f3a9c21', priority: 'high', events: { imageReceived: true, replyReceived: true }, }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ enabled: true, serverUrl: 'https://ntfy.example.com', topic: 'redditbot-7f3a9c21', priority: 'high', }); // Same object references: the notifier holds this config, not a copy. expect(config.pushNotifications).toBe(settingsRef); expect(config.pushNotifications.events).toBe(eventsRef); expect(config.pushNotifications.events.replyReceived).toBe(true); // A partial events payload leaves the unmentioned selections alone. expect(config.pushNotifications.events.sessionExpired).toBe(true); expect(saved).toHaveLength(1); }); it('rejects an invalid server URL, topic, or unknown field', async () => { const { app, saved } = harness(); expect((await update(app, { serverUrl: 'ftp://ntfy.example.com' })).status).toBe(400); expect((await update(app, { topic: 'not a topic!' })).status).toBe(400); expect((await update(app, { webhook: 'https://example.com' })).status).toBe(400); expect(saved).toHaveLength(0); }); it('refuses to enable notifications without a topic', async () => { const { app, config, saved } = harness(); const response = await update(app, { enabled: true }); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/topic is required/i); expect(config.pushNotifications.enabled).toBe(false); expect(saved).toHaveLength(0); }); it('enables notifications when the topic arrives in the same save', async () => { const { app, config } = harness(); const response = await update(app, { enabled: true, topic: 'redditbot-7f3a9c21' }); expect(response.status).toBe(200); expect(config.pushNotifications.enabled).toBe(true); }); it('reports the outcome of a test notification', async () => { const { app, sendTest } = harness(); expect(await (await request(app, '/api/push-notifications/test', { method: 'POST' })).json()) .toEqual({ delivered: true }); expect(sendTest).toHaveBeenCalledTimes(1); const failing = harness({ sendTest: () => Promise.resolve({ delivered: false, reason: 'ntfy responded 403 Forbidden' }) }); expect(await (await request(failing.app, '/api/push-notifications/test', { method: 'POST' })).json()) .toEqual({ delivered: false, reason: 'ntfy responded 403 Forbidden' }); }); it('reports unavailable rather than crashing when the notifier is missing', async () => { const { app } = harness({ withNotifier: false }); expect((await request(app, '/api/push-notifications/test', { method: 'POST' })).status).toBe(503); }); });