Normalize notification events and update grab flow

Introduce a NotificationEventConfig interface and validate NOTIFICATION_EVENTS with `satisfies` for stronger typing and normalized metadata shape. Replace escaped emoji sequences with literal emoji, simplify helper functions (getEventMeta/getEventTitle) to use the typed registry, and clean up titleByRequestType typing.

In download-torrent.processor: include the requesting user when setting status to downloading to avoid an extra DB query, and use that returned user to enqueue a non-blocking `request_grabbed` notification.

Docs: note that `request_grabbed` notifications are opt-in for existing backends. Tests: add messageLabel rendering tests for Apprise and ntfy providers to validate emoji, label text, and type-specific titles.
This commit is contained in:
kikootwo
2026-05-14 15:57:15 -04:00
parent 4ded2cf219
commit 5e4a38a340
5 changed files with 157 additions and 52 deletions
+58
View File
@@ -458,6 +458,64 @@ describe('AppriseProvider', () => {
});
});
describe('messageLabel rendering by event', () => {
const basePayload = {
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
};
it('renders "⚠️ Error:" with error emoji for request_error', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
const { AppriseProvider } = await import('@/lib/services/notification');
const provider = new AppriseProvider();
await provider.send(
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
{ ...basePayload, event: 'request_error', message: 'Boom' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.body).toContain('⚠️ Error: Boom');
expect(body.body).not.toContain('📝');
});
it('renders "📝 Reason:" with note emoji for issue_reported', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
const { AppriseProvider } = await import('@/lib/services/notification');
const provider = new AppriseProvider();
await provider.send(
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
{ ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.body).toContain('📝 Reason: Chapter 3 cuts off');
expect(body.body).not.toContain('⚠️');
expect(body.body).not.toContain('Error:');
});
it('renders "📝 Details:" with note emoji for request_grabbed', async () => {
fetchMock.mockResolvedValue({ ok: true, text: async () => 'ok' });
const { AppriseProvider } = await import('@/lib/services/notification');
const provider = new AppriseProvider();
await provider.send(
{ serverUrl: 'http://apprise:8000', urls: 'slack://token' },
{ ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.body).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)');
expect(body.body).not.toContain('⚠️');
expect(body.body).not.toContain('Error:');
expect(body.title).toBe('Audiobook Grabbed');
});
});
describe('integration with NotificationService.sendToBackend', () => {
it('decrypts sensitive fields and sends to Apprise', async () => {
fetchMock.mockResolvedValue({
+58
View File
@@ -267,6 +267,64 @@ describe('NtfyProvider', () => {
});
});
describe('messageLabel rendering by event', () => {
const basePayload = {
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
};
it('renders "⚠️ Error:" with error emoji for request_error', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
const { NtfyProvider } = await import('@/lib/services/notification');
const provider = new NtfyProvider();
await provider.send(
{ topic: 'audiobooks' },
{ ...basePayload, event: 'request_error', message: 'Boom' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain('⚠️ Error: Boom');
expect(body.message).not.toContain('📝');
});
it('renders "📝 Reason:" with note emoji for issue_reported', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
const { NtfyProvider } = await import('@/lib/services/notification');
const provider = new NtfyProvider();
await provider.send(
{ topic: 'audiobooks' },
{ ...basePayload, event: 'issue_reported', issueId: 'iss-1', message: 'Chapter 3 cuts off' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain('📝 Reason: Chapter 3 cuts off');
expect(body.message).not.toContain('⚠️');
expect(body.message).not.toContain('Error:');
});
it('renders "📝 Details:" with note emoji for request_grabbed', async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ id: 'msg' }) });
const { NtfyProvider } = await import('@/lib/services/notification');
const provider = new NtfyProvider();
await provider.send(
{ topic: 'audiobooks' },
{ ...basePayload, event: 'request_grabbed', message: 'Test Book [M4B] via NZBGeek (SABnzbd)', requestType: 'audiobook' }
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.message).toContain('📝 Details: Test Book [M4B] via NZBGeek (SABnzbd)');
expect(body.message).not.toContain('⚠️');
expect(body.message).not.toContain('Error:');
expect(body.title).toBe('Audiobook Grabbed');
});
});
describe('integration with NotificationService.sendToBackend', () => {
it('decrypts accessToken and sends to ntfy', async () => {
fetchMock.mockResolvedValue({