Files
gronod df5328349b
Build and Push Docker Image / build (push) Successful in 1m46s
Docs Check / Markdown lint (push) Successful in 2m19s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m53s
CI / Security audit (push) Successful in 3m23s
Docs Check / Mermaid diagram parse check (push) Successful in 3m55s
CI / Swagger Validation & Coverage (push) Successful in 4m18s
CI / Tests & coverage (push) Successful in 4m36s
chore: bump version to 1.7.34 and update CHANGELOG and docs
2026-05-28 18:15:27 +01:00

1216 lines
48 KiB
JavaScript

// 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', 'poll:ombi-requests'
];
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
// ---------------------------------------------------------------------------
beforeEach(() => {
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' }]);
seedEmptyCache();
});
afterEach(() => {
nock.cleanAll();
invalidatePollCache();
cache.invalidate('emby:users');
delete process.env.EMBY_URL;
delete process.env.SONARR_INSTANCES;
delete process.env.RADARR_INSTANCES;
});
// ---------------------------------------------------------------------------
// 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.arrLink).toBe(SONARR_BASE + '/series/admin-show');
expect(dl.downloadPath).toBeDefined();
});
it('includes ARR ID fields for non-admin user (for blocklist functionality)', 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();
// ARR IDs are now exposed to non-admins for blocklist functionality
expect(dl.arrQueueId).toBe(1001);
expect(dl.arrType).toBe('sonarr');
// But sensitive fields remain admin-only
expect(dl.arrInstanceKey).toBeUndefined();
expect(dl.arrLink).toBeUndefined();
expect(dl.downloadPath).toBeUndefined();
expect(dl.targetPath).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);
});
});
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
it('decorates active series downloads with Sonarr links for administrator', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Seed cache: queue record exists and matches SABnzbd slot
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);
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
nock(SONARR_BASE)
.get('/api/v3/series')
.reply(200, [
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
]);
// Mock Radarr /api/v3/movie response
nock(RADARR_BASE)
.get('/api/v3/movie')
.reply(200, []);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const downloads = res.body.downloads;
const dl = downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
expect(dl.arrType).toBe('sonarr');
});
});
});
// ---------------------------------------------------------------------------
// GET /api/status
// ---------------------------------------------------------------------------
describe('GET /api/status', () => {
it('returns 401 when not authenticated', async () => {
const app = createApp({ skipRateLimits: true });
const res = await request(app).get('/api/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/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/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();
});
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/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/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/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 without qualifying conditions', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok'
}] }, CACHE_TTL);
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(/permission denied/i);
});
it('returns 403 for non-admin when download not found in arr queue cache', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Cache is already seeded empty by beforeEach; no queue record with id=1 exists
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, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/download not found/i);
});
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Seed cache: queue record with import issues — qualifies non-admin for blocklist
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Import error 1'] }]
}] }, CACHE_TTL);
// Mock Sonarr DELETE and command endpoints
nock(SONARR_BASE)
.delete('/api/v3/queue/1')
.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: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
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);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
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);
// Seed the Radarr queue cache so the permission lookup finds the record
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
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);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
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);
});
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 })
.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', arrSeriesId: 42, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(SONARR_BASE)
.post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] })
.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', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => {
// Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always
// a string, but the queue record id from the Radarr/Sonarr API cache is a number.
// Without String() casting the === comparison fails and returns 403.
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed Radarr queue with a numeric id (as Radarr API returns it)
cache.set('poll:radarr-queue', { records: [{
id: 9050001,
title: 'Project.Hail.Mary.2026.2160p',
movieId: 77,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: RADARR_BASE,
_instanceKey: 'rk'
}] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/9050001')
.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)
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
// ---------------------------------------------------------------------------
// GET /api/dashboard/stream (SSE)
// ---------------------------------------------------------------------------
const OMBI_STREAM_FIXTURE = {
movie: [
{ id: 1, title: 'Movie 1', requestedUser: { userName: 'alice' } },
{ id: 2, title: 'Movie 2', requestedUser: { userName: 'bob' } }
],
tv: [
{ id: 3, title: 'TV 1', requestedUser: { userName: 'alice' } },
{ id: 4, title: 'TV 2', requestedUser: { userName: 'bob' } }
]
};
describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () => {
let appInstance;
beforeEach(() => {
appInstance = createApp({ skipRateLimits: true });
// Seed basic cached values to prevent on-demand poll
cache.set('poll:sab-queue', { 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);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
});
it('filters Ombi requests by user when showAll is false', async () => {
const { cookies } = await loginAs(appInstance);
// Explicitly seed the cache to ensure we have the fixtures in memory
cache.set('poll:sab-queue', { 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);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
// Parse the data payload
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
expect(data.user).toBe('alice');
expect(data.ombiRequests.movie).toHaveLength(1);
expect(data.ombiRequests.movie[0].title).toBe('Movie 1');
expect(data.ombiRequests.tv).toHaveLength(1);
expect(data.ombiRequests.tv[0].title).toBe('TV 1');
});
it('returns all Ombi requests when admin with showAll is true', async () => {
const { cookies } = await loginAs(appInstance, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Explicitly seed the cache to ensure we have the fixtures in memory
cache.set('poll:sab-queue', { 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);
cache.set('poll:ombi-requests', OMBI_STREAM_FIXTURE, CACHE_TTL);
nock(EMBY_BASE)
.get('/Users')
.reply(200, [EMBY_USER, EMBY_ADMIN_USER]);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ showAll: 'true', testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
// Parse the data payload
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
expect(data.user).toBe('admin');
expect(data.ombiRequests.movie).toHaveLength(2);
expect(data.ombiRequests.tv).toHaveLength(2);
});
it('verifies SSE payload structure contract against the frontend schema', async () => {
const { cookies } = await loginAs(appInstance);
const res = await request(appInstance)
.get('/api/dashboard/stream')
.query({ testClose: 'true' })
.set('Cookie', cookies);
expect(res.status).toBe(200);
const text = res.text;
expect(text).toContain('data:');
const dataStr = text.substring(text.indexOf('{'));
const data = JSON.parse(dataStr.trim());
// Payload Contract Validation
expect(data).toHaveProperty('user');
expect(data).toHaveProperty('isAdmin');
expect(data).toHaveProperty('downloads');
expect(data).toHaveProperty('downloadClients');
expect(data).toHaveProperty('ombiRequests');
expect(data).toHaveProperty('ombiBaseUrl');
expect(Array.isArray(data.downloads)).toBe(true);
expect(Array.isArray(data.downloadClients)).toBe(true);
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
});
it('sends heartbeat comment over active stream and cleans up on close', async () => {
vi.useFakeTimers();
// 1. Get the route handler from the dashboard router stack
const dashboardRouter = require('../../server/routes/dashboard.js');
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
// Get the final handler (after requireAuth middleware)
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
// 2. Setup mock req and res
const mockUser = { name: 'Alice', isAdmin: false };
const reqOnCallbacks = {};
const mockReq = {
user: mockUser,
query: { showAll: 'false', testClose: 'false' },
on: vi.fn((event, cb) => {
reqOnCallbacks[event] = cb;
})
};
const resWrites = [];
const mockRes = {
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn((data) => {
resWrites.push(data);
}),
end: vi.fn()
};
// 3. Call the handler
await streamHandler(mockReq, mockRes);
// Initial payload should be written
expect(resWrites.length).toBeGreaterThan(0);
expect(resWrites[0]).toContain('data:');
// 4. Advance time by 25s to trigger the heartbeat setInterval
vi.advanceTimersByTime(25000);
// Check that heartbeat was written
expect(resWrites).toContain(': heartbeat\n\n');
// 5. Simulate client disconnect by triggering the 'close' event callback
expect(reqOnCallbacks['close']).toBeDefined();
reqOnCallbacks['close']();
// Check that advancing time again does NOT write another heartbeat
const beforeLength = resWrites.length;
vi.advanceTimersByTime(25000);
expect(resWrites.length).toBe(beforeLength); // No new writes!
vi.useRealTimers();
});
});