33b122d22b
Build and Push Docker Image / build (push) Successful in 1m46s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m33s
CI / Security audit (push) Successful in 1m56s
CI / Swagger Validation & Coverage (push) Successful in 2m35s
CI / Tests & coverage (push) Successful in 2m51s
Ombi's TV API nests all request data (requestedUser, approved, available, denied, requested, requestedDate) inside childRequests[] sub-objects. The application previously only inspected top-level properties, causing TV shows to consistently display 'unknown' status, 'unknown' user, and no request date. Changes: - OmbiRetriever._hydrateRequest(): hydrate requestedUser on each childRequests entry and promote requestedDate to top level - getRequestStatus() (server + client): aggregate status flags from childRequests[] when top-level properties are absent - Client date display: fallback to childRequests[0].requestedDate - Add 18 unit tests covering childRequests hydration, status aggregation, and date promotion Closes #53
335 lines
10 KiB
JavaScript
335 lines
10 KiB
JavaScript
// 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');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OmbiRetriever._hydrateRequest', () => {
|
|
let retriever;
|
|
|
|
beforeEach(() => {
|
|
retriever = new OmbiRetriever({
|
|
id: 'ombi-test',
|
|
name: 'Test Ombi',
|
|
url: 'http://localhost:5000',
|
|
apiKey: 'test-key'
|
|
});
|
|
|
|
// Seed the userMap cache
|
|
retriever.cache.userMap.set('user-1', {
|
|
id: 'user-1',
|
|
userName: 'testuser',
|
|
alias: 'TestUser',
|
|
userAlias: 'TestUser',
|
|
normalizedUserName: 'testuser'
|
|
});
|
|
retriever.cache.userMap.set('user-2', {
|
|
id: 'user-2',
|
|
userName: 'adminuser',
|
|
alias: 'AdminUser',
|
|
userAlias: 'AdminUser',
|
|
normalizedUserName: 'adminuser'
|
|
});
|
|
});
|
|
|
|
it('hydrates top-level requestedUserId', () => {
|
|
const req = {
|
|
id: 1,
|
|
requestedUserId: 'user-1',
|
|
requestedUser: {}
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result.requestedUser.userName).toBe('testuser');
|
|
expect(result.requestedUser.alias).toBe('TestUser');
|
|
});
|
|
|
|
it('hydrates childRequests requestedUserId (TV requests)', () => {
|
|
const req = {
|
|
id: 3,
|
|
title: 'Test Show',
|
|
requestedUserId: 'user-1',
|
|
requestedUser: {},
|
|
childRequests: [
|
|
{
|
|
id: 10,
|
|
requestedUserId: 'user-2',
|
|
requestedUser: {}
|
|
}
|
|
]
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result.requestedUser.userName).toBe('testuser');
|
|
expect(result.childRequests[0].requestedUser.userName).toBe('adminuser');
|
|
expect(result.childRequests[0].requestedUser.alias).toBe('AdminUser');
|
|
});
|
|
|
|
it('promotes requestedDate from childRequests to top level', () => {
|
|
const req = {
|
|
id: 3,
|
|
title: 'Test Show',
|
|
childRequests: [
|
|
{
|
|
id: 10,
|
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
|
}
|
|
]
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result.requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
|
expect(result.childRequests[0].requestedDate).toBe('2026-05-15T10:00:00.000Z');
|
|
});
|
|
|
|
it('does not overwrite existing top-level requestedDate', () => {
|
|
const req = {
|
|
id: 3,
|
|
requestedDate: '2026-01-01T00:00:00.000Z',
|
|
childRequests: [
|
|
{
|
|
id: 10,
|
|
requestedDate: '2026-05-15T10:00:00.000Z'
|
|
}
|
|
]
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result.requestedDate).toBe('2026-01-01T00:00:00.000Z');
|
|
});
|
|
|
|
it('handles PascalCase RequestedDate from childRequests', () => {
|
|
const req = {
|
|
id: 3,
|
|
childRequests: [
|
|
{
|
|
id: 10,
|
|
RequestedDate: '2026-06-01T12:00:00.000Z'
|
|
}
|
|
]
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result.requestedDate).toBe('2026-06-01T12:00:00.000Z');
|
|
});
|
|
|
|
it('returns unmodified request when no hydration needed', () => {
|
|
const req = {
|
|
id: 1,
|
|
title: 'Test Movie',
|
|
requestedUser: { userName: 'existing', alias: 'Existing' }
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result).toEqual(req);
|
|
});
|
|
|
|
it('handles null childRequests gracefully', () => {
|
|
const req = {
|
|
id: 3,
|
|
childRequests: null
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result).toEqual(req);
|
|
});
|
|
|
|
it('handles empty childRequests gracefully', () => {
|
|
const req = {
|
|
id: 3,
|
|
childRequests: []
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result).toEqual(req);
|
|
});
|
|
|
|
it('skips child hydration when child already has valid requestedUser', () => {
|
|
const req = {
|
|
id: 3,
|
|
childRequests: [
|
|
{
|
|
id: 10,
|
|
requestedUserId: 'user-1',
|
|
requestedUser: { userName: 'already_set', alias: 'AlreadySet' }
|
|
}
|
|
]
|
|
};
|
|
const result = retriever._hydrateRequest(req);
|
|
expect(result.childRequests[0].requestedUser.userName).toBe('already_set');
|
|
expect(result.childRequests[0].requestedUser.alias).toBe('AlreadySet');
|
|
});
|
|
});
|