// 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' } ]); }); }); });