Files
RedditBot/tests/push-notifier.test.ts
Jamie BruntonandClaude Opus 5 38d175e4a1 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>
2026-08-10 13:55:00 +01:00

240 lines
9.5 KiB
TypeScript

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);
});
});