Adds a Notifications tab to Settings so an operator can pick which events reach their phone, published to a single self-hosted ntfy topic. The notifier subscribes to the realtime event hub the dashboard already consumes, so every notifiable event keeps exactly one publisher. Inbound media had no event, so `media.received` is added and published from the existing durable `gotImage` gate in the chat service — one notification per conversation, never repeated when the room re-syncs. It feeds the in-app notification bell as well. - Best-effort delivery: a rejecting or unreachable ntfy server is logged and never propagated into the path that produced the event. - Events buffered before the notifier attaches are skipped, so a restart cannot replay a backlog to somebody's phone. - Settings are applied field by field onto the live config object, so a save takes effect without a restart. - The access token is environment-owned (NTFY_AUTH_TOKEN) like the other secrets: stripped before the settings record is written, never returned by the API, which reports only `hasAuthToken`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
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<ReturnType<express.Express['listen']>> = [];
|
|
|
|
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
|
|
const server = app.listen(0);
|
|
servers.push(server);
|
|
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()))));
|
|
});
|
|
|
|
interface HarnessOptions {
|
|
authToken?: string;
|
|
sendTest?: () => Promise<PushDeliveryResult>;
|
|
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<Response> {
|
|
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);
|
|
});
|
|
});
|