feat: push selected events to a phone over ntfy

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>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 13:55:00 +01:00
1 parent 827eb3a1e0
commit 38d175e4a1
34 files changed
+1728 -8

No files matched your search

@@ -0,0 +1,140 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js';
import { ChatService } from '../src/services/chat/service.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
const credentials = {
homeserver: 'https://matrix.example.test',
accessToken: 'token',
userId: '@me:example.test',
capturedAt: Date.now(),
};
const temporaryDirectories: string[] = [];
const openDatabases: Array<ReturnType<typeof createTestDb>> = [];
afterEach(async () => {
for (const database of openDatabases.splice(0)) database.close();
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
fs.rm(directory, { recursive: true, force: true })));
});
describe('ChatService inbound media notifications', () => {
it('reports an inbound image exactly once, however often the chat re-syncs', async () => {
const fixture = await createFixture({ inboundMediaCount: 2 });
const received: unknown[] = [];
fixture.service.setMediaReceivedListener((info) => received.push(info));
await fixture.service.runStartupSync();
expect(received).toEqual([{ conversationId: fixture.roomId, username: 'alice', count: 2 }]);
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
await fixture.service.runStartupSync();
expect(received).toHaveLength(1);
});
it('stays quiet for a text-only conversation', async () => {
const fixture = await createFixture({ inboundMediaCount: 0 });
const received: unknown[] = [];
fixture.service.setMediaReceivedListener((info) => received.push(info));
await fixture.service.runStartupSync();
expect(received).toEqual([]);
});
it('records the image even when the listener throws', async () => {
const fixture = await createFixture({ inboundMediaCount: 1 });
fixture.service.setMediaReceivedListener(() => { throw new Error('notifier exploded'); });
await fixture.service.runStartupSync();
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
});
});
async function createFixture(options: { inboundMediaCount: number }) {
const logger = createMockLogger();
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-notify-'));
temporaryDirectories.push(directory);
const cacheFile = path.join(directory, 'chat-cache.json');
const db = createTestDb();
openDatabases.push(db);
const repos = createTestRepositories(db);
const outcomes = new ConversationOutcomesRepository(db);
const conversationStates = new ConversationStateRepository(db);
const now = Date.now();
const roomId = '!alice:example.test';
repos.postsRepo.insert({
id: 'source-post', subreddit: 'example', author: 'alice', title: 'Source',
imageUrl: null, processedAt: now - 10_000,
});
repos.messagesRepo.insert({
postId: 'source-post', recipient: 'alice', subject: 'alice', message: 'Hello',
sentAt: now - 9_000, strategyId: 'direct', chatRoomId: roomId,
});
const media = Array.from({ length: options.inboundMediaCount }, (_unused, index) => ({
originalUrl: `mxc://example.test/image-${index}`,
localPath: `/data/media/image-${index}.jpg`,
fileName: `image-${index}.jpg`,
}));
await fs.writeFile(cacheFile, JSON.stringify({
version: 1,
savedAt: now - 60_000,
chats: [{
username: 'alice',
roomName: 'alice',
lastMessageTime: now - 2_000,
lastMessageSnippet: '[Image]',
isFromUs: false,
unread: false,
roomId,
messages: [
{ sender: 'Me', text: 'Hello', timestamp: now - 5_000, isSelf: true },
{
sender: 'alice',
text: media.length > 0 ? '[Image]' : 'Thanks!',
timestamp: now - 2_000,
isSelf: false,
...(media.length > 0 ? { media } : {}),
},
],
}],
nextBatch: 's1',
lastFullSyncAt: now - 120_000,
lastDeltaSyncAt: now - 60_000,
cooldown: null,
}), 'utf8');
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async (): Promise<MatrixSyncResponse> => ({ next_batch: 's2' })) as typeof matrix.sync;
const cache = new ChatCache(logger, cacheFile);
const service = new ChatService(
matrix,
logger,
repos.successfulMessagesRepo,
undefined,
undefined,
undefined,
cache,
repos.messagesRepo,
undefined,
authenticatedAccountAuth,
outcomes,
conversationStates,
);
await service.loadCache();
return { repos, roomId, service };
}
+23
View File
@@ -44,6 +44,7 @@ describe('ConfigStore', () => {
config.web.authToken = 'also-secret';
config.web.agentReadToken = 'read-agent-secret';
config.web.agentControlToken = 'control-agent-secret';
config.pushNotifications.authToken = 'ntfy-secret';
store.save(config);
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
@@ -51,9 +52,31 @@ describe('ConfigStore', () => {
expect(row.config_json).not.toContain('also-secret');
expect(row.config_json).not.toContain('read-agent-secret');
expect(row.config_json).not.toContain('control-agent-secret');
expect(row.config_json).not.toContain('ntfy-secret');
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
});
it('takes the ntfy access token from the environment on every load', () => {
const config = getTestConfig();
config.pushNotifications.topic = 'redditbot-7f3a9c21';
config.pushNotifications.authToken = 'saved-token-must-be-ignored';
store.save(config);
const withToken = store.load({
OPENROUTER_API_KEY: 'environment-key',
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
NTFY_AUTH_TOKEN: 'environment-ntfy-token',
});
const withoutToken = store.load({
OPENROUTER_API_KEY: 'environment-key',
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
});
expect(withToken?.pushNotifications.topic).toBe('redditbot-7f3a9c21');
expect(withToken?.pushNotifications.authToken).toBe('environment-ntfy-token');
expect(withoutToken?.pushNotifications.authToken).toBeUndefined();
});
it('updates the singleton record instead of appending versions', () => {
store.save(getTestConfig());
store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
@@ -0,0 +1,174 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
};
function pushSettings(overrides: Record<string, unknown> = {}) {
return {
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,
...overrides,
};
}
let settings = pushSettings();
let saved: unknown[] = [];
const server = setupServer(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/accounts', () => HttpResponse.json([])),
http.get('/api/push-notifications', () => HttpResponse.json(settings)),
http.put('/api/push-notifications', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
saved.push(body);
settings = pushSettings({ ...body, events: { ...settings.events, ...(body.events as object) } });
return HttpResponse.json(settings);
}),
);
function renderSettings() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter([{ path: '/settings', element: <SettingsRoute /> }], {
initialEntries: ['/settings?section=notifications'],
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
function panel() {
return screen.getByRole('region', { name: 'Notifications' });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
settings = pushSettings();
saved = [];
});
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('push notification settings', () => {
it('shows the notifications section with every selectable event', async () => {
renderSettings();
const section = await screen.findByRole('region', { name: 'Notifications' });
expect(within(section).getByLabelText(/Server URL/)).toHaveValue('https://ntfy.sh');
expect(within(section).getByRole('group', { name: /Events to notify me about \(2 of 7\)/ })).toBeInTheDocument();
expect(within(section).getByRole('checkbox', { name: /Image received/ })).toBeChecked();
expect(within(section).getByRole('checkbox', { name: /Reply received/ })).not.toBeChecked();
expect(within(section).getByText(/No access token is configured/)).toBeInTheDocument();
});
it('saves the topic and event selection the operator picked', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
await user.type(within(panel()).getByLabelText(/Topic/), 'redditbot-7f3a9c21');
await user.click(within(panel()).getByRole('checkbox', { name: /Reply received/ }));
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toMatchObject({
enabled: true,
topic: 'redditbot-7f3a9c21',
events: { imageReceived: true, replyReceived: true },
});
expect(await within(panel()).findByText('Notification settings saved.')).toBeInTheDocument();
});
it('refuses to save an enabled configuration with no topic', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
expect(within(panel()).getByText(/A topic is required/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('rejects a topic containing characters ntfy will not accept', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.type(within(panel()).getByLabelText(/Topic/), 'not a topic!');
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
expect(within(panel()).getByText(/only contain letters, numbers/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('sends a test notification and reports the delivery outcome', async () => {
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
server.use(http.post('/api/push-notifications/test', () => HttpResponse.json({
delivered: false,
reason: 'ntfy responded 403 Forbidden',
})));
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('button', { name: /Send test notification/ }));
expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument();
});
it('will not send a test until unsaved changes are saved', async () => {
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ }));
expect(within(panel()).getByRole('button', { name: /Send test notification/ })).toBeDisabled();
});
it('reports that an access token is configured without revealing it', async () => {
settings = pushSettings({ hasAuthToken: true });
renderSettings();
const section = await screen.findByRole('region', { name: 'Notifications' });
expect(within(section).getByText(/An access token is configured/)).toBeInTheDocument();
});
});
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import {
PushNotifier,
pushNotificationForEvent,
type PushNotificationSettings,
} from '../src/services/push-notifier.js';
import type { RealtimeEvent, RealtimeEventMap, RealtimeEventType } from '../src/web/events/event-types.js';
import { createMockLogger, getLogsByLevel } from './mockLogger.js';
interface RecordedRequest {
url: string;
init: RequestInit;
}
function settings(overrides: Partial<PushNotificationSettings> = {}): PushNotificationSettings {
return {
enabled: true,
serverUrl: 'https://ntfy.example.com',
topic: 'redditbot-test',
priority: 'default',
events: {
imageReceived: true,
replyReceived: true,
messageFailed: true,
botStopped: true,
rateLimited: true,
sessionExpired: true,
syncFailed: true,
},
...overrides,
};
}
function event<TType extends RealtimeEventType>(
type: TType,
payload: RealtimeEventMap[TType],
occurredAt = '2026-08-10T12:00:00.000Z',
): RealtimeEvent {
return { id: `${type}-1`, occurredAt, type, payload } as RealtimeEvent;
}
class FakeHub {
listener: ((event: RealtimeEvent) => void) | null = null;
unsubscribed = false;
subscribe(listener: (event: RealtimeEvent) => void) {
this.listener = listener;
return { unsubscribe: () => { this.unsubscribed = true; this.listener = null; } };
}
emit(value: RealtimeEvent): void {
this.listener?.(value);
}
}
describe('pushNotificationForEvent', () => {
it('maps an inbound image onto the imageReceived notification', () => {
expect(pushNotificationForEvent(event('media.received', {
conversationId: 'room-1',
sender: 'alice',
count: 2,
}))).toMatchObject({
event: 'imageReceived',
title: 'Image from u/alice',
body: '2 images are attached to the conversation.',
});
});
it('only notifies for the actionable side of two-way status events', () => {
expect(pushNotificationForEvent(event('bot.status.changed', { status: 'running', dryRun: false }))).toBeNull();
expect(pushNotificationForEvent(event('bot.status.changed', { status: 'stopped', dryRun: false })))
.toMatchObject({ event: 'botStopped' });
expect(pushNotificationForEvent(event('rate-limit.changed', { limited: false }))).toBeNull();
expect(pushNotificationForEvent(event('rate-limit.changed', { limited: true })))
.toMatchObject({ event: 'rateLimited' });
expect(pushNotificationForEvent(event('reddit.session.changed', { status: 'authenticated' }))).toBeNull();
expect(pushNotificationForEvent(event('reddit.session.changed', { status: 'expired' })))
.toMatchObject({ event: 'sessionExpired', priority: 'high' });
});
it('ignores events that are not worth a phone notification', () => {
expect(pushNotificationForEvent(event('log.created', { level: 'error', message: 'boom' }))).toBeNull();
expect(pushNotificationForEvent(event('sync.progress', { syncId: 's1', kind: 'full', completed: 3 }))).toBeNull();
expect(pushNotificationForEvent(event('message.sent', { messageId: 'm1', recipient: 'alice' }))).toBeNull();
});
});
describe('PushNotifier', () => {
let requests: RecordedRequest[];
let response: { ok: boolean; status: number; statusText: string };
let fetcher: (url: string, init: RequestInit) => Promise<typeof response>;
beforeEach(() => {
requests = [];
response = { ok: true, status: 200, statusText: 'OK' };
fetcher = (url, init) => {
requests.push({ url, init });
return Promise.resolve(response);
};
});
it('posts the notification to the configured topic', async () => {
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher);
const result = await notifier.publish({
event: 'imageReceived',
title: 'Image from u/alice',
body: 'An image is attached.',
tags: ['camera'],
});
expect(result).toEqual({ delivered: true });
expect(requests).toHaveLength(1);
expect(requests[0].url).toBe('https://ntfy.example.com/redditbot-test');
expect(requests[0].init.method).toBe('POST');
expect(requests[0].init.body).toBe('An image is attached.');
const headers = requests[0].init.headers as Record<string, string>;
expect(headers.Title).toBe('Image from u/alice');
expect(headers.Priority).toBe('default');
expect(headers.Tags).toBe('camera');
expect(headers.Authorization).toBeUndefined();
});
it('tolerates a trailing slash on the server URL and sends the access token when set', async () => {
const notifier = new PushNotifier(
() => settings({ serverUrl: 'https://ntfy.example.com/', authToken: 'tk_secret' }),
createMockLogger(),
fetcher,
);
await notifier.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] });
expect(requests[0].url).toBe('https://ntfy.example.com/redditbot-test');
expect((requests[0].init.headers as Record<string, string>).Authorization).toBe('Bearer tk_secret');
});
it('sends nothing when disabled, unselected, or missing a topic', async () => {
const disabled = new PushNotifier(() => settings({ enabled: false }), createMockLogger(), fetcher);
const unselected = new PushNotifier(
() => settings({ events: { ...settings().events, imageReceived: false } }),
createMockLogger(),
fetcher,
);
const topicless = new PushNotifier(() => settings({ topic: ' ' }), createMockLogger(), fetcher);
const draft = { event: 'imageReceived', title: 'Hi', body: 'There', tags: [] } as const;
expect(await disabled.publish({ ...draft })).toMatchObject({ delivered: false });
expect(await unselected.publish({ ...draft })).toMatchObject({ delivered: false });
expect(await topicless.publish({ ...draft })).toMatchObject({ delivered: false });
expect(requests).toHaveLength(0);
});
it('reports a rejected or unreachable server without throwing', async () => {
const logger = createMockLogger();
response = { ok: false, status: 403, statusText: 'Forbidden' };
const rejecting = new PushNotifier(() => settings(), logger, fetcher);
expect(await rejecting.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] }))
.toEqual({ delivered: false, reason: 'ntfy responded 403 Forbidden' });
const unreachable = new PushNotifier(() => settings(), logger, () => Promise.reject(new Error('ECONNREFUSED')));
expect(await unreachable.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] }))
.toEqual({ delivered: false, reason: 'ECONNREFUSED' });
expect(getLogsByLevel(logger, 'warn')).toHaveLength(2);
});
it('strips newlines from the title so the header cannot be split', async () => {
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher);
await notifier.publish({
event: 'replyReceived',
title: 'Reply from u/alice\r\nX-Injected: yes',
body: 'hello',
tags: [],
});
expect((requests[0].init.headers as Record<string, string>).Title)
.toBe('Reply from u/alice X-Injected: yes');
});
it('sends selected hub events and skips the ones the operator turned off', async () => {
const hub = new FakeHub();
const notifier = new PushNotifier(
() => settings({ events: { ...settings().events, replyReceived: false } }),
createMockLogger(),
fetcher,
() => Date.parse('2026-08-10T11:00:00.000Z'),
);
notifier.attach(hub);
hub.emit(event('media.received', { conversationId: 'room-1', sender: 'alice', count: 1 }));
hub.emit(event('reply.received', { conversationId: 'room-1', replyId: 'r1', sender: 'alice' }));
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect((requests[0].init.headers as Record<string, string>).Title).toBe('Image from u/alice');
});
it('skips buffered events the hub replays from before it attached', async () => {
const hub = new FakeHub();
const notifier = new PushNotifier(
() => settings(),
createMockLogger(),
fetcher,
() => Date.parse('2026-08-10T12:00:00.000Z'),
);
notifier.attach(hub);
hub.emit(event('media.received', { conversationId: 'old', sender: 'alice', count: 1 }, '2026-08-10T11:59:59.000Z'));
hub.emit(event('media.received', { conversationId: 'new', sender: 'bob', count: 1 }, '2026-08-10T12:00:01.000Z'));
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect((requests[0].init.headers as Record<string, string>).Title).toBe('Image from u/bob');
});
it('sends a test notification even when no event is selected, but not when disabled', async () => {
const noEvents = Object.fromEntries(
Object.keys(settings().events).map((key) => [key, false]),
) as PushNotificationSettings['events'];
const notifier = new PushNotifier(() => settings({ events: noEvents }), createMockLogger(), fetcher);
expect(await notifier.sendTest()).toEqual({ delivered: true });
expect(requests).toHaveLength(1);
const disabled = new PushNotifier(() => settings({ enabled: false }), createMockLogger(), fetcher);
expect(await disabled.sendTest()).toMatchObject({ delivered: false });
expect(requests).toHaveLength(1);
});
it('stops delivering once detached', async () => {
const hub = new FakeHub();
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher, () => 0);
notifier.attach(hub);
notifier.detach();
expect(hub.unsubscribed).toBe(true);
hub.emit(event('media.received', { conversationId: 'room-1', sender: 'alice', count: 1 }));
await Promise.resolve();
expect(requests).toHaveLength(0);
});
});
+16
View File
@@ -93,6 +93,22 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
variants: [],
...overrides?.chatStrategies,
},
pushNotifications: {
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
...overrides?.pushNotifications,
},
web: {
enabled: true,
port: 3000,
+170
View File
@@ -0,0 +1,170 @@
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);
});
});