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 { 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( 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; 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; 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).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).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).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).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); }); });