Files
sofarr/tests/unit/services/DownloadAssembler.test.js
T
gronod 9cffb96f29
Build and Push Docker Image / build (push) Successful in 45s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m9s
CI / Security audit (push) Successful in 1m24s
CI / Tests & coverage (push) Failing after 1m37s
Extract DownloadAssembler service from dashboard routes
- Create server/services/DownloadAssembler.js with 7 pure functions:
  - getCoverArt, getImportIssues, getSonarrLink, getRadarrLink
  - canBlocklist, extractEpisode, gatherEpisodes
- Update server/routes/dashboard.js to use DownloadAssembler
- Add comprehensive unit tests (73 tests covering edge cases)
- Fix null check in extractEpisode function
- All tests passing: DownloadAssembler (73/73), TagMatcher (26/26)
2026-05-20 22:32:09 +01:00

756 lines
26 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const DownloadAssembler = require('../../../server/services/DownloadAssembler');
describe('DownloadAssembler', () => {
describe('getCoverArt', () => {
it('returns null when item is null or undefined', () => {
expect(DownloadAssembler.getCoverArt(null)).toBeNull();
expect(DownloadAssembler.getCoverArt(undefined)).toBeNull();
});
it('returns null when item has no images array', () => {
expect(DownloadAssembler.getCoverArt({})).toBeNull();
expect(DownloadAssembler.getCoverArt({ images: null })).toBeNull();
});
it('returns poster URL from remoteUrl', () => {
const item = {
images: [
{ coverType: 'poster', remoteUrl: 'http://example.com/poster.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster.jpg');
});
it('returns poster URL from url when remoteUrl is missing', () => {
const item = {
images: [
{ coverType: 'poster', url: 'http://example.com/poster.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster.jpg');
});
it('returns fanart as fallback when no poster', () => {
const item = {
images: [
{ coverType: 'banner', url: 'http://example.com/banner.jpg' },
{ coverType: 'fanart', remoteUrl: 'http://example.com/fanart.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/fanart.jpg');
});
it('returns null when no poster or fanart found', () => {
const item = {
images: [
{ coverType: 'banner', url: 'http://example.com/banner.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBeNull();
});
it('prefers remoteUrl over url for poster', () => {
const item = {
images: [
{ coverType: 'poster', url: 'http://example.com/poster-url.jpg', remoteUrl: 'http://example.com/poster-remote.jpg' }
]
};
expect(DownloadAssembler.getCoverArt(item)).toBe('http://example.com/poster-remote.jpg');
});
});
describe('getImportIssues', () => {
it('returns null when queueRecord is null or undefined', () => {
expect(DownloadAssembler.getImportIssues(null)).toBeNull();
expect(DownloadAssembler.getImportIssues(undefined)).toBeNull();
});
it('returns null when state is not importPending and status is not warning/error', () => {
const record = {
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok'
};
expect(DownloadAssembler.getImportIssues(record)).toBeNull();
});
it('returns null when state is importPending but no messages', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: []
};
expect(DownloadAssembler.getImportIssues(record)).toBeNull();
});
it('returns messages when state is importPending with statusMessages', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ messages: ['Error 1', 'Error 2'] },
{ title: 'Warning message' }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Error 1', 'Error 2', 'Warning message']);
});
it('returns messages when status is warning', () => {
const record = {
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'warning',
statusMessages: [
{ messages: ['Warning 1'] }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Warning 1']);
});
it('returns messages when status is error', () => {
const record = {
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'error',
statusMessages: [
{ messages: ['Error 1'] }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Error 1']);
});
it('includes errorMessage when present', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
errorMessage: 'Main error message'
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Main error message']);
});
it('combines statusMessages and errorMessage', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ messages: ['Error 1'] }
],
errorMessage: 'Main error'
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Error 1', 'Main error']);
});
it('handles empty statusMessages array with title', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ title: 'Title only' }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Title only']);
});
it('handles statusMessages with empty messages array', () => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'ok',
statusMessages: [
{ messages: [] }
]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toBeNull();
});
// Test all status/state combinations
it('returns null for all combinations when no messages', () => {
const states = ['importPending', 'downloading', 'queued', 'completed'];
const statuses = ['warning', 'error', 'ok', 'downloading'];
states.forEach(state => {
statuses.forEach(status => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: status,
statusMessages: []
};
const result = DownloadAssembler.getImportIssues(record);
// Only importPending, warning, or error should potentially return issues
// But without messages, all should return null
expect(result).toBeNull();
});
});
});
it('returns messages for importPending state regardless of status', () => {
const statuses = ['ok', 'warning', 'error', 'downloading'];
statuses.forEach(status => {
const record = {
trackedDownloadState: 'importPending',
trackedDownloadStatus: status,
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Test message']);
});
});
it('returns messages for warning status regardless of state', () => {
const states = ['importPending', 'downloading', 'queued', 'completed'];
states.forEach(state => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Test message']);
});
});
it('returns messages for error status regardless of state', () => {
const states = ['importPending', 'downloading', 'queued', 'completed'];
states.forEach(state => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: 'error',
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toEqual(['Test message']);
});
});
it('returns null for non-matching state/status combinations', () => {
const combinations = [
{ state: 'downloading', status: 'ok' },
{ state: 'queued', status: 'downloading' },
{ state: 'completed', status: 'completed' }
];
combinations.forEach(({ state, status }) => {
const record = {
trackedDownloadState: state,
trackedDownloadStatus: status,
statusMessages: [{ messages: ['Test message'] }]
};
const result = DownloadAssembler.getImportIssues(record);
expect(result).toBeNull();
});
});
});
describe('getSonarrLink', () => {
it('returns null when series is null or undefined', () => {
expect(DownloadAssembler.getSonarrLink(null)).toBeNull();
expect(DownloadAssembler.getSonarrLink(undefined)).toBeNull();
});
it('returns null when series is missing _instanceUrl', () => {
expect(DownloadAssembler.getSonarrLink({ titleSlug: 'test' })).toBeNull();
});
it('returns null when series is missing titleSlug', () => {
expect(DownloadAssembler.getSonarrLink({ _instanceUrl: 'http://example.com' })).toBeNull();
});
it('returns correct link when both _instanceUrl and titleSlug present', () => {
const series = {
_instanceUrl: 'http://example.com',
titleSlug: 'test-series'
};
expect(DownloadAssembler.getSonarrLink(series)).toBe('http://example.com/series/test-series');
});
it('does not handle trailing slash in _instanceUrl (simple concatenation)', () => {
const series = {
_instanceUrl: 'http://example.com/',
titleSlug: 'test-series'
};
expect(DownloadAssembler.getSonarrLink(series)).toBe('http://example.com//series/test-series');
});
});
describe('getRadarrLink', () => {
it('returns null when movie is null or undefined', () => {
expect(DownloadAssembler.getRadarrLink(null)).toBeNull();
expect(DownloadAssembler.getRadarrLink(undefined)).toBeNull();
});
it('returns null when movie is missing _instanceUrl', () => {
expect(DownloadAssembler.getRadarrLink({ titleSlug: 'test' })).toBeNull();
});
it('returns null when movie is missing titleSlug', () => {
expect(DownloadAssembler.getRadarrLink({ _instanceUrl: 'http://example.com' })).toBeNull();
});
it('returns correct link when both _instanceUrl and titleSlug present', () => {
const movie = {
_instanceUrl: 'http://example.com',
titleSlug: 'test-movie'
};
expect(DownloadAssembler.getRadarrLink(movie)).toBe('http://example.com/movie/test-movie');
});
it('does not handle trailing slash in _instanceUrl (simple concatenation)', () => {
const movie = {
_instanceUrl: 'http://example.com/',
titleSlug: 'test-movie'
};
expect(DownloadAssembler.getRadarrLink(movie)).toBe('http://example.com//movie/test-movie');
});
});
describe('canBlocklist', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns true for admin users', () => {
const download = {};
expect(DownloadAssembler.canBlocklist(download, true)).toBe(true);
});
it('returns true for non-admin with importIssues', () => {
const download = {
importIssues: ['Error 1', 'Error 2']
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns true for non-admin with empty importIssues array', () => {
const download = {
importIssues: []
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin without importIssues and missing qbittorrent data', () => {
const download = {};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin with qbittorrent but missing addedOn', () => {
const download = {
qbittorrent: {},
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin with qbittorrent but missing availability', () => {
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns true for non-admin when torrent is old and availability < 100', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 25 hours ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns false for non-admin when torrent is old but availability >= 100', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 25 hours ago
availability: '100'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns false for non-admin when torrent is new even with low availability', () => {
vi.setSystemTime(new Date('2024-01-01T00:30:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 30 minutes ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('returns true for non-admin when torrent is exactly 1 hour old with low availability', () => {
vi.setSystemTime(new Date('2024-01-01T01:00:01Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 1 hour + 1 second ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns false for non-admin when torrent is just under 1 hour old with low availability', () => {
vi.setSystemTime(new Date('2024-01-01T00:59:59Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 59 minutes 59 seconds ago
availability: '50'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(false);
});
it('handles availability as number instead of string', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: 50
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('handles availability as decimal', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: '99.9'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns true for non-admin with availability exactly 0', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: '0'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('returns true for non-admin with availability 99.99', () => {
vi.setSystemTime(new Date('2024-01-02T01:00:00Z'));
const download = {
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z',
availability: '99.99'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
it('prioritizes importIssues over age/availability check', () => {
vi.setSystemTime(new Date('2024-01-01T00:30:00Z'));
const download = {
importIssues: ['Error'],
qbittorrent: {},
addedOn: '2024-01-01T00:00:00Z', // 30 minutes ago
availability: '100'
};
expect(DownloadAssembler.canBlocklist(download, false)).toBe(true);
});
});
describe('extractEpisode', () => {
it('returns null when record is null or undefined', () => {
expect(DownloadAssembler.extractEpisode(null)).toBeNull();
expect(DownloadAssembler.extractEpisode(undefined)).toBeNull();
});
it('returns null when season and episode are both missing', () => {
const record = {};
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
});
it('extracts from episode.seasonNumber and episode.episodeNumber', () => {
const record = {
episode: {
seasonNumber: 1,
episodeNumber: 5,
title: 'Test Episode'
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 1,
episode: 5,
title: 'Test Episode'
});
});
it('extracts from record.seasonNumber and record.episodeNumber when episode is missing', () => {
const record = {
seasonNumber: 2,
episodeNumber: 10,
title: 'Test'
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 2,
episode: 10,
title: null
});
});
it('prioritizes episode.seasonNumber over record.seasonNumber', () => {
const record = {
seasonNumber: 1,
episodeNumber: 5,
episode: {
seasonNumber: 3,
episodeNumber: 7,
title: 'Test Episode'
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 3,
episode: 7,
title: 'Test Episode'
});
});
it('handles null seasonNumber in episode', () => {
const record = {
episode: {
seasonNumber: null,
episodeNumber: 5,
title: 'Test'
},
seasonNumber: 2
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 2,
episode: 5,
title: 'Test'
});
});
it('handles null episodeNumber in episode', () => {
const record = {
episode: {
seasonNumber: 2,
episodeNumber: null,
title: 'Test'
},
episodeNumber: 10
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 2,
episode: 10,
title: 'Test'
});
});
it('returns null when only season is present', () => {
const record = {
episode: {
seasonNumber: 1,
title: 'Test'
}
};
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
});
it('returns null when only episode is present', () => {
const record = {
episode: {
episodeNumber: 5,
title: 'Test'
}
};
expect(DownloadAssembler.extractEpisode(record)).toBeNull();
});
it('handles title as null when not present', () => {
const record = {
episode: {
seasonNumber: 1,
episodeNumber: 5
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 1,
episode: 5,
title: null
});
});
it('handles zero values for season and episode', () => {
const record = {
episode: {
seasonNumber: 0,
episodeNumber: 0,
title: 'Test'
}
};
expect(DownloadAssembler.extractEpisode(record)).toEqual({
season: 0,
episode: 0,
title: 'Test'
});
});
});
describe('gatherEpisodes', () => {
it('returns empty array when no records', () => {
const result = DownloadAssembler.gatherEpisodes('test', []);
expect(result).toEqual([]);
});
it('matches all records when titleLower is empty (empty string is included in any string)', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('matches records by title inclusion', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Other Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } },
{ title: 'Test Show S01E03', episode: { seasonNumber: 1, episodeNumber: 3, title: 'Ep 3' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 3, title: 'Ep 3' }
]);
});
it('matches records by sourceTitle inclusion', () => {
const records = [
{ sourceTitle: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ sourceTitle: 'Other Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('matches when titleLower is included in record title', () => {
const records = [
{ title: 'Test Show S01E01 Extra', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show s01e01', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('deduplicates episodes by season and episode number', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1 Duplicate' } },
{ title: 'Test Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 2, title: 'Ep 2' }
]);
});
it('sorts episodes by season then episode', () => {
const records = [
{ title: 'Test Show S02E05', episode: { seasonNumber: 2, episodeNumber: 5, title: 'Ep 5' } },
{ title: 'Test Show S01E10', episode: { seasonNumber: 1, episodeNumber: 10, title: 'Ep 10' } },
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Test Show S02E01', episode: { seasonNumber: 2, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 10, title: 'Ep 10' },
{ season: 2, episode: 1, title: 'Ep 1' },
{ season: 2, episode: 5, title: 'Ep 5' }
]);
});
it('handles case insensitivity', () => {
const records = [
{ title: 'TEST SHOW S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('skips records that cannot extract episode info', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } },
{ title: 'Test Show No Episode' },
{ title: 'Test Show S01E02', episode: { seasonNumber: 1, episodeNumber: 2, title: 'Ep 2' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' },
{ season: 1, episode: 2, title: 'Ep 2' }
]);
});
it('handles records with missing title and sourceTitle', () => {
const records = [
{ episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([]);
});
it('keeps first occurrence when deduplicating', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'First' } },
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Second' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'First' }
]);
});
it('handles multiple seasons', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'S1E1' } },
{ title: 'Test Show S02E01', episode: { seasonNumber: 2, episodeNumber: 1, title: 'S2E1' } },
{ title: 'Test Show S03E01', episode: { seasonNumber: 3, episodeNumber: 1, title: 'S3E1' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'S1E1' },
{ season: 2, episode: 1, title: 'S2E1' },
{ season: 3, episode: 1, title: 'S3E1' }
]);
});
it('handles special characters in titles', () => {
const records = [
{ title: 'Test.Show.S01E01.HDTV.x264', episode: { seasonNumber: 1, episodeNumber: 1, title: 'Ep 1' } }
];
const result = DownloadAssembler.gatherEpisodes('test.show.s01e01', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'Ep 1' }
]);
});
it('deduplicates across different record types', () => {
const records = [
{ title: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'From Title' } },
{ sourceTitle: 'Test Show S01E01', episode: { seasonNumber: 1, episodeNumber: 1, title: 'From Source' } }
];
const result = DownloadAssembler.gatherEpisodes('test show', records);
expect(result).toEqual([
{ season: 1, episode: 1, title: 'From Title' }
]);
});
});
});