diff --git a/server/services/DownloadMatcher.js b/server/services/DownloadMatcher.js index c741f33..1079fdd 100644 --- a/server/services/DownloadMatcher.js +++ b/server/services/DownloadMatcher.js @@ -629,6 +629,7 @@ module.exports = { buildSeriesMapFromRecords, buildMoviesMapFromRecords, getSlotStatusAndSpeed, + addOmbiMatching, matchSabSlots, matchSabHistory, matchTorrents diff --git a/tests/unit/arrRetrievers.test.js b/tests/unit/arrRetrievers.test.js new file mode 100644 index 0000000..d2e4dcb --- /dev/null +++ b/tests/unit/arrRetrievers.test.js @@ -0,0 +1,185 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// Mock the logger and config before importing the registry +vi.mock('../../server/utils/logger', () => ({ + logToFile: vi.fn() +})); + +// Mock config to return test data +const mockOmbiInstances = [ + { id: 'ombi-test', name: 'Test Ombi', url: 'http://localhost:5000', apiKey: 'test-key' } +]; + +const mockSonarrInstances = [ + { id: 'sonarr-test', name: 'Test Sonarr', url: 'http://localhost:8989', apiKey: 'sonarr-key' } +]; + +const mockRadarrInstances = [ + { id: 'radarr-test', name: 'Test Radarr', url: 'http://localhost:7878', apiKey: 'radarr-key' } +]; + +vi.mock('../../server/utils/config', () => ({ + getSonarrInstances: vi.fn(() => mockSonarrInstances), + getRadarrInstances: vi.fn(() => mockRadarrInstances), + getOmbiInstances: vi.fn(() => mockOmbiInstances) +})); + +// Import the registry after mocking +const arrRetrieverRegistry = require('../../server/utils/arrRetrievers'); +const OmbiRetriever = require('../../server/clients/OmbiRetriever'); +const ArrRetriever = require('../../server/clients/ArrRetriever'); + +describe('arrRetrieverRegistry', () => { + beforeEach(() => { + // Reset the registry state before each test + arrRetrieverRegistry.retrievers.clear(); + arrRetrieverRegistry.initialized = false; + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe('initialize', () => { + it('should initialize without errors', async () => { + await expect(arrRetrieverRegistry.initialize()).resolves.not.toThrow(); + }); + + it('should not reinitialize if already initialized', async () => { + await arrRetrieverRegistry.initialize(); + const firstRetrieverCount = arrRetrieverRegistry.getOmbiRetrievers().length; + + await arrRetrieverRegistry.initialize(); + const secondRetrieverCount = arrRetrieverRegistry.getOmbiRetrievers().length; + + expect(secondRetrieverCount).toBe(firstRetrieverCount); + }); + }); + + describe('getOmbiRetrievers', () => { + it('should return Ombi retrievers only', async () => { + await arrRetrieverRegistry.initialize(); + + const ombiRetrievers = arrRetrieverRegistry.getOmbiRetrievers(); + expect(ombiRetrievers.length).toBeGreaterThanOrEqual(0); + ombiRetrievers.forEach(retriever => { + expect(retriever.getRetrieverType()).toBe('ombi'); + }); + }); + + }); + + describe('getOmbiRequests', () => { + it('should return movie and TV request arrays', async () => { + await arrRetrieverRegistry.initialize(); + + const result = await arrRetrieverRegistry.getOmbiRequests(); + + expect(result).toHaveProperty('movie'); + expect(result).toHaveProperty('tv'); + expect(Array.isArray(result.movie)).toBe(true); + expect(Array.isArray(result.tv)).toBe(true); + }); + + it('should handle errors gracefully', async () => { + nock('http://localhost:5000') + .get('/api/v1/Request/movie') + .reply(500, { error: 'Server Error' }); + + nock('http://localhost:5000') + .get('/api/v1/Request/tv') + .reply(500, { error: 'Server Error' }); + + await arrRetrieverRegistry.initialize(); + + const result = await arrRetrieverRegistry.getOmbiRequests(); + + expect(result).toEqual({ movie: [], tv: [] }); + }); + + }); + + describe('getOmbiRequestsByType', () => { + it('should return grouped requests by type', async () => { + await arrRetrieverRegistry.initialize(); + + const result = await arrRetrieverRegistry.getOmbiRequestsByType(); + + expect(result).toHaveProperty('movie'); + expect(result).toHaveProperty('tv'); + expect(Array.isArray(result.movie)).toBe(true); + expect(Array.isArray(result.tv)).toBe(true); + }); + }); + + describe('findOmbiRequest', () => { + it('should return null for unknown type', async () => { + await arrRetrieverRegistry.initialize(); + const result = await arrRetrieverRegistry.findOmbiRequest('unknown', { tmdbId: '12345' }); + + expect(result).toBeNull(); + }); + }); + + describe('getAllRetrievers', () => { + it('should return all retrievers', async () => { + await arrRetrieverRegistry.initialize(); + + const allRetrievers = arrRetrieverRegistry.getAllRetrievers(); + expect(Array.isArray(allRetrievers)).toBe(true); + }); + }); + + describe('getRetriever', () => { + it('should return null for non-existent instance', async () => { + await arrRetrieverRegistry.initialize(); + + const retriever = arrRetrieverRegistry.getRetriever('non-existent'); + expect(retriever).toBeNull(); + }); + }); + + describe('getRetrieversByType', () => { + it('should filter retrievers by type', async () => { + await arrRetrieverRegistry.initialize(); + + const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr'); + const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr'); + const ombiRetrievers = arrRetrieverRegistry.getRetrieversByType('ombi'); + + sonarrRetrievers.forEach(r => expect(r.getRetrieverType()).toBe('sonarr')); + radarrRetrievers.forEach(r => expect(r.getRetrieverType()).toBe('radarr')); + ombiRetrievers.forEach(r => expect(r.getRetrieverType()).toBe('ombi')); + }); + }); + + describe('matching helper functions', () => { + it('should expose matchDownload function', () => { + expect(arrRetrieverRegistry.matchDownload).toBeDefined(); + expect(typeof arrRetrieverRegistry.matchDownload).toBe('function'); + }); + + it('should expose matchDownloadToArr alias', () => { + expect(arrRetrieverRegistry.matchDownloadToArr).toBeDefined(); + expect(typeof arrRetrieverRegistry.matchDownloadToArr).toBe('function'); + }); + + it('should expose aggregateMatch alias', () => { + expect(arrRetrieverRegistry.aggregateMatch).toBeDefined(); + expect(typeof arrRetrieverRegistry.aggregateMatch).toBe('function'); + }); + + it('should expose matchingHelper alias', () => { + expect(arrRetrieverRegistry.matchingHelper).toBeDefined(); + expect(typeof arrRetrieverRegistry.matchingHelper).toBe('function'); + }); + + it('should expose compareDownloadAndArr alias', () => { + expect(arrRetrieverRegistry.compareDownloadAndArr).toBeDefined(); + expect(typeof arrRetrieverRegistry.compareDownloadAndArr).toBe('function'); + }); + }); +}); diff --git a/tests/unit/clients/OmbiClient.test.js b/tests/unit/clients/OmbiClient.test.js new file mode 100644 index 0000000..f9bbe14 --- /dev/null +++ b/tests/unit/clients/OmbiClient.test.js @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// Mock the logger before importing the client +vi.mock('../../../server/utils/logger', () => ({ + logToFile: vi.fn() +})); + +// Import OmbiClient after mocking +const OmbiClient = require('../../../server/clients/OmbiClient'); + +describe('OmbiClient', () => { + const baseUrl = 'http://localhost:5000'; + const apiKey = 'test-api-key-12345'; + + beforeEach(() => { + // Clean up nock after each test + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe('constructor', () => { + it('should initialize with URL and API key', () => { + const client = new OmbiClient(baseUrl, apiKey); + + expect(client.url).toBe(baseUrl); + expect(client.apiKey).toBe(apiKey); + }); + + it('should remove trailing slash from URL', () => { + const client = new OmbiClient('http://localhost:5000/', apiKey); + + expect(client.url).toBe('http://localhost:5000'); + }); + + it('should set up axios with API key header', () => { + const client = new OmbiClient(baseUrl, apiKey); + + expect(client.axios.defaults.headers['ApiKey']).toBe(apiKey); + }); + + it('should set up axios with 10 second timeout', () => { + const client = new OmbiClient(baseUrl, apiKey); + + expect(client.axios.defaults.timeout).toBe(10000); + }); + }); + + describe('getMovieRequests', () => { + it('should return movie requests on successful API call', async () => { + const mockMovies = [ + { id: 1, title: 'Test Movie 1', theMovieDbId: '12345' }, + { id: 2, title: 'Test Movie 2', theMovieDbId: '67890' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .matchHeader('ApiKey', apiKey) + .reply(200, mockMovies); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getMovieRequests(); + + expect(result).toEqual(mockMovies); + }); + + it('should return empty array on API error', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .matchHeader('ApiKey', apiKey) + .reply(500, { error: 'Internal Server Error' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getMovieRequests(); + + expect(result).toEqual([]); + }); + + it('should return empty array when response data is null', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .matchHeader('ApiKey', apiKey) + .reply(200, null); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getMovieRequests(); + + expect(result).toEqual([]); + }); + }); + + describe('getTvRequests', () => { + it('should return TV requests on successful API call', async () => { + const mockTvShows = [ + { id: 1, title: 'Test Show 1', theTvDbId: '12345' }, + { id: 2, title: 'Test Show 2', theTvDbId: '67890' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/tv') + .matchHeader('ApiKey', apiKey) + .reply(200, mockTvShows); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getTvRequests(); + + expect(result).toEqual(mockTvShows); + }); + + it('should return empty array on API error', async () => { + nock(baseUrl) + .get('/api/v1/Request/tv') + .matchHeader('ApiKey', apiKey) + .reply(500, { error: 'Internal Server Error' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getTvRequests(); + + expect(result).toEqual([]); + }); + + it('should return empty array when response data is null', async () => { + nock(baseUrl) + .get('/api/v1/Request/tv') + .matchHeader('ApiKey', apiKey) + .reply(200, null); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.getTvRequests(); + + expect(result).toEqual([]); + }); + }); + + describe('searchMovieByTmdbId', () => { + it('should return movie data for valid TMDB ID', async () => { + const mockMovie = { + id: 12345, + title: 'Test Movie', + theMovieDbId: '12345' + }; + + nock(baseUrl) + .get('/api/v1/Search/movie/12345') + .matchHeader('ApiKey', apiKey) + .reply(200, mockMovie); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByTmdbId('12345'); + + expect(result).toEqual(mockMovie); + }); + + it('should return null for null TMDB ID', async () => { + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByTmdbId(null); + + expect(result).toBeNull(); + }); + + it('should return null for undefined TMDB ID', async () => { + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByTmdbId(undefined); + + expect(result).toBeNull(); + }); + + it('should return null on API error', async () => { + nock(baseUrl) + .get('/api/v1/Search/movie/12345') + .matchHeader('ApiKey', apiKey) + .reply(404, { error: 'Not Found' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByTmdbId('12345'); + + expect(result).toBeNull(); + }); + }); + + describe('searchMovieByImdbId', () => { + it('should return movie data for valid IMDB ID', async () => { + const mockMovie = { + id: 12345, + title: 'Test Movie', + imdbId: 'tt1234567' + }; + + nock(baseUrl) + .get('/api/v1/Search/movie/imdb/tt1234567') + .matchHeader('ApiKey', apiKey) + .reply(200, mockMovie); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByImdbId('tt1234567'); + + expect(result).toEqual(mockMovie); + }); + + it('should return null for null IMDB ID', async () => { + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByImdbId(null); + + expect(result).toBeNull(); + }); + + it('should return null on API error', async () => { + nock(baseUrl) + .get('/api/v1/Search/movie/imdb/tt1234567') + .matchHeader('ApiKey', apiKey) + .reply(404, { error: 'Not Found' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchMovieByImdbId('tt1234567'); + + expect(result).toBeNull(); + }); + }); + + describe('searchTvByTvdbId', () => { + it('should return TV show data for valid TVDB ID', async () => { + const mockShow = { + id: 12345, + title: 'Test Show', + theTvDbId: '12345' + }; + + nock(baseUrl) + .get('/api/v1/Search/tv/12345') + .matchHeader('ApiKey', apiKey) + .reply(200, mockShow); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchTvByTvdbId('12345'); + + expect(result).toEqual(mockShow); + }); + + it('should return null for null TVDB ID', async () => { + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchTvByTvdbId(null); + + expect(result).toBeNull(); + }); + + it('should return null on API error', async () => { + nock(baseUrl) + .get('/api/v1/Search/tv/12345') + .matchHeader('ApiKey', apiKey) + .reply(404, { error: 'Not Found' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchTvByTvdbId('12345'); + + expect(result).toBeNull(); + }); + }); + + describe('searchTvByTmdbId', () => { + it('should return TV show data for valid TMDB ID', async () => { + const mockShow = { + id: 12345, + title: 'Test Show', + theMovieDbId: '67890' + }; + + nock(baseUrl) + .get('/api/v1/Search/tv/tmdb/67890') + .matchHeader('ApiKey', apiKey) + .reply(200, mockShow); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchTvByTmdbId('67890'); + + expect(result).toEqual(mockShow); + }); + + it('should return null for null TMDB ID', async () => { + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchTvByTmdbId(null); + + expect(result).toBeNull(); + }); + + it('should return null on API error', async () => { + nock(baseUrl) + .get('/api/v1/Search/tv/tmdb/67890') + .matchHeader('ApiKey', apiKey) + .reply(404, { error: 'Not Found' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.searchTvByTmdbId('67890'); + + expect(result).toBeNull(); + }); + }); + + describe('testConnection', () => { + it('should return true for successful connection', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .matchHeader('ApiKey', apiKey) + .reply(200, []); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.testConnection(); + + expect(result).toBe(true); + }); + + it('should return false for failed connection', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .matchHeader('ApiKey', apiKey) + .reply(401, { error: 'Unauthorized' }); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.testConnection(); + + expect(result).toBe(false); + }); + + it('should return false on network error', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .matchHeader('ApiKey', apiKey) + .replyWithError('Network error'); + + const client = new OmbiClient(baseUrl, apiKey); + const result = await client.testConnection(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/tests/unit/clients/OmbiRetriever.test.js b/tests/unit/clients/OmbiRetriever.test.js new file mode 100644 index 0000000..d0c054c --- /dev/null +++ b/tests/unit/clients/OmbiRetriever.test.js @@ -0,0 +1,678 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// Mock the logger before importing the retriever +vi.mock('../../../server/utils/logger', () => ({ + logToFile: vi.fn() +})); + +// Import OmbiRetriever after mocking +const OmbiRetriever = require('../../../server/clients/OmbiRetriever'); +const ArrRetriever = require('../../../server/clients/ArrRetriever'); + +describe('OmbiRetriever', () => { + const baseUrl = 'http://localhost:5000'; + const apiKey = 'test-api-key-12345'; + const instanceConfig = { + id: 'test-ombi-1', + name: 'Test Ombi Instance', + url: baseUrl, + apiKey: apiKey + }; + + beforeEach(() => { + nock.cleanAll(); + vi.useFakeTimers(); + }); + + afterEach(() => { + nock.cleanAll(); + vi.useRealTimers(); + }); + + describe('constructor', () => { + it('should extend ArrRetriever base class', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever).toBeInstanceOf(ArrRetriever); + }); + + it('should initialize with correct properties', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever.id).toBe('test-ombi-1'); + expect(retriever.name).toBe('Test Ombi Instance'); + expect(retriever.url).toBe(baseUrl); + expect(retriever.apiKey).toBe(apiKey); + expect(retriever.baseUrl).toBe(baseUrl); + }); + + it('should initialize cache with empty arrays and maps', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever.cache.movieRequests).toEqual([]); + expect(retriever.cache.tvRequests).toEqual([]); + expect(retriever.cache.movieMap).toBeInstanceOf(Map); + expect(retriever.cache.tvMap).toBeInstanceOf(Map); + }); + + it('should set cache TTL to 5 minutes', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever.cache.ttl).toBe(5 * 60 * 1000); // 5 minutes in ms + }); + }); + + describe('getRetrieverType', () => { + it('should return "ombi"', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever.getRetrieverType()).toBe('ombi'); + }); + }); + + describe('getInstanceId', () => { + it('should return configured instance ID', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever.getInstanceId()).toBe('test-ombi-1'); + }); + }); + + describe('getTags', () => { + it('should return empty array', async () => { + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getTags(); + + expect(result).toEqual([]); + }); + }); + + describe('getQueue', () => { + it('should return combined movie and TV requests', async () => { + const mockMovies = [ + { id: 1, title: 'Movie 1', theMovieDbId: '12345' }, + { id: 2, title: 'Movie 2', theMovieDbId: '67890' } + ]; + const mockTvShows = [ + { id: 3, title: 'Show 1', theTvDbId: '11111' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getQueue(); + + expect(result.records).toHaveLength(3); + expect(result.records[0].title).toBe('Movie 1'); + expect(result.records[2].title).toBe('Show 1'); + }); + }); + + describe('getHistory', () => { + it('should return empty records array', async () => { + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getHistory(); + + expect(result).toEqual({ records: [] }); + }); + + it('should return empty records even with options', async () => { + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getHistory({ pageSize: 10, sortKey: 'date' }); + + expect(result).toEqual({ records: [] }); + }); + }); + + describe('testConnection', () => { + it('should return true for successful connection', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, []); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.testConnection(); + + expect(result).toBe(true); + }); + + it('should return false for failed connection', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(401, { error: 'Unauthorized' }); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.testConnection(); + + expect(result).toBe(false); + }); + }); + + describe('isCacheExpired', () => { + it('should return true when cache is fresh (never fetched)', () => { + const retriever = new OmbiRetriever(instanceConfig); + + expect(retriever.isCacheExpired()).toBe(true); + }); + + it('should return false when cache is within TTL', async () => { + const mockMovies = [{ id: 1, title: 'Movie 1' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + expect(retriever.isCacheExpired()).toBe(false); + }); + + it('should return true when cache is beyond TTL', async () => { + const mockMovies = [{ id: 1, title: 'Movie 1' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + // Advance time by 6 minutes (beyond 5-minute TTL) + vi.advanceTimersByTime(6 * 60 * 1000); + + expect(retriever.isCacheExpired()).toBe(true); + }); + }); + + describe('refreshCache', () => { + it('should not refresh if cache is not expired', async () => { + const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + + // First refresh + await retriever.refreshCache(); + expect(retriever.cache.movieRequests).toHaveLength(1); + + // Reset nock to verify no new calls are made + nock.cleanAll(); + + // Second refresh should not make API calls + await retriever.refreshCache(); + expect(retriever.cache.movieRequests).toHaveLength(1); + }); + + it('should refresh when cache is expired', async () => { + const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }]; + const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies1); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + + // First refresh + await retriever.refreshCache(); + expect(retriever.cache.movieRequests).toHaveLength(1); + + // Advance time beyond TTL + vi.advanceTimersByTime(6 * 60 * 1000); + + // Set up new mocks for second refresh + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies2); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + // Second refresh should make API calls + await retriever.refreshCache(); + expect(retriever.cache.movieRequests).toHaveLength(2); + }); + + it('should build movie map with TMDB and IMDB IDs', async () => { + const mockMovies = [ + { id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' }, + { id: 2, title: 'Movie 2', theMovieDbId: '67890' } + ]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + expect(retriever.cache.movieMap.get('12345')).toEqual(mockMovies[0]); + expect(retriever.cache.movieMap.get('tt12345')).toEqual(mockMovies[0]); + expect(retriever.cache.movieMap.get('67890')).toEqual(mockMovies[1]); + }); + + it('should build TV map with TVDB and TMDB IDs', async () => { + const mockMovies = []; + const mockTvShows = [ + { id: 1, title: 'Show 1', theTvDbId: '11111', theMovieDbId: '22222' }, + { id: 2, title: 'Show 2', theTvDbId: '33333' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + expect(retriever.cache.tvMap.get('11111')).toEqual(mockTvShows[0]); + expect(retriever.cache.tvMap.get('22222')).toEqual(mockTvShows[0]); + expect(retriever.cache.tvMap.get('33333')).toEqual(mockTvShows[1]); + }); + + it('should handle API errors gracefully', async () => { + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(500, { error: 'Server Error' }); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(500, { error: 'Server Error' }); + + const retriever = new OmbiRetriever(instanceConfig); + + // Should not throw error + await expect(retriever.refreshCache()).resolves.not.toThrow(); + + // Cache should remain empty but not crash + expect(retriever.cache.movieRequests).toEqual([]); + expect(retriever.cache.tvRequests).toEqual([]); + }); + }); + + describe('getMovieRequests', () => { + it('should return cached movie requests on cache hit', async () => { + const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + // Reset nock to ensure no new API calls + nock.cleanAll(); + + const result = await retriever.getMovieRequests(); + expect(result).toEqual(mockMovies); + }); + + it('should fetch and return movie requests on cache miss', async () => { + const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getMovieRequests(); + + expect(result).toEqual(mockMovies); + }); + }); + + describe('getTvRequests', () => { + it('should return cached TV requests on cache hit', async () => { + const mockMovies = []; + const mockTvShows = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + // Reset nock to ensure no new API calls + nock.cleanAll(); + + const result = await retriever.getTvRequests(); + expect(result).toEqual(mockTvShows); + }); + + it('should fetch and return TV requests on cache miss', async () => { + const mockMovies = []; + const mockTvShows = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.getTvRequests(); + + expect(result).toEqual(mockTvShows); + }); + }); + + describe('findMovieRequest', () => { + it('should find movie by TMDB ID from cache', async () => { + const mockMovies = [ + { id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' } + ]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.findMovieRequest('12345'); + + expect(result).toEqual(mockMovies[0]); + }); + + it('should find movie by IMDB ID when TMDB ID not found', async () => { + const mockMovies = [ + { id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' } + ]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.findMovieRequest('99999', 'tt12345'); + + expect(result).toEqual(mockMovies[0]); + }); + + it('should return null when movie not found', async () => { + const mockMovies = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }]; + const mockTvShows = []; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.findMovieRequest('99999'); + + expect(result).toBeNull(); + }); + }); + + describe('findTvRequest', () => { + it('should find TV show by TVDB ID from cache', async () => { + const mockMovies = []; + const mockTvShows = [ + { id: 1, title: 'Show 1', theTvDbId: '11111', theMovieDbId: '22222' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.findTvRequest('11111'); + + expect(result).toEqual(mockTvShows[0]); + }); + + it('should find TV show by TMDB ID when TVDB ID not found', async () => { + const mockMovies = []; + const mockTvShows = [ + { id: 1, title: 'Show 1', theTvDbId: '11111', theMovieDbId: '22222' } + ]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.findTvRequest('99999', '22222'); + + expect(result).toEqual(mockTvShows[0]); + }); + + it('should return null when TV show not found', async () => { + const mockMovies = []; + const mockTvShows = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.findTvRequest('99999'); + + expect(result).toBeNull(); + }); + }); + + describe('searchMovie', () => { + it('should search by TMDB ID first', async () => { + const mockSearchResult = { + id: 12345, + title: 'Searched Movie', + theMovieDbId: '12345' + }; + + nock(baseUrl) + .get('/api/v1/Search/movie/12345') + .reply(200, mockSearchResult); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.searchMovie('12345'); + + expect(result).toEqual(mockSearchResult); + }); + + it('should fall back to IMDB ID when TMDB search fails', async () => { + const mockSearchResult = { + id: 12345, + title: 'Searched Movie', + imdbId: 'tt12345' + }; + + nock(baseUrl) + .get('/api/v1/Search/movie/12345') + .reply(404, { error: 'Not Found' }); + + nock(baseUrl) + .get('/api/v1/Search/movie/imdb/tt12345') + .reply(200, mockSearchResult); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.searchMovie('12345', 'tt12345'); + + expect(result).toEqual(mockSearchResult); + }); + + it('should return null when both searches fail', async () => { + nock(baseUrl) + .get('/api/v1/Search/movie/12345') + .reply(404, { error: 'Not Found' }); + + nock(baseUrl) + .get('/api/v1/Search/movie/imdb/tt12345') + .reply(404, { error: 'Not Found' }); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.searchMovie('12345', 'tt12345'); + + expect(result).toBeNull(); + }); + }); + + describe('searchTv', () => { + it('should search by TVDB ID first', async () => { + const mockSearchResult = { + id: 11111, + title: 'Searched Show', + theTvDbId: '11111' + }; + + nock(baseUrl) + .get('/api/v1/Search/tv/11111') + .reply(200, mockSearchResult); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.searchTv('11111'); + + expect(result).toEqual(mockSearchResult); + }); + + it('should fall back to TMDB ID when TVDB search fails', async () => { + const mockSearchResult = { + id: 11111, + title: 'Searched Show', + theMovieDbId: '22222' + }; + + nock(baseUrl) + .get('/api/v1/Search/tv/11111') + .reply(404, { error: 'Not Found' }); + + nock(baseUrl) + .get('/api/v1/Search/tv/tmdb/22222') + .reply(200, mockSearchResult); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.searchTv('11111', '22222'); + + expect(result).toEqual(mockSearchResult); + }); + + it('should return null when both searches fail', async () => { + nock(baseUrl) + .get('/api/v1/Search/tv/11111') + .reply(404, { error: 'Not Found' }); + + nock(baseUrl) + .get('/api/v1/Search/tv/tmdb/22222') + .reply(404, { error: 'Not Found' }); + + const retriever = new OmbiRetriever(instanceConfig); + const result = await retriever.searchTv('11111', '22222'); + + expect(result).toBeNull(); + }); + }); + + describe('getCacheStats', () => { + it('should return cache statistics', async () => { + const mockMovies = [ + { id: 1, title: 'Movie 1', theMovieDbId: '12345' }, + { id: 2, title: 'Movie 2', theMovieDbId: '67890' } + ]; + const mockTvShows = [{ id: 3, title: 'Show 1', theTvDbId: '11111' }]; + + nock(baseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovies); + + nock(baseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvShows); + + const retriever = new OmbiRetriever(instanceConfig); + await retriever.refreshCache(); + + const stats = retriever.getCacheStats(); + + expect(stats.movieRequests).toBe(2); + expect(stats.tvRequests).toBe(1); + expect(stats.movieMapSize).toBe(2); + expect(stats.tvMapSize).toBe(1); + expect(stats.lastFetch).toBeGreaterThan(0); + expect(stats.age).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/tests/unit/config.test.js b/tests/unit/config.test.js index 80a74b2..8dd15ca 100644 --- a/tests/unit/config.test.js +++ b/tests/unit/config.test.js @@ -7,7 +7,7 @@ * because misconfigured instances silently return no data rather than crashing. */ -import { parseInstances, getSonarrInstances, getRadarrInstances } from '../../server/utils/config.js'; +import { parseInstances, getSonarrInstances, getRadarrInstances, getOmbiInstances } from '../../server/utils/config.js'; describe('parseInstances', () => { describe('JSON array format', () => { @@ -106,4 +106,87 @@ describe('parseInstances', () => { expect(result).toEqual([]); }); }); + + describe('Ombi configuration', () => { + it('getOmbiInstances parses OMBI_INSTANCES JSON array', () => { + process.env.OMBI_INSTANCES = JSON.stringify([{ name: 'ombi-main', url: 'https://ombi.local', apiKey: 'ombi-key-123' }]); + const result = getOmbiInstances(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('ombi-main'); + expect(result[0].url).toBe('https://ombi.local'); + expect(result[0].apiKey).toBe('ombi-key-123'); + expect(result[0].id).toBe('ombi-main'); + delete process.env.OMBI_INSTANCES; + }); + + it('getOmbiInstances parses multiple Ombi instances', () => { + process.env.OMBI_INSTANCES = JSON.stringify([ + { name: 'ombi-primary', url: 'https://ombi1.local', apiKey: 'key1' }, + { name: 'ombi-backup', url: 'https://ombi2.local', apiKey: 'key2' } + ]); + const result = getOmbiInstances(); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('ombi-primary'); + expect(result[1].name).toBe('ombi-backup'); + delete process.env.OMBI_INSTANCES; + }); + + it('getOmbiInstances falls back to legacy OMBI_URL and OMBI_API_KEY', () => { + delete process.env.OMBI_INSTANCES; + process.env.OMBI_URL = 'https://legacy-ombi.local'; + process.env.OMBI_API_KEY = 'legacy-ombi-key'; + const result = getOmbiInstances(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('default'); + expect(result[0].name).toBe('Default'); + expect(result[0].url).toBe('https://legacy-ombi.local'); + expect(result[0].apiKey).toBe('legacy-ombi-key'); + delete process.env.OMBI_URL; + delete process.env.OMBI_API_KEY; + }); + + it('getOmbiInstances returns empty array when not configured', () => { + delete process.env.OMBI_INSTANCES; + delete process.env.OMBI_URL; + delete process.env.OMBI_API_KEY; + const result = getOmbiInstances(); + expect(result).toEqual([]); + }); + + it('getOmbiInstances handles multi-line JSON', () => { + const json = `[ + { + "name": "ombi-test", + "url": "https://ombi.test", + "apiKey": "test-key" + } + ]`; + process.env.OMBI_INSTANCES = json; + const result = getOmbiInstances(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('ombi-test'); + delete process.env.OMBI_INSTANCES; + }); + + it('getOmbiInstances handles invalid JSON by falling back to legacy', () => { + process.env.OMBI_INSTANCES = 'not-valid-json'; + process.env.OMBI_URL = 'https://fallback-ombi.local'; + process.env.OMBI_API_KEY = 'fallback-key'; + const result = getOmbiInstances(); + expect(result).toHaveLength(1); + expect(result[0].url).toBe('https://fallback-ombi.local'); + delete process.env.OMBI_INSTANCES; + delete process.env.OMBI_URL; + delete process.env.OMBI_API_KEY; + }); + + it('parseInstances validates Ombi instance URLs', () => { + process.env.OMBI_INSTANCES = JSON.stringify([{ name: 'bad-url', url: 'not-a-valid-url', apiKey: 'key' }]); + const result = getOmbiInstances(); + // Should still parse but with validation warning + expect(result).toHaveLength(1); + expect(result[0].url).toBe('not-a-valid-url'); + delete process.env.OMBI_INSTANCES; + }); + }); }); diff --git a/tests/unit/services/DownloadAssembler.test.js b/tests/unit/services/DownloadAssembler.test.js index 133ff32..98bc167 100644 --- a/tests/unit/services/DownloadAssembler.test.js +++ b/tests/unit/services/DownloadAssembler.test.js @@ -752,4 +752,78 @@ describe('DownloadAssembler', () => { ]); }); }); + + describe('getOmbiLink', () => { + it('returns correct URL for valid requestId, type, and baseUrl', () => { + const result = DownloadAssembler.getOmbiLink(123, 'movie', 'http://localhost:5000'); + expect(result).toBe('http://localhost:5000/#/request/movie/123'); + }); + + it('returns correct URL for TV type', () => { + const result = DownloadAssembler.getOmbiLink(456, 'tv', 'http://localhost:5000'); + expect(result).toBe('http://localhost:5000/#/request/tv/456'); + }); + + it('returns null when requestId is missing', () => { + const result = DownloadAssembler.getOmbiLink(null, 'movie', 'http://localhost:5000'); + expect(result).toBeNull(); + }); + + it('returns null when type is missing', () => { + const result = DownloadAssembler.getOmbiLink(123, null, 'http://localhost:5000'); + expect(result).toBeNull(); + }); + + it('returns null when baseUrl is missing', () => { + const result = DownloadAssembler.getOmbiLink(123, 'movie', null); + expect(result).toBeNull(); + }); + + it('returns null when all parameters are missing', () => { + const result = DownloadAssembler.getOmbiLink(null, null, null); + expect(result).toBeNull(); + }); + + it('handles string requestId', () => { + const result = DownloadAssembler.getOmbiLink('abc-123', 'movie', 'http://localhost:5000'); + expect(result).toBe('http://localhost:5000/#/request/movie/abc-123'); + }); + }); + + describe('getOmbiSearchLink', () => { + it('returns correct URL for series type', () => { + const result = DownloadAssembler.getOmbiSearchLink(789, 'series', 'http://localhost:5000'); + expect(result).toBe('http://localhost:5000/#/tv/search/789'); + }); + + it('returns correct URL for movie type', () => { + const result = DownloadAssembler.getOmbiSearchLink(101, 'movie', 'http://localhost:5000'); + expect(result).toBe('http://localhost:5000/#/movie/search/101'); + }); + + it('returns null when searchId is missing', () => { + const result = DownloadAssembler.getOmbiSearchLink(null, 'series', 'http://localhost:5000'); + expect(result).toBeNull(); + }); + + it('returns null when type is missing', () => { + const result = DownloadAssembler.getOmbiSearchLink(789, null, 'http://localhost:5000'); + expect(result).toBeNull(); + }); + + it('returns null when baseUrl is missing', () => { + const result = DownloadAssembler.getOmbiSearchLink(789, 'series', null); + expect(result).toBeNull(); + }); + + it('returns null for invalid type', () => { + const result = DownloadAssembler.getOmbiSearchLink(789, 'invalid', 'http://localhost:5000'); + expect(result).toBeNull(); + }); + + it('handles string searchId', () => { + const result = DownloadAssembler.getOmbiSearchLink('search-123', 'movie', 'http://localhost:5000'); + expect(result).toBe('http://localhost:5000/#/movie/search/search-123'); + }); + }); }); diff --git a/tests/unit/services/DownloadMatcher.test.js b/tests/unit/services/DownloadMatcher.test.js new file mode 100644 index 0000000..45a4613 --- /dev/null +++ b/tests/unit/services/DownloadMatcher.test.js @@ -0,0 +1,368 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import nock from 'nock'; + +// Mock dependencies +vi.mock('../../../server/utils/logger', () => ({ + logToFile: vi.fn() +})); + +// Import after mocking +const DownloadMatcher = require('../../../server/services/DownloadMatcher'); +const OmbiRetriever = require('../../../server/clients/OmbiRetriever'); + +describe('DownloadMatcher', () => { + const ombiBaseUrl = 'http://localhost:5000'; + + beforeEach(() => { + nock.cleanAll(); + vi.clearAllMocks(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + describe('addOmbiMatching', () => { + it('should return early when ombiRetriever is missing', async () => { + const downloadObj = { type: 'series', title: 'Test Show' }; + const series = { tvdbId: '12345', tmdbId: '67890' }; + const context = { ombiRetriever: null, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, series, context); + + expect(downloadObj.ombiLink).toBeUndefined(); + expect(downloadObj.ombiRequestId).toBeUndefined(); + }); + + it('should return early when ombiBaseUrl is missing', async () => { + const downloadObj = { type: 'series', title: 'Test Show' }; + const series = { tvdbId: '12345', tmdbId: '67890' }; + + const mockRetriever = { + findTvRequest: vi.fn(), + searchTv: vi.fn() + }; + + const context = { ombiRetriever: mockRetriever, ombiBaseUrl: null }; + + await DownloadMatcher.addOmbiMatching(downloadObj, series, context); + + expect(mockRetriever.findTvRequest).not.toHaveBeenCalled(); + expect(downloadObj.ombiLink).toBeUndefined(); + }); + + it('should return early when seriesOrMovie is missing', async () => { + const downloadObj = { type: 'series', title: 'Test Show' }; + + const mockRetriever = { + findTvRequest: vi.fn(), + searchTv: vi.fn() + }; + + const context = { ombiRetriever: mockRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, null, context); + + expect(mockRetriever.findTvRequest).not.toHaveBeenCalled(); + expect(downloadObj.ombiLink).toBeUndefined(); + }); + + it('should add ombiLink and ombiRequestId for TV request found by TVDB ID', async () => { + const mockMovieRequests = []; + const mockTvRequests = [ + { id: 101, title: 'Test Show', type: 'tv', theTvDbId: '12345' } + ]; + + nock(ombiBaseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovieRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvRequests); + + const ombiRetriever = new OmbiRetriever({ + id: 'test-ombi', + name: 'Test Ombi', + url: ombiBaseUrl, + apiKey: 'test-key' + }); + + const downloadObj = { type: 'series', title: 'Test Show' }; + const series = { tvdbId: '12345', tmdbId: '67890' }; + const context = { ombiRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, series, context); + + expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/tv/101'); + expect(downloadObj.ombiRequestId).toBe(101); + expect(downloadObj.ombiTooltip).toBe('Request'); + }); + + it('should add ombiLink and ombiRequestId for TV request found by TMDB ID fallback', async () => { + const mockMovieRequests = []; + const mockTvRequests = [ + { id: 102, title: 'Test Show TMDB', type: 'tv', theMovieDbId: '67890' } + ]; + + nock(ombiBaseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovieRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvRequests); + + const ombiRetriever = new OmbiRetriever({ + id: 'test-ombi', + name: 'Test Ombi', + url: ombiBaseUrl, + apiKey: 'test-key' + }); + + const downloadObj = { type: 'series', title: 'Test Show TMDB' }; + const series = { tvdbId: '99999', tmdbId: '67890' }; + const context = { ombiRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, series, context); + + expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/tv/102'); + expect(downloadObj.ombiRequestId).toBe(102); + expect(downloadObj.ombiTooltip).toBe('Request'); + }); + + it('should add ombiLink and ombiRequestId for movie request found by TMDB ID', async () => { + const mockMovieRequests = [ + { id: 201, title: 'Test Movie', type: 'movie', theMovieDbId: '54321' } + ]; + const mockTvRequests = []; + + nock(ombiBaseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovieRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvRequests); + + const ombiRetriever = new OmbiRetriever({ + id: 'test-ombi', + name: 'Test Ombi', + url: ombiBaseUrl, + apiKey: 'test-key' + }); + + const downloadObj = { type: 'movie', title: 'Test Movie' }; + const movie = { tmdbId: '54321', imdbId: 'tt54321' }; + const context = { ombiRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, movie, context); + + expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/movie/201'); + expect(downloadObj.ombiRequestId).toBe(201); + expect(downloadObj.ombiTooltip).toBe('Request'); + }); + + it('should add ombiLink and ombiRequestId for movie request found by IMDB ID fallback', async () => { + const mockMovieRequests = [ + { id: 202, title: 'Test Movie IMDB', type: 'movie', imdbId: 'tt98765' } + ]; + const mockTvRequests = []; + + nock(ombiBaseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovieRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvRequests); + + const ombiRetriever = new OmbiRetriever({ + id: 'test-ombi', + name: 'Test Ombi', + url: ombiBaseUrl, + apiKey: 'test-key' + }); + + const downloadObj = { type: 'movie', title: 'Test Movie IMDB' }; + const movie = { tmdbId: '99999', imdbId: 'tt98765' }; + const context = { ombiRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, movie, context); + + expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/request/movie/202'); + expect(downloadObj.ombiRequestId).toBe(202); + expect(downloadObj.ombiTooltip).toBe('Request'); + }); + + it('should add search link and tooltip when no request found but search succeeds', async () => { + const mockMovieRequests = []; + const mockTvRequests = []; + const mockSearchResult = { id: 12345, title: 'Test Show Search', theTvDbId: '11111' }; + + nock(ombiBaseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovieRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Search/tv/11111') + .reply(200, mockSearchResult); + + const ombiRetriever = new OmbiRetriever({ + id: 'test-ombi', + name: 'Test Ombi', + url: ombiBaseUrl, + apiKey: 'test-key' + }); + + const downloadObj = { type: 'series', title: 'Test Show Search' }; + const series = { tvdbId: '11111', tmdbId: '22222' }; + const context = { ombiRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, series, context); + + expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/tv/search/12345'); + expect(downloadObj.ombiTooltip).toBe('Search'); + expect(downloadObj.ombiRequestId).toBeUndefined(); + }); + + it('should add movie search link for movie type', async () => { + const mockMovieRequests = []; + const mockTvRequests = []; + const mockSearchResult = { id: 54321, title: 'Test Movie Search', theMovieDbId: '33333' }; + + nock(ombiBaseUrl) + .get('/api/v1/Request/movie') + .reply(200, mockMovieRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Request/tv') + .reply(200, mockTvRequests); + + nock(ombiBaseUrl) + .get('/api/v1/Search/movie/33333') + .reply(200, mockSearchResult); + + const ombiRetriever = new OmbiRetriever({ + id: 'test-ombi', + name: 'Test Ombi', + url: ombiBaseUrl, + apiKey: 'test-key' + }); + + const downloadObj = { type: 'movie', title: 'Test Movie Search' }; + const movie = { tmdbId: '33333', imdbId: 'tt33333' }; + const context = { ombiRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, movie, context); + + expect(downloadObj.ombiLink).toBe('http://localhost:5000/#/movie/search/54321'); + expect(downloadObj.ombiTooltip).toBe('Search'); + expect(downloadObj.ombiRequestId).toBeUndefined(); + }); + + it('should handle errors gracefully without breaking download object', async () => { + const mockRetriever = { + findTvRequest: vi.fn().mockRejectedValue(new Error('Ombi API error')), + searchTv: vi.fn().mockRejectedValue(new Error('Search error')) + }; + + const downloadObj = { type: 'series', title: 'Test Show Error' }; + const series = { tvdbId: '66666', tmdbId: '77777' }; + const context = { ombiRetriever: mockRetriever, ombiBaseUrl }; + + // Should not throw error + await expect(DownloadMatcher.addOmbiMatching(downloadObj, series, context)).resolves.not.toThrow(); + + // Download object should still have original data + expect(downloadObj.title).toBe('Test Show Error'); + expect(downloadObj.ombiLink).toBeUndefined(); + expect(downloadObj.ombiRequestId).toBeUndefined(); + }); + + it('should do nothing for unknown download type', async () => { + const mockRetriever = { + findTvRequest: vi.fn(), + findMovieRequest: vi.fn() + }; + + const downloadObj = { type: 'unknown', title: 'Unknown Type' }; + const media = { id: 123 }; + const context = { ombiRetriever: mockRetriever, ombiBaseUrl }; + + await DownloadMatcher.addOmbiMatching(downloadObj, media, context); + + expect(mockRetriever.findTvRequest).not.toHaveBeenCalled(); + expect(mockRetriever.findMovieRequest).not.toHaveBeenCalled(); + expect(downloadObj.ombiLink).toBeUndefined(); + }); + }); + + describe('buildSeriesMapFromRecords', () => { + it('should build a map from queue and history records', () => { + const queueRecords = [ + { seriesId: 1, series: { id: 1, title: 'Series 1' } } + ]; + const historyRecords = [ + { seriesId: 2, series: { id: 2, title: 'Series 2' } } + ]; + + const result = DownloadMatcher.buildSeriesMapFromRecords(queueRecords, historyRecords); + + expect(result.get(1)).toEqual({ id: 1, title: 'Series 1' }); + expect(result.get(2)).toEqual({ id: 2, title: 'Series 2' }); + }); + + it('should not overwrite existing series in map', () => { + const queueRecords = [ + { seriesId: 1, series: { id: 1, title: 'Series 1' } } + ]; + const historyRecords = [ + { seriesId: 1, series: { id: 1, title: 'Series 1 from History' } } + ]; + + const result = DownloadMatcher.buildSeriesMapFromRecords(queueRecords, historyRecords); + + expect(result.get(1).title).toBe('Series 1'); + }); + }); + + describe('buildMoviesMapFromRecords', () => { + it('should build a map from queue and history records', () => { + const queueRecords = [ + { movieId: 1, movie: { id: 1, title: 'Movie 1' } } + ]; + const historyRecords = [ + { movieId: 2, movie: { id: 2, title: 'Movie 2' } } + ]; + + const result = DownloadMatcher.buildMoviesMapFromRecords(queueRecords, historyRecords); + + expect(result.get(1)).toEqual({ id: 1, title: 'Movie 1' }); + expect(result.get(2)).toEqual({ id: 2, title: 'Movie 2' }); + }); + }); + + describe('getSlotStatusAndSpeed', () => { + it('should return Paused status when queue is paused', () => { + const slot = { status: 'Downloading' }; + const result = DownloadMatcher.getSlotStatusAndSpeed(slot, 'Paused', '0', '0'); + + expect(result.status).toBe('Paused'); + expect(result.speed).toBe('0'); + }); + + it('should return slot status when queue is active', () => { + const slot = { status: 'Downloading' }; + const result = DownloadMatcher.getSlotStatusAndSpeed(slot, 'Active', '1.5 MB/s', '1536'); + + expect(result.status).toBe('Downloading'); + expect(result.speed).toBe('1.5 MB/s'); + }); + }); +});