9cffb96f29
- 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)
756 lines
26 KiB
JavaScript
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' }
|
|
]);
|
|
});
|
|
});
|
|
});
|