// Copyright (c) 2026 Gordon Bolton. MIT License. /** * Integration tests for server/routes/sonarr.js, server/routes/radarr.js, * and server/routes/sabnzbd.js. * * Covers: * Sonarr: queue, history, series, series/:id, notifications CRUD, * notifications/test, notifications/schema, sofarr-webhook (create + update) * Radarr: same set, movies instead of series * SABnzbd: queue, history * * All routes require auth; state-changing requests (POST/PUT/DELETE) must also * carry the CSRF token issued by GET /api/auth/csrf (verifyCsrf middleware). */ import request from 'supertest'; import nock from 'nock'; import { createApp } from '../../server/app.js'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const EMBY_BASE = 'https://emby.test'; const SONARR_BASE = 'https://sonarr.test'; const RADARR_BASE = 'https://radarr.test'; const SABNZBD_BASE = 'https://sabnzbd.test'; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } }; const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } }; const SONARR_QUEUE = { records: [{ id: 1, title: 'Show.S01E01', seriesId: 10 }] }; const SONARR_HISTORY = { records: [{ id: 100, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01' }] }; const SONARR_SERIES_LIST = [{ id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }]; const SONARR_SERIES_ITEM = { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }; const SONARR_NOTIFICATIONS = [{ id: 5, name: 'Plex', implementation: 'Plex' }]; const SONARR_NOTIF_ITEM = { id: 5, name: 'Plex', implementation: 'Plex', fields: [] }; const RADARR_QUEUE = { records: [{ id: 2, title: 'Movie.2024.1080p', movieId: 20 }] }; const RADARR_HISTORY = { records: [{ id: 200, eventType: 'downloadFolderImported', sourceTitle: 'Movie.2024.1080p' }] }; const RADARR_MOVIES_LIST = [{ id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }]; const RADARR_MOVIE_ITEM = { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }; const RADARR_NOTIFICATIONS = [{ id: 7, name: 'Plex', implementation: 'Plex' }]; const RADARR_NOTIF_ITEM = { id: 7, name: 'Plex', implementation: 'Plex', fields: [] }; const SAB_QUEUE_RESP = { queue: { status: 'Downloading', slots: [{ filename: 'Show.S01E01', percentage: '50' }] } }; const SAB_HISTORY_RESP = { history: { slots: [{ name: 'Show.S01E01.Done', status: 'Completed' }] } }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function interceptLogin() { nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, EMBY_AUTH); nock(EMBY_BASE).get(/\/Users\//).reply(200, EMBY_USER); } async function loginAs(app) { interceptLogin(); const res = await request(app) .post('/api/auth/login') .send({ username: 'alice', password: 'pw' }); return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken }; } async function getSessionWithCsrf(app) { const { cookies, csrf } = await loginAs(app); // Obtain a fresh csrf cookie as well (login already sets one, but keep consistent) const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); return { cookies, csrf, csrfCookie }; } // Build the Cookie header for state-changing requests: session + csrf cookies function joinCookies(sessionCookies, csrfCookie) { const all = Array.isArray(sessionCookies) ? [...sessionCookies] : [sessionCookies]; if (csrfCookie && !all.includes(csrfCookie)) all.push(csrfCookie); return all.join('; '); } // --------------------------------------------------------------------------- // Environment // --------------------------------------------------------------------------- beforeAll(() => { process.env.EMBY_URL = EMBY_BASE; process.env.SONARR_URL = SONARR_BASE; process.env.SONARR_API_KEY = 'sk'; process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]); process.env.RADARR_URL = RADARR_BASE; process.env.RADARR_API_KEY = 'rk'; process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]); process.env.SABNZBD_URL = SABNZBD_BASE; process.env.SABNZBD_API_KEY = 'sabkey'; process.env.SOFARR_BASE_URL = 'https://sofarr.test'; process.env.SOFARR_WEBHOOK_SECRET = 'webhook-secret-abc'; }); afterAll(() => { delete process.env.EMBY_URL; delete process.env.SONARR_URL; delete process.env.SONARR_API_KEY; delete process.env.SONARR_INSTANCES; delete process.env.RADARR_URL; delete process.env.RADARR_API_KEY; delete process.env.RADARR_INSTANCES; delete process.env.SABNZBD_URL; delete process.env.SABNZBD_API_KEY; delete process.env.SOFARR_BASE_URL; delete process.env.SOFARR_WEBHOOK_SECRET; }); afterEach(() => { nock.cleanAll(); }); // =========================================================================== // SONARR ROUTES // =========================================================================== describe('Sonarr routes', () => { describe('GET /api/sonarr/queue', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/sonarr/queue'); expect(res.status).toBe(401); }); it('proxies Sonarr queue', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/queue').reply(200, SONARR_QUEUE); const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.records).toBeDefined(); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/sonarr/queue').set('Cookie', cookies); expect(res.status).toBe(500); expect(res.body.error).toMatch(/queue/i); }); }); describe('GET /api/sonarr/history', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/sonarr/history'); expect(res.status).toBe(401); }); it('proxies Sonarr history with default pageSize', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/history').query(true).reply(200, SONARR_HISTORY); const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.records).toBeDefined(); }); it('passes through custom pageSize', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/history').query({ pageSize: '100' }).reply(200, SONARR_HISTORY); const res = await request(app).get('/api/sonarr/history?pageSize=100').set('Cookie', cookies); expect(res.status).toBe(200); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/sonarr/history').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/sonarr/series', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/sonarr/series'); expect(res.status).toBe(401); }); it('proxies series list', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/series').reply(200, SONARR_SERIES_LIST); const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/series').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/sonarr/series').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/sonarr/series/:id', () => { it('proxies individual series', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/series/10').reply(200, SONARR_SERIES_ITEM); const res = await request(app).get('/api/sonarr/series/10').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.title).toBe('My Show'); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/series/999').replyWithError('not found'); const res = await request(app).get('/api/sonarr/series/999').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/sonarr/notifications', () => { it('returns 503 when no Sonarr instance configured', async () => { const app = createApp({ skipRateLimits: true }); // Temporarily clear instances const saved = process.env.SONARR_INSTANCES; delete process.env.SONARR_INSTANCES; delete process.env.SONARR_URL; delete process.env.SONARR_API_KEY; interceptLogin(); const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' }); const cookies = loginRes.headers['set-cookie']; const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies); expect(res.status).toBe(503); process.env.SONARR_INSTANCES = saved; process.env.SONARR_URL = SONARR_BASE; process.env.SONARR_API_KEY = 'sk'; }); it('proxies notifications list', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/notification').reply(200, SONARR_NOTIFICATIONS); const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/sonarr/notifications').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/sonarr/notifications/:id', () => { it('proxies a single notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/notification/5').reply(200, SONARR_NOTIF_ITEM); const res = await request(app).get('/api/sonarr/notifications/5').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.name).toBe('Plex'); }); }); describe('POST /api/sonarr/notifications', () => { it('returns 403 (CSRF missing) without auth', async () => { // verifyCsrf fires before requireAuth on POST routes — no CSRF → 403 const app = createApp({ skipRateLimits: true }); const res = await request(app).post('/api/sonarr/notifications').send({}); expect(res.status).toBe(403); }); it('creates a notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 99, name: 'New' }); const res = await request(app) .post('/api/sonarr/notifications') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ name: 'New' }); expect(res.status).toBe(200); expect(res.body.name).toBe('New'); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).post('/api/v3/notification').replyWithError('ECONNREFUSED'); const res = await request(app) .post('/api/sonarr/notifications') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ name: 'New' }); expect(res.status).toBe(500); }); }); describe('PUT /api/sonarr/notifications/:id', () => { it('updates a notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).put('/api/v3/notification/5').reply(200, { id: 5, name: 'Updated' }); const res = await request(app) .put('/api/sonarr/notifications/5') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 5, name: 'Updated' }); expect(res.status).toBe(200); expect(res.body.name).toBe('Updated'); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).put('/api/v3/notification/5').replyWithError('ECONNREFUSED'); const res = await request(app) .put('/api/sonarr/notifications/5') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 5 }); expect(res.status).toBe(500); }); }); describe('DELETE /api/sonarr/notifications/:id', () => { it('deletes a notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).delete('/api/v3/notification/5').reply(200, {}); const res = await request(app) .delete('/api/sonarr/notifications/5') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf); expect(res.status).toBe(200); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).delete('/api/v3/notification/5').replyWithError('ECONNREFUSED'); const res = await request(app) .delete('/api/sonarr/notifications/5') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf); expect(res.status).toBe(500); }); }); describe('POST /api/sonarr/notifications/test', () => { it('returns 503 when no Sonarr instance configured', async () => { const app = createApp({ skipRateLimits: true }); const saved = process.env.SONARR_INSTANCES; const savedUrl = process.env.SONARR_URL; const savedKey = process.env.SONARR_API_KEY; delete process.env.SONARR_INSTANCES; delete process.env.SONARR_URL; delete process.env.SONARR_API_KEY; interceptLogin(); const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' }); const cookies = loginRes.headers['set-cookie']; const csrf = loginRes.body.csrfToken; const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); const res = await request(app) .post('/api/sonarr/notifications/test') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(503); process.env.SONARR_INSTANCES = saved; process.env.SONARR_URL = savedUrl; process.env.SONARR_API_KEY = savedKey; }); it('tests a notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).post('/api/v3/notification/test').reply(200, {}); const res = await request(app) .post('/api/sonarr/notifications/test') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 5 }); expect(res.status).toBe(200); }); it('returns 500 when test fails', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED'); const res = await request(app) .post('/api/sonarr/notifications/test') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 5 }); expect(res.status).toBe(500); }); }); describe('GET /api/sonarr/notifications/schema', () => { it('proxies the schema', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SONARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]); const res = await request(app).get('/api/sonarr/notifications/schema').set('Cookie', cookies); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); }); describe('POST /api/sonarr/notifications/sofarr-webhook', () => { it('returns 400 when SOFARR_BASE_URL is not configured', async () => { const app = createApp({ skipRateLimits: true }); const saved = process.env.SOFARR_BASE_URL; delete process.env.SOFARR_BASE_URL; interceptLogin(); const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' }); const cookies = loginRes.headers['set-cookie']; const csrf = loginRes.body.csrfToken; const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); const res = await request(app) .post('/api/sonarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(400); expect(res.body.error).toMatch(/SOFARR_BASE_URL/); process.env.SOFARR_BASE_URL = saved; }); it('creates a new webhook notification when none exists', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).get('/api/v3/notification').reply(200, []); nock(SONARR_BASE).post('/api/v3/notification').reply(200, { id: 10, name: 'Sofarr' }); const res = await request(app) .post('/api/sonarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(200); expect(res.body.name).toBe('Sofarr'); }); it('updates an existing Sofarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE) .get('/api/v3/notification') .reply(200, [{ id: 10, name: 'Sofarr', implementation: 'Webhook' }]); nock(SONARR_BASE) .put('/api/v3/notification/10') .reply(200, { id: 10, name: 'Sofarr' }); const res = await request(app) .post('/api/sonarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(200); expect(res.body.name).toBe('Sofarr'); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(SONARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED'); const res = await request(app) .post('/api/sonarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(500); }); }); }); // =========================================================================== // RADARR ROUTES // =========================================================================== describe('Radarr routes', () => { describe('GET /api/radarr/queue', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/radarr/queue'); expect(res.status).toBe(401); }); it('proxies Radarr queue', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/queue').reply(200, RADARR_QUEUE); const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.records).toBeDefined(); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/queue').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/radarr/queue').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/radarr/history', () => { it('proxies Radarr history', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/history').query(true).reply(200, RADARR_HISTORY); const res = await request(app).get('/api/radarr/history').set('Cookie', cookies); expect(res.status).toBe(200); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/history').query(true).replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/radarr/history').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/radarr/movies', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/radarr/movies'); expect(res.status).toBe(401); }); it('proxies movies list', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/movie').reply(200, RADARR_MOVIES_LIST); const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/movie').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/radarr/movies').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/radarr/movies/:id', () => { it('proxies a single movie', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/movie/20').reply(200, RADARR_MOVIE_ITEM); const res = await request(app).get('/api/radarr/movies/20').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.title).toBe('My Movie'); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/movie/999').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/radarr/movies/999').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('GET /api/radarr/notifications', () => { it('returns 503 when no Radarr instance configured', async () => { const app = createApp({ skipRateLimits: true }); const saved = process.env.RADARR_INSTANCES; const savedUrl = process.env.RADARR_URL; const savedKey = process.env.RADARR_API_KEY; delete process.env.RADARR_INSTANCES; delete process.env.RADARR_URL; delete process.env.RADARR_API_KEY; interceptLogin(); const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' }); const cookies = loginRes.headers['set-cookie']; const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies); expect(res.status).toBe(503); process.env.RADARR_INSTANCES = saved; process.env.RADARR_URL = savedUrl; process.env.RADARR_API_KEY = savedKey; }); it('proxies notifications list', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/notification').reply(200, RADARR_NOTIFICATIONS); const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies); expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/radarr/notifications').set('Cookie', cookies); expect(res.status).toBe(500); }); }); describe('POST /api/radarr/notifications', () => { it('creates a Radarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 88, name: 'New' }); const res = await request(app) .post('/api/radarr/notifications') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ name: 'New' }); expect(res.status).toBe(200); expect(res.body.name).toBe('New'); }); }); describe('PUT /api/radarr/notifications/:id', () => { it('updates a Radarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).put('/api/v3/notification/7').reply(200, { id: 7, name: 'Updated' }); const res = await request(app) .put('/api/radarr/notifications/7') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 7, name: 'Updated' }); expect(res.status).toBe(200); expect(res.body.name).toBe('Updated'); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).put('/api/v3/notification/7').replyWithError('ECONNREFUSED'); const res = await request(app) .put('/api/radarr/notifications/7') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 7 }); expect(res.status).toBe(500); }); }); describe('DELETE /api/radarr/notifications/:id', () => { it('deletes a Radarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).delete('/api/v3/notification/7').reply(200, {}); const res = await request(app) .delete('/api/radarr/notifications/7') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf); expect(res.status).toBe(200); }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).delete('/api/v3/notification/7').replyWithError('ECONNREFUSED'); const res = await request(app) .delete('/api/radarr/notifications/7') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf); expect(res.status).toBe(500); }); }); describe('GET /api/radarr/notifications/:id', () => { it('proxies a single Radarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/notification/7').reply(200, RADARR_NOTIF_ITEM); const res = await request(app).get('/api/radarr/notifications/7').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.name).toBe('Plex'); }); }); describe('POST /api/radarr/notifications/test', () => { it('tests a Radarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).post('/api/v3/notification/test').reply(200, {}); const res = await request(app) .post('/api/radarr/notifications/test') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 7 }); expect(res.status).toBe(200); }); it('returns 500 when test fails', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).post('/api/v3/notification/test').replyWithError('ECONNREFUSED'); const res = await request(app) .post('/api/radarr/notifications/test') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({ id: 7 }); expect(res.status).toBe(500); }); }); describe('GET /api/radarr/notifications/schema', () => { it('proxies the Radarr notification schema', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(RADARR_BASE).get('/api/v3/notification/schema').reply(200, [{ implementation: 'Webhook' }]); const res = await request(app).get('/api/radarr/notifications/schema').set('Cookie', cookies); expect(res.status).toBe(200); }); }); describe('POST /api/radarr/notifications/sofarr-webhook', () => { it('creates a new Radarr webhook when none exists', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).get('/api/v3/notification').reply(200, []); nock(RADARR_BASE).post('/api/v3/notification').reply(200, { id: 20, name: 'Sofarr' }); const res = await request(app) .post('/api/radarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(200); expect(res.body.name).toBe('Sofarr'); }); it('updates an existing Sofarr Radarr notification', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE) .get('/api/v3/notification') .reply(200, [{ id: 20, name: 'Sofarr', implementation: 'Webhook' }]); nock(RADARR_BASE) .put('/api/v3/notification/20') .reply(200, { id: 20, name: 'Sofarr' }); const res = await request(app) .post('/api/radarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(200); }); it('returns 400 when SOFARR_WEBHOOK_SECRET is not configured', async () => { const app = createApp({ skipRateLimits: true }); const saved = process.env.SOFARR_WEBHOOK_SECRET; delete process.env.SOFARR_WEBHOOK_SECRET; interceptLogin(); const loginRes = await request(app).post('/api/auth/login').send({ username: 'alice', password: 'pw' }); const cookies = loginRes.headers['set-cookie']; const csrf = loginRes.body.csrfToken; const csrfCookie = cookies.find(c => c.startsWith('csrf_token=')); const res = await request(app) .post('/api/radarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(400); expect(res.body.error).toMatch(/SOFARR_WEBHOOK_SECRET/); process.env.SOFARR_WEBHOOK_SECRET = saved; }); it('returns 500 on upstream failure', async () => { const app = createApp({ skipRateLimits: true }); const { cookies, csrf, csrfCookie } = await getSessionWithCsrf(app); nock(RADARR_BASE).get('/api/v3/notification').replyWithError('ECONNREFUSED'); const res = await request(app) .post('/api/radarr/notifications/sofarr-webhook') .set('Cookie', joinCookies(cookies, csrfCookie)) .set('X-CSRF-Token', csrf) .send({}); expect(res.status).toBe(500); }); }); }); // =========================================================================== // SABNZBD ROUTES // =========================================================================== describe('SABnzbd routes', () => { describe('GET /api/sabnzbd/queue', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/sabnzbd/queue'); expect(res.status).toBe(401); }); it('proxies SABnzbd queue', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SABNZBD_BASE) .get('/api') .query({ mode: 'queue', apikey: 'sabkey', output: 'json' }) .reply(200, SAB_QUEUE_RESP); const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.queue).toBeDefined(); expect(res.body.queue.status).toBe('Downloading'); }); it('returns 500 when SABnzbd is unreachable', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SABNZBD_BASE) .get('/api') .query(true) .replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/sabnzbd/queue').set('Cookie', cookies); expect(res.status).toBe(500); expect(res.body.error).toMatch(/queue/i); }); }); describe('GET /api/sabnzbd/history', () => { it('returns 401 when unauthenticated', async () => { const app = createApp({ skipRateLimits: true }); const res = await request(app).get('/api/sabnzbd/history'); expect(res.status).toBe(401); }); it('proxies SABnzbd history with default limit', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SABNZBD_BASE) .get('/api') .query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '50' }) .reply(200, SAB_HISTORY_RESP); const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies); expect(res.status).toBe(200); expect(res.body.history).toBeDefined(); }); it('passes through custom limit', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SABNZBD_BASE) .get('/api') .query({ mode: 'history', apikey: 'sabkey', output: 'json', limit: '100' }) .reply(200, SAB_HISTORY_RESP); const res = await request(app).get('/api/sabnzbd/history?limit=100').set('Cookie', cookies); expect(res.status).toBe(200); }); it('returns 500 when SABnzbd is unreachable', async () => { const app = createApp({ skipRateLimits: true }); const { cookies } = await loginAs(app); nock(SABNZBD_BASE) .get('/api') .query(true) .replyWithError('ECONNREFUSED'); const res = await request(app).get('/api/sabnzbd/history').set('Cookie', cookies); expect(res.status).toBe(500); expect(res.body.error).toMatch(/history/i); }); }); });