test: remediate test suite, enable skipped frontend/SSE tests, and add comprehensive unit tests
Build and Push Docker Image / build (push) Successful in 53s
Docs Check / Markdown lint (push) Successful in 1m36s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m31s
CI / Security audit (push) Successful in 2m55s
Docs Check / Mermaid diagram parse check (push) Successful in 3m24s
CI / Swagger Validation & Coverage (push) Successful in 3m30s
CI / Tests & coverage (push) Successful in 3m50s

- Deleted redundant unit test file tests/unit/dashboard.test.js
- Enabled skipped frontend DOM state and API tests in tests/frontend/state.test.js
- Fixed Supertest client socket abort exception in SSE stream integration tests using new graceful testClose parameter
- Consolidated duplicate helpers in server/routes/history.js and server/utils/arrRetrievers.js to unified services TagMatcher and DownloadAssembler
- Added comprehensive unit tests for loadSecrets.js, PollingSonarrRetriever.js, PollingRadarrRetriever.js, and ombiHelpers.js
- Achieved a 100% Vitest pass rate (834/834 tests) with robust code coverage
This commit is contained in:
2026-05-22 13:33:21 +01:00
parent d3d085d614
commit 4aa3590017
16 changed files with 1058 additions and 761 deletions
@@ -0,0 +1,198 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import nock from 'nock';
import PollingRadarrRetriever from '../../../server/clients/PollingRadarrRetriever';
describe('PollingRadarrRetriever', () => {
const config = {
id: 'radarr-test',
name: 'Test Radarr',
url: 'http://radarr-mock.test',
apiKey: 'mock-api-key'
};
let retriever;
beforeEach(() => {
retriever = new PollingRadarrRetriever(config);
nock.disableNetConnect();
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
it('should return correct type and instance ID', () => {
expect(retriever.getRetrieverType()).toBe('radarr');
expect(retriever.getInstanceId()).toBe('radarr-test');
});
describe('getTags', () => {
it('should fetch tags successfully', async () => {
const mockTags = [{ id: 1, label: 'tag1' }, { id: 2, label: 'tag2' }];
nock(config.url)
.get('/api/v3/tag')
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockTags);
const tags = await retriever.getTags();
expect(tags).toEqual(mockTags);
});
it('should return an empty array on error and log it', async () => {
nock(config.url)
.get('/api/v3/tag')
.reply(500, 'Internal Server Error');
const tags = await retriever.getTags();
expect(tags).toEqual([]);
});
});
describe('getQueue', () => {
it('should fetch queue in a single page if records count is less than 1000', async () => {
const mockQueueResponse = {
page: 1,
pageSize: 1000,
totalRecords: 2,
records: [
{ id: 1, title: 'Movie 1' },
{ id: 2, title: 'Movie 2' }
]
};
nock(config.url)
.get('/api/v3/queue')
.query({ includeMovie: 'true', page: 1, pageSize: 1000 })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockQueueResponse);
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(2);
expect(queue.records).toEqual(mockQueueResponse.records);
});
it('should paginate queue if the page size is exactly 1000', async () => {
const page1Records = Array.from({ length: 1000 }, (_, i) => ({ id: i, title: `Movie ${i}` }));
const page2Records = [{ id: 1000, title: 'Movie 1000' }];
nock(config.url)
.get('/api/v3/queue')
.query({ includeMovie: 'true', page: 1, pageSize: 1000 })
.reply(200, {
page: 1,
pageSize: 1000,
totalRecords: 1001,
records: page1Records
});
nock(config.url)
.get('/api/v3/queue')
.query({ includeMovie: 'true', page: 2, pageSize: 1000 })
.reply(200, {
page: 2,
pageSize: 1000,
totalRecords: 1001,
records: page2Records
});
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(1001);
expect(queue.records[1000]).toEqual(page2Records[0]);
});
it('should throw an error if the request fails', async () => {
nock(config.url)
.get('/api/v3/queue')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getQueue()).rejects.toThrow();
});
});
describe('getHistory', () => {
it('should fetch history with default parameters', async () => {
const mockHistoryResponse = {
page: 1,
pageSize: 100,
totalRecords: 2,
records: [
{ id: 1, eventType: 'grabbed' },
{ id: 2, eventType: 'downloadFolderImported' }
]
};
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 100, includeMovie: 'true' })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory();
expect(history.records).toHaveLength(2);
expect(history.records).toEqual(mockHistoryResponse.records);
});
it('should apply sorting and startDate filters from options', async () => {
const mockHistoryResponse = { page: 1, pageSize: 10, totalRecords: 0, records: [] };
nock(config.url)
.get('/api/v3/history')
.query({
page: 1,
pageSize: 10,
includeMovie: 'false',
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
})
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory({
pageSize: 10,
includeMovie: false,
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
});
expect(history.records).toEqual([]);
});
it('should paginate history when more pages are available up to maxPages', async () => {
const page1Records = Array.from({ length: 50 }, (_, i) => ({ id: i }));
const page2Records = Array.from({ length: 50 }, (_, i) => ({ id: 50 + i }));
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 50, includeMovie: 'true' })
.reply(200, {
page: 1,
pageSize: 50,
records: page1Records
});
nock(config.url)
.get('/api/v3/history')
.query({ page: 2, pageSize: 50, includeMovie: 'true' })
.reply(200, {
page: 2,
pageSize: 50,
records: page2Records
});
const history = await retriever.getHistory({ pageSize: 50, maxPages: 2 });
expect(history.records).toHaveLength(100);
});
it('should throw an error on API failure', async () => {
nock(config.url)
.get('/api/v3/history')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getHistory()).rejects.toThrow();
});
});
});
@@ -0,0 +1,200 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import nock from 'nock';
import PollingSonarrRetriever from '../../../server/clients/PollingSonarrRetriever';
describe('PollingSonarrRetriever', () => {
const config = {
id: 'sonarr-test',
name: 'Test Sonarr',
url: 'http://sonarr-mock.test',
apiKey: 'mock-api-key'
};
let retriever;
beforeEach(() => {
retriever = new PollingSonarrRetriever(config);
nock.disableNetConnect();
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
it('should return correct type and instance ID', () => {
expect(retriever.getRetrieverType()).toBe('sonarr');
expect(retriever.getInstanceId()).toBe('sonarr-test');
});
describe('getTags', () => {
it('should fetch tags successfully', async () => {
const mockTags = [{ id: 1, label: 'tag1' }, { id: 2, label: 'tag2' }];
nock(config.url)
.get('/api/v3/tag')
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockTags);
const tags = await retriever.getTags();
expect(tags).toEqual(mockTags);
});
it('should return an empty array on error and log it', async () => {
nock(config.url)
.get('/api/v3/tag')
.reply(500, 'Internal Server Error');
const tags = await retriever.getTags();
expect(tags).toEqual([]);
});
});
describe('getQueue', () => {
it('should fetch queue in a single page if records count is less than 1000', async () => {
const mockQueueResponse = {
page: 1,
pageSize: 1000,
totalRecords: 2,
records: [
{ id: 1, title: 'Item 1' },
{ id: 2, title: 'Item 2' }
]
};
nock(config.url)
.get('/api/v3/queue')
.query({ includeSeries: 'true', includeEpisode: 'true', page: 1, pageSize: 1000 })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockQueueResponse);
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(2);
expect(queue.records).toEqual(mockQueueResponse.records);
});
it('should paginate queue if the page size is exactly 1000', async () => {
const page1Records = Array.from({ length: 1000 }, (_, i) => ({ id: i, title: `Item ${i}` }));
const page2Records = [{ id: 1000, title: 'Item 1000' }];
nock(config.url)
.get('/api/v3/queue')
.query({ includeSeries: 'true', includeEpisode: 'true', page: 1, pageSize: 1000 })
.reply(200, {
page: 1,
pageSize: 1000,
totalRecords: 1001,
records: page1Records
});
nock(config.url)
.get('/api/v3/queue')
.query({ includeSeries: 'true', includeEpisode: 'true', page: 2, pageSize: 1000 })
.reply(200, {
page: 2,
pageSize: 1000,
totalRecords: 1001,
records: page2Records
});
const queue = await retriever.getQueue();
expect(queue.records).toHaveLength(1001);
expect(queue.records[1000]).toEqual(page2Records[0]);
});
it('should throw an error if the request fails', async () => {
nock(config.url)
.get('/api/v3/queue')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getQueue()).rejects.toThrow();
});
});
describe('getHistory', () => {
it('should fetch history with default parameters', async () => {
const mockHistoryResponse = {
page: 1,
pageSize: 100,
totalRecords: 2,
records: [
{ id: 1, eventType: 'grabbed' },
{ id: 2, eventType: 'downloadFolderImported' }
]
};
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 100, includeSeries: 'true', includeEpisode: 'true' })
.matchHeader('X-Api-Key', config.apiKey)
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory();
expect(history.records).toHaveLength(2);
expect(history.records).toEqual(mockHistoryResponse.records);
});
it('should apply sorting and startDate filters from options', async () => {
const mockHistoryResponse = { page: 1, pageSize: 10, totalRecords: 0, records: [] };
nock(config.url)
.get('/api/v3/history')
.query({
page: 1,
pageSize: 10,
includeSeries: 'false',
includeEpisode: 'false',
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
})
.reply(200, mockHistoryResponse);
const history = await retriever.getHistory({
pageSize: 10,
includeSeries: false,
includeEpisode: false,
sortKey: 'date',
sortDir: 'descending',
startDate: '2026-05-22T00:00:00Z'
});
expect(history.records).toEqual([]);
});
it('should paginate history when more pages are available up to maxPages', async () => {
const page1Records = Array.from({ length: 50 }, (_, i) => ({ id: i }));
const page2Records = Array.from({ length: 50 }, (_, i) => ({ id: 50 + i }));
nock(config.url)
.get('/api/v3/history')
.query({ page: 1, pageSize: 50, includeSeries: 'true', includeEpisode: 'true' })
.reply(200, {
page: 1,
pageSize: 50,
records: page1Records
});
nock(config.url)
.get('/api/v3/history')
.query({ page: 2, pageSize: 50, includeSeries: 'true', includeEpisode: 'true' })
.reply(200, {
page: 2,
pageSize: 50,
records: page2Records
});
const history = await retriever.getHistory({ pageSize: 50, maxPages: 2 });
expect(history.records).toHaveLength(100);
});
it('should throw an error on API failure', async () => {
nock(config.url)
.get('/api/v3/history')
.query(true)
.reply(500, 'Server Error');
await expect(retriever.getHistory()).rejects.toThrow();
});
});
});
-492
View File
@@ -1,492 +0,0 @@
// 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([]);
});
});
+122
View File
@@ -0,0 +1,122 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect } from 'vitest';
const {
extractRequestedUser,
filterRequestsByUser
} = require('../../server/utils/ombiHelpers');
describe('ombiHelpers', () => {
describe('extractRequestedUser', () => {
it('returns empty string if request is null or undefined', () => {
expect(extractRequestedUser(null)).toBe('');
expect(extractRequestedUser(undefined)).toBe('');
});
it('returns requestedUser if requestedUser is a string', () => {
const req = { requestedUser: 'testuser', requestedByAlias: 'alias' };
expect(extractRequestedUser(req)).toBe('testuser');
});
it('falls back to requestedByAlias if requestedUser is missing', () => {
const req = { requestedByAlias: 'aliasuser' };
expect(extractRequestedUser(req)).toBe('aliasuser');
});
it('returns alias from requestedUser object if present', () => {
const req = {
requestedUser: {
alias: 'alias_val',
userAlias: 'userAlias_val',
userName: 'userName_val',
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('alias_val');
});
it('returns userAlias from requestedUser object if alias is missing', () => {
const req = {
requestedUser: {
userAlias: 'userAlias_val',
userName: 'userName_val',
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('userAlias_val');
});
it('returns userName from requestedUser object if alias/userAlias are missing', () => {
const req = {
requestedUser: {
userName: 'userName_val',
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('userName_val');
});
it('returns normalizedUserName from requestedUser object if other fields are missing', () => {
const req = {
requestedUser: {
normalizedUserName: 'normalized_val'
}
};
expect(extractRequestedUser(req)).toBe('normalized_val');
});
it('falls back to requestedByAlias when requestedUser is empty object {} (bug fix)', () => {
const req = {
requestedUser: {},
requestedByAlias: 'fallback_alias'
};
expect(extractRequestedUser(req)).toBe('fallback_alias');
});
it('returns empty string if requestedUser is empty object {} and requestedByAlias is missing', () => {
const req = {
requestedUser: {}
};
expect(extractRequestedUser(req)).toBe('');
});
});
describe('filterRequestsByUser', () => {
const movie1 = { id: 1, requestedUser: { userName: 'user1' }, type: 'movie' };
const movie2 = { id: 2, requestedUser: { userName: 'user2' }, type: 'movie' };
const tv1 = { id: 3, requestedUser: { alias: 'User1' }, type: 'tv' };
it('returns empty array if requests input is not an array', () => {
expect(filterRequestsByUser(null, 'user1', false)).toEqual([]);
expect(filterRequestsByUser({}, 'user1', false)).toEqual([]);
});
it('returns all requests unmodified if showAll is true', () => {
const requests = [movie1, movie2];
expect(filterRequestsByUser(requests, 'user1', true)).toEqual(requests);
});
it('returns all requests unmodified if username is falsy or missing', () => {
const requests = [movie1, movie2];
expect(filterRequestsByUser(requests, '', false)).toEqual(requests);
expect(filterRequestsByUser(requests, null, false)).toEqual(requests);
});
it('filters requests correctly for a specific user', () => {
const requests = [movie1, movie2, tv1];
const result = filterRequestsByUser(requests, 'user1', false);
expect(result).toHaveLength(2);
expect(result).toContainEqual(movie1);
expect(result).toContainEqual(tv1);
expect(result).not.toContainEqual(movie2);
});
it('performs case-insensitive filtering', () => {
const requests = [movie1, movie2, tv1];
const result = filterRequestsByUser(requests, 'USER1', false);
expect(result).toHaveLength(2);
expect(result).toContainEqual(movie1);
expect(result).toContainEqual(tv1);
});
});
});
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import loadSecrets from '../../../server/utils/loadSecrets';
describe('loadSecrets utility', () => {
let originalEnv;
let exitSpy;
beforeEach(() => {
originalEnv = { ...process.env };
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
vi.spyOn(fs, 'readFileSync');
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
it('does nothing if no _FILE env variables are set', () => {
// Ensure mappings are not in env
delete process.env.COOKIE_SECRET_FILE;
delete process.env.COOKIE_SECRET;
loadSecrets();
expect(fs.readFileSync).not.toHaveBeenCalled();
expect(process.env.COOKIE_SECRET).toBeUndefined();
expect(exitSpy).not.toHaveBeenCalled();
});
it('loads secrets successfully from a valid file', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
delete process.env.COOKIE_SECRET;
vi.mocked(fs.readFileSync).mockReturnValue(' super_secret_value \n');
loadSecrets();
expect(fs.readFileSync).toHaveBeenCalledWith('/path/to/cookie_secret', 'utf8');
expect(process.env.COOKIE_SECRET).toBe('super_secret_value');
expect(exitSpy).not.toHaveBeenCalled();
});
it('logs a warning if both standard env and _FILE env are set', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
process.env.COOKIE_SECRET = 'existing_value';
vi.mocked(fs.readFileSync).mockReturnValue('new_value');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadSecrets();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Both COOKIE_SECRET and COOKIE_SECRET_FILE are set')
);
expect(process.env.COOKIE_SECRET).toBe('new_value');
expect(exitSpy).not.toHaveBeenCalled();
});
it('logs a warning and skips loading if file is empty', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
delete process.env.COOKIE_SECRET;
vi.mocked(fs.readFileSync).mockReturnValue(' \n ');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
loadSecrets();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('COOKIE_SECRET_FILE points to an empty file')
);
expect(process.env.COOKIE_SECRET).toBeUndefined();
expect(exitSpy).not.toHaveBeenCalled();
});
it('exits with status 1 if file reading fails', () => {
process.env.COOKIE_SECRET_FILE = '/path/to/cookie_secret';
delete process.env.COOKIE_SECRET;
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('Permission denied');
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
loadSecrets();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to read COOKIE_SECRET_FILE')
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});