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
493 lines
17 KiB
JavaScript
493 lines
17 KiB
JavaScript
// 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([]);
|
|
});
|
|
});
|