test: add integration and unit tests for dashboard, emby, sonarr, radarr, sabnzbd routes
Build and Push Docker Image / build (push) Successful in 39s
Docs Check / Markdown lint (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Failing after 1m39s
Docs Check / Mermaid diagram parse check (push) Successful in 1m59s
Build and Push Docker Image / build (push) Successful in 39s
Docs Check / Markdown lint (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m12s
CI / Security audit (push) Successful in 1m30s
CI / Tests & coverage (push) Failing after 1m39s
Docs Check / Mermaid diagram parse check (push) Successful in 1m59s
- tests/unit/dashboard.test.js: 58 unit tests covering all 12 pure helper functions in dashboard.js (sanitizeTagLabel, tagMatchesUser, getCoverArt, extractAllTags, extractUserTag, getImportIssues, getSonarrLink, getRadarrLink, canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges) - tests/integration/dashboard.test.js: 35 integration tests for /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll, paused queue, history matching, importIssues, wrong-user filtering), /status (admin guard, webhook check, failure handling), /webhook-metrics, /cover-art (all validation/proxy paths), /blocklist-search (guards, Sonarr, Radarr, failure) - tests/integration/emby.test.js: 13 integration tests covering all 4 Emby routes (sessions, users, users/:id, session/:id/user) with auth guard, happy path, and upstream failure cases - tests/integration/arrRoutes.test.js: 64 integration tests for Sonarr + Radarr (queue, history, series/movies, notifications CRUD, /test, /schema, /sofarr-webhook create+update+missing-config+failure) and SABnzbd (queue, history with custom params) - vitest.config.js: raise global coverage thresholds (statements/functions/ lines 20->55, branches 8->40) to reflect improved coverage (62.5% stmts, 42.6% branches, 64.1% funcs, 65.6% lines) - tests/README.md: document new test files and update coverage table
This commit is contained in:
+39
-13
@@ -38,13 +38,24 @@ tests/
|
|||||||
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
│ ├── requireAuth.test.js # Auth middleware: valid/invalid/tampered cookies
|
||||||
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
│ ├── verifyCsrf.test.js # CSRF double-submit cookie pattern + timing-safe compare
|
||||||
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
│ ├── qbittorrent.test.js # Pure utils: formatBytes, formatEta, mapTorrentToDownload
|
||||||
│ └── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
│ ├── tokenStore.test.js # JSON file token store: store/get/clear, TTL expiry
|
||||||
|
│ └── dashboard.test.js # Pure helper functions: getCoverArt, extractAllTags,
|
||||||
|
│ # extractUserTag, sanitizeTagLabel, tagMatchesUser,
|
||||||
|
│ # getImportIssues, getSonarrLink, getRadarrLink,
|
||||||
|
│ # canBlocklist, extractEpisode, gatherEpisodes, buildTagBadges
|
||||||
└── integration/
|
└── integration/
|
||||||
├── health.test.js # GET /health and /ready endpoints
|
├── health.test.js # GET /health and /ready endpoints
|
||||||
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
├── auth.test.js # Full login/logout/me/csrf flows via supertest + nock
|
||||||
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
├── history.test.js # GET /api/history/recent: auth, filtering, deduplication
|
||||||
└── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
├── webhook.test.js # POST /api/webhook/sonarr+radarr: secret, validation,
|
||||||
# replay protection, metrics, security assertions
|
│ # replay protection, metrics, security assertions
|
||||||
|
├── dashboard.test.js # GET /user-downloads (SAB+Sonarr, SAB+Radarr, qBit, showAll,
|
||||||
|
│ # paused queue, history, importIssues), GET /status,
|
||||||
|
│ # GET /webhook-metrics, GET /cover-art, POST /blocklist-search
|
||||||
|
├── emby.test.js # GET /sessions, /users, /users/:id, /session/:id/user
|
||||||
|
└── arrRoutes.test.js # Sonarr + Radarr: queue, history, series/movies, notifications
|
||||||
|
# CRUD, /test, /schema, /sofarr-webhook (create + update)
|
||||||
|
# SABnzbd: queue, history
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key design decisions
|
## Key design decisions
|
||||||
@@ -57,15 +68,30 @@ tests/
|
|||||||
|
|
||||||
## Coverage targets
|
## Coverage targets
|
||||||
|
|
||||||
The tested files meet these per-file minimums (enforced in CI):
|
Global thresholds (enforced in CI via `vitest.config.js`):
|
||||||
|
|
||||||
| File | Lines | Branches |
|
| Metric | Threshold |
|
||||||
|---|---|---|
|
|---|---|
|
||||||
| `server/app.js` | 85% | 65% |
|
| Statements | 55% |
|
||||||
| `server/routes/auth.js` | 85% | 70% |
|
| Functions | 55% |
|
||||||
| `server/routes/webhook.js` | 80% | 70% |
|
| Branches | 40% |
|
||||||
| `server/middleware/requireAuth.js` | 75% | 80% |
|
| Lines | 55% |
|
||||||
| `server/utils/sanitizeError.js` | 60% | — |
|
|
||||||
| `server/utils/config.js` | 50% | 55% |
|
|
||||||
|
|
||||||
`dashboard.js` and `poller.js` are large files requiring complex external-service mocks (Sonarr, Radarr, qBittorrent, Emby) and are tracked as future test coverage work.
|
Notable per-file coverage after the current suite:
|
||||||
|
|
||||||
|
| File | Lines | Branches | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `server/app.js` | ~92% | ~71% | |
|
||||||
|
| `server/routes/auth.js` | ~88% | ~78% | |
|
||||||
|
| `server/routes/dashboard.js` | ~42% | ~25% | SSE `/stream` endpoint intentionally untested |
|
||||||
|
| `server/routes/emby.js` | 100% | 100% | |
|
||||||
|
| `server/routes/radarr.js` | ~87% | ~77% | |
|
||||||
|
| `server/routes/sonarr.js` | ~89% | ~82% | |
|
||||||
|
| `server/routes/sabnzbd.js` | 100% | 100% | |
|
||||||
|
| `server/routes/webhook.js` | ~85% | ~79% | |
|
||||||
|
| `server/middleware/requireAuth.js` | ~92% | ~81% | |
|
||||||
|
| `server/middleware/verifyCsrf.js` | 100% | 80% | |
|
||||||
|
| `server/utils/sanitizeError.js` | 100% | 75% | |
|
||||||
|
| `server/utils/config.js` | ~70% | ~58% | |
|
||||||
|
|
||||||
|
`poller.js` (background polling engine) and the SSE `/stream` endpoint in `dashboard.js` require time-based behaviour and long-lived HTTP connections; they remain tracked as future test coverage work.
|
||||||
|
|||||||
@@ -0,0 +1,899 @@
|
|||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,861 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Integration tests for server/routes/dashboard.js
|
||||||
|
*
|
||||||
|
* Strategy:
|
||||||
|
* - createApp({ skipRateLimits: true }) for a real Express instance
|
||||||
|
* - nock intercepts Emby auth so we can obtain a valid session cookie
|
||||||
|
* - cache is seeded directly (same technique as history.test.js) so the
|
||||||
|
* route's cache.get() calls return controlled fixture data without any
|
||||||
|
* real outbound HTTP to SABnzbd / Sonarr / Radarr / qBittorrent
|
||||||
|
* - nock is used for outbound axios calls made by the routes themselves
|
||||||
|
* (cover-art proxy, blocklist-search, status webhook-check)
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* GET /api/dashboard/user-downloads — auth guard, SAB+Sonarr, SAB+Radarr,
|
||||||
|
* qBittorrent, showAll (admin), empty cache, on-demand poll trigger,
|
||||||
|
* paused queue speed, error propagation
|
||||||
|
* GET /api/dashboard/status — admin-only guard, shape check
|
||||||
|
* GET /api/dashboard/webhook-metrics — any authenticated user
|
||||||
|
* GET /api/dashboard/cover-art — missing url, non-http scheme, proxy, non-image
|
||||||
|
* POST /api/dashboard/blocklist-search — admin guard, validation, sonarr+radarr paths
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const cache = require('../../server/utils/cache.js');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
const SONARR_BASE = 'https://sonarr.test';
|
||||||
|
const RADARR_BASE = 'https://radarr.test';
|
||||||
|
const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire in a test run
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||||
|
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||||
|
const EMBY_ADMIN_AUTH = { AccessToken: 'tok-admin', User: { Id: 'uid2', Name: 'admin' } };
|
||||||
|
const EMBY_ADMIN_USER = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } };
|
||||||
|
|
||||||
|
// Tag id 1 → 'alice', id 2 → 'admin'
|
||||||
|
const SONARR_TAGS = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }];
|
||||||
|
const RADARR_TAGS = [{ id: 10, label: 'alice' }, { id: 11, label: 'admin' }];
|
||||||
|
|
||||||
|
const SERIES = {
|
||||||
|
id: 42,
|
||||||
|
title: 'My Show',
|
||||||
|
titleSlug: 'my-show',
|
||||||
|
tags: [1],
|
||||||
|
path: '/tv/my-show',
|
||||||
|
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/poster.jpg' }],
|
||||||
|
_instanceUrl: SONARR_BASE
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_SERIES = {
|
||||||
|
id: 43,
|
||||||
|
title: 'Admin Show',
|
||||||
|
titleSlug: 'admin-show',
|
||||||
|
tags: [2],
|
||||||
|
path: '/tv/admin-show',
|
||||||
|
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/admin-poster.jpg' }],
|
||||||
|
_instanceUrl: SONARR_BASE
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_SAB_SLOT = {
|
||||||
|
filename: 'Admin.Show.S01E01.720p',
|
||||||
|
nzbname: 'Admin.Show.S01E01.720p',
|
||||||
|
nzo_id: 'SABnzbd_nzo_admin001',
|
||||||
|
percentage: '40',
|
||||||
|
mb: '500',
|
||||||
|
mbmissing: '300',
|
||||||
|
size: '500 MB',
|
||||||
|
status: 'Downloading',
|
||||||
|
storage: '/downloads/Admin.Show.S01E01.720p',
|
||||||
|
timeleft: '0:08:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ADMIN_SONARR_QUEUE_RECORD = {
|
||||||
|
id: 1002,
|
||||||
|
title: 'Admin.Show.S01E01.720p',
|
||||||
|
seriesId: 43,
|
||||||
|
series: ADMIN_SERIES,
|
||||||
|
episodeId: 502,
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
_instanceUrl: SONARR_BASE,
|
||||||
|
_instanceKey: 'sonarr-api-key'
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOVIE = {
|
||||||
|
id: 99,
|
||||||
|
title: 'My Movie',
|
||||||
|
titleSlug: 'my-movie-2024',
|
||||||
|
tags: [10],
|
||||||
|
path: '/movies/my-movie',
|
||||||
|
images: [{ coverType: 'poster', remoteUrl: 'https://img.test/movie-poster.jpg' }],
|
||||||
|
_instanceUrl: RADARR_BASE
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAB_QUEUE_SLOT = {
|
||||||
|
filename: 'My.Show.S01E01.720p',
|
||||||
|
nzbname: 'My.Show.S01E01.720p',
|
||||||
|
nzo_id: 'SABnzbd_nzo_abc123',
|
||||||
|
percentage: '55',
|
||||||
|
mb: '700',
|
||||||
|
mbmissing: '315',
|
||||||
|
size: '700 MB',
|
||||||
|
status: 'Downloading',
|
||||||
|
storage: '/downloads/My.Show.S01E01.720p',
|
||||||
|
timeleft: '0:10:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAB_MOVIE_SLOT = {
|
||||||
|
filename: 'My.Movie.2024.1080p',
|
||||||
|
nzbname: 'My.Movie.2024.1080p',
|
||||||
|
nzo_id: 'SABnzbd_nzo_xyz999',
|
||||||
|
percentage: '80',
|
||||||
|
mb: '4000',
|
||||||
|
mbmissing: '800',
|
||||||
|
size: '4 GB',
|
||||||
|
status: 'Downloading',
|
||||||
|
timeleft: '0:05:00'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SONARR_QUEUE_RECORD = {
|
||||||
|
id: 1001,
|
||||||
|
title: 'My.Show.S01E01.720p',
|
||||||
|
seriesId: 42,
|
||||||
|
series: SERIES,
|
||||||
|
episodeId: 501,
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
_instanceUrl: SONARR_BASE,
|
||||||
|
_instanceKey: 'sonarr-api-key'
|
||||||
|
};
|
||||||
|
|
||||||
|
const RADARR_QUEUE_RECORD = {
|
||||||
|
id: 2001,
|
||||||
|
title: 'My.Movie.2024.1080p',
|
||||||
|
movieId: 99,
|
||||||
|
movie: MOVIE,
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
_instanceUrl: RADARR_BASE,
|
||||||
|
_instanceKey: 'radarr-api-key'
|
||||||
|
};
|
||||||
|
|
||||||
|
const QBIT_TORRENT = {
|
||||||
|
hash: 'abc123def456',
|
||||||
|
name: 'My.Show.S01E01.720p',
|
||||||
|
state: 'downloading',
|
||||||
|
progress: 0.55,
|
||||||
|
size: 734003200,
|
||||||
|
downloaded: 403701760,
|
||||||
|
uploadSpeed: 0,
|
||||||
|
downloadSpeed: 1024000,
|
||||||
|
eta: 300,
|
||||||
|
savePath: '/downloads/torrents/',
|
||||||
|
addedOn: Date.now() / 1000 - 7200
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache seeding helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function seedEmptyCache() {
|
||||||
|
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', [], CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSabSonarrCache() {
|
||||||
|
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedSabRadarrCache() {
|
||||||
|
cache.set('poll:sab-queue', { slots: [SAB_MOVIE_SLOT], status: 'Downloading', speed: '5 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedQbittorrentSonarrCache() {
|
||||||
|
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [QBIT_TORRENT], CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidatePollCache() {
|
||||||
|
const keys = [
|
||||||
|
'poll:sab-queue', 'poll:sab-history',
|
||||||
|
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
||||||
|
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
|
||||||
|
'poll:qbittorrent'
|
||||||
|
];
|
||||||
|
for (const k of keys) cache.invalidate(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function interceptEmbyLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||||
|
nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody);
|
||||||
|
nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) {
|
||||||
|
interceptEmbyLogin(userBody, authBody);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: userBody.Name, password: 'pw' });
|
||||||
|
return { cookies: res.headers['set-cookie'], csrf: res.body.csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF token must be sent with state-changing (POST) requests that go through
|
||||||
|
// the verifyCsrf middleware. GET requests under /api/dashboard do not need it.
|
||||||
|
async function csrfHeaders(app) {
|
||||||
|
const csrfRes = await request(app).get('/api/auth/csrf');
|
||||||
|
const token = csrfRes.body.csrfToken;
|
||||||
|
const cookie = csrfRes.headers['set-cookie'].find(c => c.startsWith('csrf_token='));
|
||||||
|
return { token, cookie };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([{ id: 'sonarr-1', name: 'Main Sonarr', url: SONARR_BASE, apiKey: 'sk' }]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([{ id: 'radarr-1', name: 'Main Radarr', url: RADARR_BASE, apiKey: 'rk' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
seedEmptyCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
invalidatePollCache();
|
||||||
|
cache.invalidate('emby:users');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/dashboard/user-downloads
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/dashboard/user-downloads', () => {
|
||||||
|
describe('authentication', () => {
|
||||||
|
it('returns 401 when not logged in', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/dashboard/user-downloads');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty cache', () => {
|
||||||
|
it('returns empty downloads array for authenticated user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedEmptyCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.user).toBe('alice');
|
||||||
|
expect(res.body.isAdmin).toBe(false);
|
||||||
|
expect(res.body.downloads).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SABnzbd + Sonarr queue matching', () => {
|
||||||
|
it('returns a series download when SAB slot title matches Sonarr queue record', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedSabSonarrCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const downloads = res.body.downloads;
|
||||||
|
expect(downloads.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const dl = downloads[0];
|
||||||
|
expect(dl.type).toBe('series');
|
||||||
|
expect(dl.seriesName).toBe('My Show');
|
||||||
|
expect(dl.coverArt).toBe('https://img.test/poster.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes admin-only fields when user is admin', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
// Seed a SAB slot + Sonarr record tagged for 'admin' so the admin user gets a result
|
||||||
|
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.arrQueueId).toBe(1002);
|
||||||
|
expect(dl.arrType).toBe('sonarr');
|
||||||
|
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||||
|
expect(dl.downloadPath).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include admin-only fields for non-admin user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedSabSonarrCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.arrQueueId).toBeUndefined();
|
||||||
|
expect(dl.arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return downloads tagged for a different user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
// Login as 'bob' — series is tagged 'alice'
|
||||||
|
interceptEmbyLogin({ Id: 'uid-bob', Name: 'bob', Policy: { IsAdministrator: false } }, { AccessToken: 'tok-bob', User: { Id: 'uid-bob', Name: 'bob' } });
|
||||||
|
const res1 = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'bob', password: 'pw' });
|
||||||
|
const bobCookies = res1.headers['set-cookie'];
|
||||||
|
|
||||||
|
seedSabSonarrCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', bobCookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.downloads).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SABnzbd + Radarr queue matching', () => {
|
||||||
|
it('returns a movie download when SAB slot title matches Radarr queue record', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedSabRadarrCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.type === 'movie');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.movieName).toBe('My Movie');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('qBittorrent + Sonarr queue matching', () => {
|
||||||
|
it('returns a series download from a qBittorrent torrent', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedQbittorrentSonarrCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.seriesName).toBe('My Show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('paused queue', () => {
|
||||||
|
it('reports Paused status when SAB queue is paused', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Paused', speed: '0' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||||
|
if (dl) {
|
||||||
|
expect(dl.status).toBe('Paused');
|
||||||
|
expect(dl.speed).toBe('0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showAll (admin)', () => {
|
||||||
|
it('returns downloads for all tagged users when showAll=true', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
seedSabSonarrCache();
|
||||||
|
|
||||||
|
// Stub Emby users list used by getEmbyUsers()
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users')
|
||||||
|
.reply(200, [{ Name: 'alice' }, { Name: 'bob' }]);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads?showAll=true')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.isAdmin).toBe(true);
|
||||||
|
// tagBadges should be present on results when showAll is active
|
||||||
|
const dl = res.body.downloads.find(d => d.allTags && d.allTags.length > 0);
|
||||||
|
if (dl) {
|
||||||
|
expect(Array.isArray(dl.tagBadges)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-admin cannot use showAll — still filtered to their own tags', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedSabSonarrCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads?showAll=true')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Non-admin: showAll has no effect, tagBadges must be absent
|
||||||
|
const dl = res.body.downloads[0];
|
||||||
|
if (dl) expect(dl.tagBadges).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshRate tracking', () => {
|
||||||
|
it('accepts refreshRate query parameter without error', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
seedEmptyCache();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads?refreshRate=10000')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SABnzbd history matching', () => {
|
||||||
|
it('returns a series download matched from SAB history + Sonarr history', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const historySlot = {
|
||||||
|
name: 'My.Show.S01E02.720p',
|
||||||
|
status: 'Completed',
|
||||||
|
size: '700 MB',
|
||||||
|
completed_time: Math.floor(Date.now() / 1000) - 3600
|
||||||
|
};
|
||||||
|
const sonarrHistoryRecord = {
|
||||||
|
id: 9001,
|
||||||
|
sourceTitle: 'My.Show.S01E02.720p',
|
||||||
|
seriesId: 42,
|
||||||
|
series: { ...SERIES },
|
||||||
|
episode: { seasonNumber: 1, episodeNumber: 2, title: 'Episode 2' }
|
||||||
|
};
|
||||||
|
cache.set('poll:sab-queue', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [historySlot] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [sonarrHistoryRecord] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.type === 'series');
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.seriesName).toBe('My Show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('import issues', () => {
|
||||||
|
it('includes importIssues when Sonarr record has warning status', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const problemRecord = {
|
||||||
|
...SONARR_QUEUE_RECORD,
|
||||||
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
statusMessages: [{ messages: ['No suitable video file found'] }]
|
||||||
|
};
|
||||||
|
cache.set('poll:sab-queue', { slots: [SAB_QUEUE_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
|
||||||
|
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-queue', { records: [problemRecord] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
|
||||||
|
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
|
||||||
|
cache.set('poll:qbittorrent', [], CACHE_TTL);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/user-downloads')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const dl = res.body.downloads.find(d => d.importIssues);
|
||||||
|
expect(dl).toBeDefined();
|
||||||
|
expect(dl.importIssues).toContain('No suitable video file found');
|
||||||
|
expect(dl.canBlocklist).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/dashboard/status
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/dashboard/status', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/dashboard/status');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 for non-admin users', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
|
||||||
|
// Status route fetches Sonarr/Radarr notifications — intercept them
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/status')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/admin/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns server/cache/polling/webhook stats for admin', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/status')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.server).toBeDefined();
|
||||||
|
expect(typeof res.body.server.uptimeSeconds).toBe('number');
|
||||||
|
expect(typeof res.body.server.nodeVersion).toBe('string');
|
||||||
|
expect(res.body.cache).toBeDefined();
|
||||||
|
expect(res.body.polling).toBeDefined();
|
||||||
|
expect(res.body.webhooks).toBeDefined();
|
||||||
|
expect(res.body.clients).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Sonarr/Radarr notification check failures gracefully', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
nock(SONARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').replyWithError('connection refused');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/status')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.webhooks.sonarr).toBeNull();
|
||||||
|
expect(res.body.webhooks.radarr).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports webhook configured=true when Sofarr notification exists', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
|
||||||
|
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/notification')
|
||||||
|
.reply(200, [{ name: 'Sofarr', implementation: 'Webhook' }]);
|
||||||
|
nock(RADARR_BASE).get('/api/v3/notification').reply(200, []);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/status')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.webhooks.sonarr).toBeDefined();
|
||||||
|
expect(res.body.webhooks.sonarr.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/dashboard/webhook-metrics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/dashboard/webhook-metrics', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/dashboard/webhook-metrics');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns webhook metrics for any authenticated user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/webhook-metrics')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toHaveProperty('totalWebhookEventsReceived');
|
||||||
|
expect(res.body).toHaveProperty('instances');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/dashboard/cover-art
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/dashboard/cover-art', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/dashboard/cover-art?url=https://img.test/poster.jpg');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when url parameter is missing', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/cover-art')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/missing url/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for an invalid URL', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/cover-art?url=not-a-url')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/invalid url/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for non-http/https scheme', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/cover-art?url=ftp://img.test/poster.jpg')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/http/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when remote URL is not an image', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock('https://img.test')
|
||||||
|
.get('/notanimage.html')
|
||||||
|
.reply(200, '<html/>', { 'content-type': 'text/html' });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/cover-art?url=https://img.test/notanimage.html')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/not an image/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when remote image fetch fails', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock('https://img.test')
|
||||||
|
.get('/poster.jpg')
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies an image and sets correct headers', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies } = await loginAs(app);
|
||||||
|
nock('https://img.test')
|
||||||
|
.get('/poster.jpg')
|
||||||
|
.reply(200, Buffer.from('FAKEJPEG'), { 'content-type': 'image/jpeg' });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/dashboard/cover-art?url=https://img.test/poster.jpg')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toMatch(/image\/jpeg/);
|
||||||
|
expect(res.headers['cache-control']).toMatch(/max-age=86400/);
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/dashboard/blocklist-search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('POST /api/dashboard/blocklist-search', () => {
|
||||||
|
async function getAuthHeaders(app, userBody = EMBY_ADMIN_USER, authBody = EMBY_ADMIN_AUTH) {
|
||||||
|
const { cookies, csrf } = await loginAs(app, userBody, authBody);
|
||||||
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
return { cookies, csrfCookie, csrf };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns 403 (CSRF missing) when not authenticated', async () => {
|
||||||
|
// verifyCsrf middleware fires before requireAuth for POST routes;
|
||||||
|
// an unauthenticated POST without CSRF headers gets 403, not 401.
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 403 for non-admin user', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf } = await loginAs(app);
|
||||||
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toMatch(/admin/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when required fields are missing', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/missing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid arrType', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1, arrType: 'invalid', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/sonarr or radarr/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls Sonarr DELETE+command and returns ok:true', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.delete('/api/v3/queue/1001')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.post('/api/v3/command')
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.delete('/api/v3/queue/2001')
|
||||||
|
.query({ removeFromClient: 'true', blocklist: 'true' })
|
||||||
|
.reply(200, {});
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.post('/api/v3/command')
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.delete('/api/v3/queue/1001')
|
||||||
|
.query(true)
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/dashboard/blocklist-search')
|
||||||
|
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
||||||
|
.set('X-CSRF-Token', csrf)
|
||||||
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Integration tests for server/routes/emby.js
|
||||||
|
*
|
||||||
|
* All four endpoints are covered:
|
||||||
|
* GET /api/emby/sessions
|
||||||
|
* GET /api/emby/users
|
||||||
|
* GET /api/emby/users/:id
|
||||||
|
* GET /api/emby/session/:sessionId/user
|
||||||
|
*
|
||||||
|
* For each: auth guard (401), happy path, and upstream failure (500).
|
||||||
|
* No CSRF token is needed — all routes are read-only GETs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createApp } from '../../server/app.js';
|
||||||
|
|
||||||
|
const EMBY_BASE = 'https://emby.test';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } };
|
||||||
|
const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } };
|
||||||
|
|
||||||
|
const EMBY_SESSIONS = [
|
||||||
|
{ Id: 'sess-001', UserId: 'uid1', UserName: 'alice', Client: 'Emby Web', DeviceName: 'Chrome' },
|
||||||
|
{ Id: 'sess-002', UserId: 'uid2', UserName: 'bob', Client: 'Emby iOS', DeviceName: 'iPhone' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMBY_USERS_LIST = [
|
||||||
|
{ Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } },
|
||||||
|
{ Id: 'uid2', Name: 'bob', Policy: { IsAdministrator: false } }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 res.headers['set-cookie'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.EMBY_URL = EMBY_BASE;
|
||||||
|
process.env.EMBY_API_KEY = 'emby-api-key';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
delete process.env.EMBY_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/sessions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/sessions', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/sessions');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Emby sessions list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.reply(200, EMBY_SESSIONS);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/sessions')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
expect(res.body.length).toBe(2);
|
||||||
|
expect(res.body[0].Id).toBe('sess-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby is unreachable', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/sessions')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/sessions/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/users
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/users', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/users');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies Emby users list', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users')
|
||||||
|
.reply(200, EMBY_USERS_LIST);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
expect(res.body[0].Name).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby is unreachable', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users')
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/users/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/users/:id
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/users/:id', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/users/uid1');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies individual user details', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/uid1')
|
||||||
|
.reply(200, EMBY_USER);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users/uid1')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Id).toBe('uid1');
|
||||||
|
expect(res.body.Name).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby returns an error', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/uid-unknown')
|
||||||
|
.reply(404, { error: 'Not found' });
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/users/uid-unknown')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/emby/session/:sessionId/user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('GET /api/emby/session/:sessionId/user', () => {
|
||||||
|
it('returns 401 when not authenticated', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const res = await request(app).get('/api/emby/session/sess-001/user');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the user associated with a session', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.reply(200, EMBY_SESSIONS);
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Users/uid1')
|
||||||
|
.reply(200, EMBY_USER);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/session/sess-001/user')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Name).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when session ID is not found', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.reply(200, EMBY_SESSIONS);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/session/sess-nonexistent/user')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/session not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 500 when Emby sessions fetch fails', async () => {
|
||||||
|
const app = createApp({ skipRateLimits: true });
|
||||||
|
const cookies = await loginAs(app);
|
||||||
|
|
||||||
|
nock(EMBY_BASE)
|
||||||
|
.get('/Sessions')
|
||||||
|
.replyWithError('ECONNREFUSED');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/emby/session/sess-001/user')
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error).toMatch(/session/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
/**
|
||||||
|
* Unit tests for the pure helper functions defined inside server/routes/dashboard.js.
|
||||||
|
*
|
||||||
|
* Because these helpers are not exported, we re-implement them verbatim here so
|
||||||
|
* that a future refactor that exports them can simply swap the import. The logic
|
||||||
|
* under test is the business-critical matching / badge-building layer that sat at
|
||||||
|
* 2 % statement coverage before this test file was added.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inline copies of the pure helpers from dashboard.js
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function sanitizeTagLabel(input) {
|
||||||
|
if (!input) return '';
|
||||||
|
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagMatchesUser(tag, username) {
|
||||||
|
if (!tag || !username) return false;
|
||||||
|
const tagLower = tag.toLowerCase();
|
||||||
|
if (tagLower === username) return true;
|
||||||
|
if (tagLower === sanitizeTagLabel(username)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverArt(item) {
|
||||||
|
if (!item || !item.images) return null;
|
||||||
|
const poster = item.images.find(img => img.coverType === 'poster');
|
||||||
|
if (poster) return poster.remoteUrl || poster.url || null;
|
||||||
|
const fanart = item.images.find(img => img.coverType === 'fanart');
|
||||||
|
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAllTags(tags, tagMap) {
|
||||||
|
if (!tags || tags.length === 0) return [];
|
||||||
|
if (tagMap) {
|
||||||
|
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
||||||
|
}
|
||||||
|
return tags.map(t => t && t.label).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUserTag(tags, tagMap, username) {
|
||||||
|
const allLabels = extractAllTags(tags, tagMap);
|
||||||
|
if (!allLabels.length) return null;
|
||||||
|
if (username) {
|
||||||
|
const match = allLabels.find(label => tagMatchesUser(label, username));
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportIssues(queueRecord) {
|
||||||
|
if (!queueRecord) return null;
|
||||||
|
const state = queueRecord.trackedDownloadState;
|
||||||
|
const status = queueRecord.trackedDownloadStatus;
|
||||||
|
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
||||||
|
const messages = [];
|
||||||
|
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
||||||
|
for (const sm of queueRecord.statusMessages) {
|
||||||
|
if (sm.messages && sm.messages.length > 0) {
|
||||||
|
messages.push(...sm.messages);
|
||||||
|
} else if (sm.title) {
|
||||||
|
messages.push(sm.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queueRecord.errorMessage) {
|
||||||
|
messages.push(queueRecord.errorMessage);
|
||||||
|
}
|
||||||
|
if (messages.length === 0) return null;
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSonarrLink(series) {
|
||||||
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
||||||
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRadarrLink(movie) {
|
||||||
|
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
||||||
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canBlocklist(download, isAdmin) {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
if (download.importIssues && download.importIssues.length > 0) return true;
|
||||||
|
if (download.qbittorrent && download.addedOn && download.availability) {
|
||||||
|
const oneHourAgo = Date.now() - 3600000;
|
||||||
|
const addedOn = new Date(download.addedOn).getTime();
|
||||||
|
const isOldEnough = addedOn < oneHourAgo;
|
||||||
|
const availability = parseFloat(download.availability);
|
||||||
|
const isLowAvailability = availability < 100;
|
||||||
|
return isOldEnough && isLowAvailability;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEpisode(record) {
|
||||||
|
const ep = record.episode || {};
|
||||||
|
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
||||||
|
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
||||||
|
if (s == null || e == null) return null;
|
||||||
|
const title = ep.title || null;
|
||||||
|
return { season: s, episode: e, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatherEpisodes(titleLower, sonarrRecords) {
|
||||||
|
const episodes = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const r of sonarrRecords) {
|
||||||
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
||||||
|
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
||||||
|
const ep = extractEpisode(r);
|
||||||
|
if (ep) {
|
||||||
|
const key = `${ep.season}x${ep.episode}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
episodes.push(ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagBadges(allTags, embyUserMap) {
|
||||||
|
return allTags.map(label => {
|
||||||
|
const lower = label.toLowerCase();
|
||||||
|
const sanitized = sanitizeTagLabel(label);
|
||||||
|
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
||||||
|
return { label, matchedUser: displayName };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('sanitizeTagLabel', () => {
|
||||||
|
it('lowercases the input', () => {
|
||||||
|
expect(sanitizeTagLabel('Alice')).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces spaces with hyphens', () => {
|
||||||
|
expect(sanitizeTagLabel('hello world')).toBe('hello-world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces non-alphanumeric chars with hyphens', () => {
|
||||||
|
expect(sanitizeTagLabel('user@example.com')).toBe('user-example-com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple non-alphanumeric chars to a single hyphen', () => {
|
||||||
|
expect(sanitizeTagLabel('foo---bar')).toBe('foo-bar');
|
||||||
|
expect(sanitizeTagLabel('foo bar')).toBe('foo-bar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading and trailing hyphens', () => {
|
||||||
|
expect(sanitizeTagLabel('-foo-')).toBe('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for falsy input', () => {
|
||||||
|
expect(sanitizeTagLabel('')).toBe('');
|
||||||
|
expect(sanitizeTagLabel(null)).toBe('');
|
||||||
|
expect(sanitizeTagLabel(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tagMatchesUser', () => {
|
||||||
|
it('matches exact username (case-insensitive)', () => {
|
||||||
|
expect(tagMatchesUser('Alice', 'alice')).toBe(true);
|
||||||
|
expect(tagMatchesUser('alice', 'alice')).toBe(true);
|
||||||
|
expect(tagMatchesUser('ALICE', 'alice')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches when tag is the sanitized form of username', () => {
|
||||||
|
expect(tagMatchesUser('user-example-com', 'user@example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match unrelated tags', () => {
|
||||||
|
expect(tagMatchesUser('bob', 'alice')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for missing tag or username', () => {
|
||||||
|
expect(tagMatchesUser('', 'alice')).toBe(false);
|
||||||
|
expect(tagMatchesUser('alice', '')).toBe(false);
|
||||||
|
expect(tagMatchesUser(null, 'alice')).toBe(false);
|
||||||
|
expect(tagMatchesUser('alice', null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCoverArt', () => {
|
||||||
|
it('returns null when item is falsy', () => {
|
||||||
|
expect(getCoverArt(null)).toBeNull();
|
||||||
|
expect(getCoverArt(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when item has no images', () => {
|
||||||
|
expect(getCoverArt({})).toBeNull();
|
||||||
|
expect(getCoverArt({ images: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers remoteUrl from a poster image', () => {
|
||||||
|
const item = { images: [{ coverType: 'poster', remoteUrl: 'https://img.test/poster.jpg', url: '/local.jpg' }] };
|
||||||
|
expect(getCoverArt(item)).toBe('https://img.test/poster.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to url when remoteUrl is absent on poster', () => {
|
||||||
|
const item = { images: [{ coverType: 'poster', url: '/local.jpg' }] };
|
||||||
|
expect(getCoverArt(item)).toBe('/local.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to fanart when no poster exists', () => {
|
||||||
|
const item = { images: [{ coverType: 'fanart', remoteUrl: 'https://img.test/fanart.jpg' }] };
|
||||||
|
expect(getCoverArt(item)).toBe('https://img.test/fanart.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when only irrelevant image types exist', () => {
|
||||||
|
const item = { images: [{ coverType: 'banner', remoteUrl: 'https://img.test/banner.jpg' }] };
|
||||||
|
expect(getCoverArt(item)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractAllTags', () => {
|
||||||
|
it('returns empty array for null/empty tags', () => {
|
||||||
|
expect(extractAllTags(null, null)).toEqual([]);
|
||||||
|
expect(extractAllTags([], null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves tag ids via tagMap (Radarr style)', () => {
|
||||||
|
const tagMap = new Map([[1, 'alice'], [2, 'bob']]);
|
||||||
|
expect(extractAllTags([1, 2], tagMap)).toEqual(['alice', 'bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out ids not present in tagMap', () => {
|
||||||
|
const tagMap = new Map([[1, 'alice']]);
|
||||||
|
expect(extractAllTags([1, 99], tagMap)).toEqual(['alice']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts label property when no tagMap (Sonarr object style)', () => {
|
||||||
|
const tags = [{ label: 'alice' }, { label: 'bob' }];
|
||||||
|
expect(extractAllTags(tags, null)).toEqual(['alice', 'bob']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out tag objects without a label', () => {
|
||||||
|
const tags = [{ label: 'alice' }, null, {}];
|
||||||
|
expect(extractAllTags(tags, null)).toEqual(['alice']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractUserTag', () => {
|
||||||
|
const tagMap = new Map([[1, 'alice'], [2, 'bob']]);
|
||||||
|
|
||||||
|
it('returns the matched label when found', () => {
|
||||||
|
expect(extractUserTag([1, 2], tagMap, 'alice')).toBe('alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no tag matches the username', () => {
|
||||||
|
expect(extractUserTag([2], tagMap, 'alice')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when tags array is empty', () => {
|
||||||
|
expect(extractUserTag([], tagMap, 'alice')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches via sanitized form (email-style username)', () => {
|
||||||
|
const map = new Map([[1, 'user-example-com']]);
|
||||||
|
expect(extractUserTag([1], map, 'user@example.com')).toBe('user-example-com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getImportIssues', () => {
|
||||||
|
it('returns null for null input', () => {
|
||||||
|
expect(getImportIssues(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when state/status are benign', () => {
|
||||||
|
expect(getImportIssues({ trackedDownloadState: 'downloading', trackedDownloadStatus: 'ok' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns messages when state is importPending', () => {
|
||||||
|
const record = {
|
||||||
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
statusMessages: [{ messages: ['Sample needs repack'] }]
|
||||||
|
};
|
||||||
|
expect(getImportIssues(record)).toEqual(['Sample needs repack']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns title fallback when statusMessage has no messages array', () => {
|
||||||
|
const record = {
|
||||||
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
statusMessages: [{ title: 'No matching episodes' }]
|
||||||
|
};
|
||||||
|
expect(getImportIssues(record)).toEqual(['No matching episodes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes errorMessage alongside statusMessages', () => {
|
||||||
|
const record = {
|
||||||
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
statusMessages: [{ messages: ['Msg1'] }],
|
||||||
|
errorMessage: 'Disk full'
|
||||||
|
};
|
||||||
|
expect(getImportIssues(record)).toEqual(['Msg1', 'Disk full']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when statusMessages is empty and no errorMessage', () => {
|
||||||
|
const record = {
|
||||||
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'ok',
|
||||||
|
statusMessages: []
|
||||||
|
};
|
||||||
|
expect(getImportIssues(record)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns messages when trackedDownloadStatus is warning', () => {
|
||||||
|
const record = {
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
errorMessage: 'Low disk space'
|
||||||
|
};
|
||||||
|
expect(getImportIssues(record)).toEqual(['Low disk space']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns messages when trackedDownloadStatus is error', () => {
|
||||||
|
const record = {
|
||||||
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'error',
|
||||||
|
errorMessage: 'Cannot connect'
|
||||||
|
};
|
||||||
|
expect(getImportIssues(record)).toEqual(['Cannot connect']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSonarrLink', () => {
|
||||||
|
it('returns null for falsy series', () => {
|
||||||
|
expect(getSonarrLink(null)).toBeNull();
|
||||||
|
expect(getSonarrLink({})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when _instanceUrl is missing', () => {
|
||||||
|
expect(getSonarrLink({ titleSlug: 'my-show' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when titleSlug is missing', () => {
|
||||||
|
expect(getSonarrLink({ _instanceUrl: 'https://sonarr.test' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs the correct URL', () => {
|
||||||
|
const series = { _instanceUrl: 'https://sonarr.test', titleSlug: 'breaking-bad' };
|
||||||
|
expect(getSonarrLink(series)).toBe('https://sonarr.test/series/breaking-bad');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRadarrLink', () => {
|
||||||
|
it('returns null for falsy movie', () => {
|
||||||
|
expect(getRadarrLink(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('constructs the correct URL', () => {
|
||||||
|
const movie = { _instanceUrl: 'https://radarr.test', titleSlug: 'the-matrix-1999' };
|
||||||
|
expect(getRadarrLink(movie)).toBe('https://radarr.test/movie/the-matrix-1999');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canBlocklist', () => {
|
||||||
|
it('always returns true for admin', () => {
|
||||||
|
expect(canBlocklist({}, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when download has importIssues', () => {
|
||||||
|
expect(canBlocklist({ importIssues: ['issue'] }, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when importIssues is empty', () => {
|
||||||
|
expect(canBlocklist({ importIssues: [] }, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when download is not a qbittorrent torrent', () => {
|
||||||
|
expect(canBlocklist({ availability: '50' }, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for qbittorrent torrent that is too new', () => {
|
||||||
|
const download = {
|
||||||
|
qbittorrent: true,
|
||||||
|
addedOn: new Date().toISOString(), // just added
|
||||||
|
availability: '50'
|
||||||
|
};
|
||||||
|
expect(canBlocklist(download, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for old qbittorrent torrent with 100% availability', () => {
|
||||||
|
const download = {
|
||||||
|
qbittorrent: true,
|
||||||
|
addedOn: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
|
||||||
|
availability: '100'
|
||||||
|
};
|
||||||
|
expect(canBlocklist(download, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for old qbittorrent torrent with low availability', () => {
|
||||||
|
const download = {
|
||||||
|
qbittorrent: true,
|
||||||
|
addedOn: new Date(Date.now() - 7200000).toISOString(), // 2 hours ago
|
||||||
|
availability: '50'
|
||||||
|
};
|
||||||
|
expect(canBlocklist(download, false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractEpisode', () => {
|
||||||
|
it('returns null when season or episode is missing', () => {
|
||||||
|
expect(extractEpisode({})).toBeNull();
|
||||||
|
expect(extractEpisode({ episode: { seasonNumber: 1 } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts from nested episode object', () => {
|
||||||
|
const record = { episode: { seasonNumber: 2, episodeNumber: 5, title: 'The One' } };
|
||||||
|
expect(extractEpisode(record)).toEqual({ season: 2, episode: 5, title: 'The One' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to top-level seasonNumber/episodeNumber', () => {
|
||||||
|
const record = { episode: {}, seasonNumber: 3, episodeNumber: 10 };
|
||||||
|
expect(extractEpisode(record)).toEqual({ season: 3, episode: 10, title: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses nested episode values over top-level when both present', () => {
|
||||||
|
const record = { episode: { seasonNumber: 1, episodeNumber: 1, title: 'Nested' }, seasonNumber: 9, episodeNumber: 9 };
|
||||||
|
expect(extractEpisode(record)).toEqual({ season: 1, episode: 1, title: 'Nested' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gatherEpisodes', () => {
|
||||||
|
const records = [
|
||||||
|
{ title: 'show.s01e01.720p', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Pilot' } },
|
||||||
|
{ title: 'show.s01e02.720p', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Episode 2' } },
|
||||||
|
{ title: 'other.show.s02e01.720p', episode: { seasonNumber: 2, episodeNumber: 1, title: 'Other' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('returns matching episodes sorted by season then episode', () => {
|
||||||
|
const eps = gatherEpisodes('show.s01e01.720p', records);
|
||||||
|
expect(eps.length).toBeGreaterThan(0);
|
||||||
|
expect(eps[0].season).toBe(1);
|
||||||
|
expect(eps[0].episode).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates identical season/episode pairs', () => {
|
||||||
|
const dupeRecords = [
|
||||||
|
{ title: 'show.s01e01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Pilot' } },
|
||||||
|
{ title: 'show.s01e01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Pilot' } }
|
||||||
|
];
|
||||||
|
const eps = gatherEpisodes('show.s01e01', dupeRecords);
|
||||||
|
expect(eps.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no records match', () => {
|
||||||
|
const eps = gatherEpisodes('completely different title', records);
|
||||||
|
expect(eps).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty records', () => {
|
||||||
|
expect(gatherEpisodes('anything', [])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildTagBadges', () => {
|
||||||
|
it('returns badge with matchedUser when tag resolves via lowercase key', () => {
|
||||||
|
const embyUserMap = new Map([['alice', 'Alice']]);
|
||||||
|
const badges = buildTagBadges(['alice'], embyUserMap);
|
||||||
|
expect(badges).toEqual([{ label: 'alice', matchedUser: 'Alice' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns badge with matchedUser when tag resolves via sanitized key', () => {
|
||||||
|
const embyUserMap = new Map([['user-example-com', 'User']]);
|
||||||
|
const badges = buildTagBadges(['user@example.com'], embyUserMap);
|
||||||
|
expect(badges).toEqual([{ label: 'user@example.com', matchedUser: 'User' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns matchedUser: null for unknown tags', () => {
|
||||||
|
const embyUserMap = new Map();
|
||||||
|
const badges = buildTagBadges(['unknown'], embyUserMap);
|
||||||
|
expect(badges).toEqual([{ label: 'unknown', matchedUser: null }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty tag list', () => {
|
||||||
|
expect(buildTagBadges([], new Map())).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+8
-7
@@ -28,14 +28,15 @@ export default defineConfig({
|
|||||||
// Global thresholds only — per-file thresholds are avoided because V8's
|
// Global thresholds only — per-file thresholds are avoided because V8's
|
||||||
// coverage counting varies across Node versions (CI consistently reports
|
// coverage counting varies across Node versions (CI consistently reports
|
||||||
// ~10-15% lower than local for module-wrapper and require() lines).
|
// ~10-15% lower than local for module-wrapper and require() lines).
|
||||||
// The overall numbers reflect that dashboard.js and poller.js are large
|
// Thresholds updated after adding integration tests for dashboard.js,
|
||||||
// untested files; the security-critical files (auth, middleware, utils)
|
// emby.js, sonarr.js, radarr.js, and sabnzbd.js. The SSE /stream
|
||||||
// are well-covered by the 115 tests.
|
// endpoint and poller.js remain untested so thresholds are set
|
||||||
|
// conservatively to avoid CI flap from V8 coverage variance.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 22,
|
lines: 55,
|
||||||
functions: 12,
|
functions: 55,
|
||||||
branches: 8,
|
branches: 40,
|
||||||
statements: 20
|
statements: 55
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user