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:
1 parent
827eb3a1e0
commit
38d175e4a1
34 files changed
+1728
-8
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user