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>
240 lines
9.5 KiB
TypeScript
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);
|
|
});
|
|
});
|