7d3e6e6a47
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
261 lines
7.9 KiB
JavaScript
261 lines
7.9 KiB
JavaScript
// 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);
|
|
});
|
|
});
|