1102 lines
36 KiB
JavaScript
1102 lines
36 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
/**
|
|
* Integration tests for server/routes/ombi.js
|
|
*
|
|
* Strategy:
|
|
* - createApp({ skipRateLimits: true }) for a real Express instance
|
|
* - nock intercepts Emby auth so we can obtain a valid session cookie
|
|
* - Mock cache.getWebhookMetrics() for webhook status endpoint
|
|
* - nock intercepts Ombi API calls for webhook status/test endpoints
|
|
*
|
|
* Covers:
|
|
* GET /api/ombi/requests — auth guard, showAll parameter, user filtering (skipped - requires complex arrRetrieverRegistry mocking)
|
|
* GET /api/ombi/webhook/status — auth guard, extended response with triggers and stats
|
|
* POST /api/ombi/webhook/enable — auth guard, Ombi configuration check
|
|
* POST /api/ombi/webhook/test — auth guard, Ombi configuration check, test webhook
|
|
*/
|
|
|
|
import request from 'supertest';
|
|
import nock from 'nock';
|
|
import { beforeEach, afterEach, vi } from 'vitest';
|
|
import { createRequire } from 'module';
|
|
import { createApp } from '../../server/app.js';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const cache = require('../../server/utils/cache.js');
|
|
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EMBY_BASE = 'https://emby.test';
|
|
const OMBI_BASE = 'https://ombi.test';
|
|
const SOFARR_BASE = 'https://sofarr.test';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EMBY_AUTH_BODY = {
|
|
AccessToken: 'test-emby-token-abc123',
|
|
User: { Id: 'user-id-001', Name: 'TestUser' }
|
|
};
|
|
|
|
const EMBY_USER_BODY = {
|
|
Id: 'user-id-001',
|
|
Name: 'TestUser',
|
|
Policy: { IsAdministrator: false }
|
|
};
|
|
|
|
const EMBY_ADMIN_BODY = {
|
|
Id: 'admin-id-001',
|
|
Name: 'AdminUser',
|
|
Policy: { IsAdministrator: true }
|
|
};
|
|
|
|
const OMBI_REQUESTS = {
|
|
movie: [
|
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' },
|
|
{ id: 2, title: 'Admin Movie', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'movie' }
|
|
],
|
|
tv: [
|
|
{ id: 3, title: 'Test Show', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'tv' },
|
|
{ id: 4, title: 'Admin Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv' }
|
|
]
|
|
};
|
|
|
|
const OMBI_WEBHOOK_CONFIG = {
|
|
enabled: true,
|
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi`,
|
|
applicationToken: 'test-ombi-api-key'
|
|
};
|
|
|
|
const OMBI_WEBHOOK_METRICS = {
|
|
eventsReceived: 10,
|
|
pollsSkipped: 5,
|
|
lastWebhookTimestamp: 1716326400000
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function interceptSuccessfulLogin(userBody = EMBY_USER_BODY) {
|
|
nock(EMBY_BASE)
|
|
.post('/Users/authenticatebyname')
|
|
.reply(200, EMBY_AUTH_BODY);
|
|
nock(EMBY_BASE)
|
|
.get(/\/Users\//)
|
|
.reply(200, userBody);
|
|
}
|
|
|
|
function setupOmbiRequestMocks(movieRequests = OMBI_REQUESTS.movie, tvRequests = OMBI_REQUESTS.tv) {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Request/movie')
|
|
.reply(200, movieRequests);
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Request/tv')
|
|
.reply(200, tvRequests);
|
|
}
|
|
|
|
function makeApp() {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
process.env.OMBI_INSTANCES = JSON.stringify([
|
|
{ id: 'ombi-1', name: 'Test Ombi', url: OMBI_BASE, apiKey: 'test-ombi-key' }
|
|
]);
|
|
process.env.SOFARR_BASE_URL = SOFARR_BASE;
|
|
process.env.SOFARR_WEBHOOK_SECRET = 'test-webhook-secret';
|
|
return createApp({ skipRateLimits: true });
|
|
}
|
|
|
|
async function authenticateUser(app, username = 'TestUser', isAdmin = false) {
|
|
const userBody = isAdmin ? EMBY_ADMIN_BODY : EMBY_USER_BODY;
|
|
interceptSuccessfulLogin(userBody);
|
|
|
|
const res = await request(app)
|
|
.post('/api/auth/login')
|
|
.send({ username, password: 'password' });
|
|
|
|
const cookies = res.headers['set-cookie'];
|
|
const csrfToken = res.body.csrfToken;
|
|
|
|
return { cookies, csrfToken };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup/Teardown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
nock.cleanAll();
|
|
});
|
|
|
|
afterEach(() => {
|
|
nock.cleanAll();
|
|
delete process.env.EMBY_URL;
|
|
delete process.env.OMBI_INSTANCES;
|
|
delete process.env.SOFARR_BASE_URL;
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/ombi/requests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/ombi/requests', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
app = makeApp();
|
|
setupOmbiRequestMocks();
|
|
|
|
// Reset the singleton registry so it re-initializes on each request
|
|
arrRetrieverRegistry.retrievers.clear();
|
|
arrRetrieverRegistry.initialized = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
arrRetrieverRegistry.retrievers.clear();
|
|
arrRetrieverRegistry.initialized = false;
|
|
});
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.expect(401);
|
|
expect(res.body.error).toBe('Not authenticated');
|
|
});
|
|
|
|
it('returns user-filtered requests for non-admin users', async () => {
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.user).toBe('TestUser');
|
|
expect(res.body.isAdmin).toBe(false);
|
|
expect(res.body.showAll).toBe(false);
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('testuser');
|
|
expect(res.body.requests.tv).toHaveLength(1);
|
|
expect(res.body.requests.tv[0].requestedUser.userName).toBe('testuser');
|
|
expect(res.body.total).toBe(2);
|
|
});
|
|
|
|
it('returns all requests when admin with showAll=true', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.user).toBe('AdminUser');
|
|
expect(res.body.isAdmin).toBe(true);
|
|
expect(res.body.showAll).toBe(true);
|
|
expect(res.body.requests.movie).toHaveLength(2);
|
|
expect(res.body.requests.tv).toHaveLength(2);
|
|
expect(res.body.total).toBe(4);
|
|
});
|
|
|
|
it('returns user-filtered requests when admin with showAll=false', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?showAll=false')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.user).toBe('AdminUser');
|
|
expect(res.body.isAdmin).toBe(true);
|
|
expect(res.body.showAll).toBe(false);
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
|
expect(res.body.requests.tv).toHaveLength(1);
|
|
expect(res.body.requests.tv[0].requestedUser.userName).toBe('adminuser');
|
|
expect(res.body.total).toBe(2);
|
|
});
|
|
|
|
it('returns user-filtered requests when admin without showAll parameter', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.showAll).toBe(false);
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
|
});
|
|
|
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
|
// 1. Setup mock instance config
|
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
|
]);
|
|
|
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
|
arrRetrieverRegistry.retrievers.clear();
|
|
arrRetrieverRegistry.initialized = false;
|
|
|
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
|
const tvRequestsWithTvDbId = [
|
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
|
|
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
|
nock('https://sonarr.test')
|
|
.get('/api/v3/series')
|
|
.reply(200, [
|
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
|
]);
|
|
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
// 4. Assert decoration succeeded
|
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
|
expect(supermanShow).toBeDefined();
|
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
|
expect(supermanShow.arrType).toBe('sonarr');
|
|
|
|
// Clean up
|
|
delete process.env.SONARR_INSTANCES;
|
|
});
|
|
|
|
it('handles case-insensitive username matching', async () => {
|
|
const requestsWithMixedCase = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
|
{ id: 2, title: 'Admin Movie', requestedUser: { userName: 'ADMIN' }, requestedByAlias: 'ADMIN', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithMixedCase, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'testuser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('TestUser');
|
|
});
|
|
|
|
it('handles missing requestedUser field gracefully', async () => {
|
|
const requestsWithMissingUser = [
|
|
{ id: 1, title: 'Test Movie', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithMissingUser, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(0);
|
|
expect(res.body.total).toBe(0);
|
|
});
|
|
|
|
it('handles empty requests array', async () => {
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks([], []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(0);
|
|
expect(res.body.requests.tv).toHaveLength(0);
|
|
expect(res.body.total).toBe(0);
|
|
});
|
|
|
|
it('handles object-format requestedUser with alias field', async () => {
|
|
const requestsWithAlias = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: { alias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithAlias, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.alias).toBe('testuser');
|
|
});
|
|
|
|
it('handles object-format requestedUser with userName field', async () => {
|
|
const requestsWithUserName = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithUserName, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('testuser');
|
|
});
|
|
|
|
it('handles object-format requestedUser with userAlias field', async () => {
|
|
const requestsWithUserAlias = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: { userAlias: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithUserAlias, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.userAlias).toBe('testuser');
|
|
});
|
|
|
|
it('handles object-format requestedUser with normalizedUserName field', async () => {
|
|
const requestsWithNormalizedUserName = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: { normalizedUserName: 'testuser' }, requestedByAlias: 'testuser', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithNormalizedUserName, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.requests.movie[0].requestedUser.normalizedUserName).toBe('testuser');
|
|
});
|
|
|
|
it('handles requestedUser as null gracefully', async () => {
|
|
const requestsWithNullUser = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: null, requestedByAlias: 'otheruser', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithNullUser, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(0);
|
|
expect(res.body.total).toBe(0);
|
|
});
|
|
|
|
it('handles requestedUser as empty object gracefully', async () => {
|
|
const requestsWithEmptyObject = [
|
|
{ id: 1, title: 'Test Movie', requestedUser: {}, requestedByAlias: 'testuser', type: 'movie' }
|
|
];
|
|
|
|
nock.cleanAll();
|
|
setupOmbiRequestMocks(requestsWithEmptyObject, []);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(1);
|
|
expect(res.body.total).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/ombi/requests — query param filtering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const FILTERED_MOVIE_REQUESTS = [
|
|
{ id: 1, title: 'The Batman', requestedDate: '2026-05-21T10:00:00.000Z', available: false, approved: true, denied: false, requested: true, theMovieDbId: 414906 },
|
|
{ id: 2, title: 'Batman Returns', requestedDate: '2026-05-10T10:00:00.000Z', available: true, approved: true, denied: false, requested: true, theMovieDbId: 414907 }
|
|
];
|
|
|
|
const FILTERED_TV_REQUESTS = [
|
|
{ id: 3, title: 'Superman Show', requestedDate: '2026-05-15T10:00:00.000Z', available: false, approved: false, denied: false, requested: true, theMovieDbId: 101 }
|
|
];
|
|
|
|
describe('GET /api/ombi/requests query params', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
app = makeApp();
|
|
setupOmbiRequestMocks(FILTERED_MOVIE_REQUESTS, FILTERED_TV_REQUESTS);
|
|
|
|
// Reset the singleton registry so it re-initializes on each request
|
|
arrRetrieverRegistry.retrievers.clear();
|
|
arrRetrieverRegistry.initialized = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (arrRetrieverRegistry) {
|
|
arrRetrieverRegistry.retrievers.clear();
|
|
arrRetrieverRegistry.initialized = false;
|
|
}
|
|
});
|
|
|
|
it('filters by type=movie', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?type=movie&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie.length, `Body was: ${JSON.stringify(res.body)}`).toBe(2);
|
|
expect(res.body.requests.tv).toHaveLength(0);
|
|
expect(res.body.total).toBe(2);
|
|
});
|
|
|
|
it('filters by type=tv', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?type=tv&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.requests.movie).toHaveLength(0);
|
|
expect(res.body.requests.tv).toHaveLength(1);
|
|
expect(res.body.total).toBe(1);
|
|
});
|
|
|
|
it('filters by status=pending', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?status=pending&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.total).toBe(1);
|
|
expect(res.body.requests.tv[0].title).toBe('Superman Show');
|
|
});
|
|
|
|
it('filters by status=available', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?status=available&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.total).toBe(1);
|
|
expect(res.body.requests.movie[0].title).toBe('Batman Returns');
|
|
});
|
|
|
|
it('sorts by title_asc', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?sort=title_asc&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
|
expect(all.map(r => r.title)).toEqual(['Batman Returns', 'The Batman', 'Superman Show']);
|
|
});
|
|
|
|
it('sorts by title_desc', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?sort=title_desc&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
|
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
|
|
});
|
|
|
|
it('sorts by requestedDate_desc (default)', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
const all = [...res.body.requests.movie, ...res.body.requests.tv];
|
|
expect(all.map(r => r.title)).toEqual(['The Batman', 'Batman Returns', 'Superman Show']);
|
|
});
|
|
|
|
it('searches by title substring', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?search=bat&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.total).toBe(2);
|
|
expect(res.body.requests.movie).toHaveLength(2);
|
|
expect(res.body.requests.tv).toHaveLength(0);
|
|
});
|
|
|
|
it('combines multiple query params', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?type=movie&status=approved&search=bat&sort=title_asc&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.total).toBe(1);
|
|
expect(res.body.requests.movie[0].title).toBe('The Batman');
|
|
});
|
|
|
|
it('invalid sort falls back to default', async () => {
|
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/requests?sort=invalid&showAll=true')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.total).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/ombi/webhook/status
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/ombi/webhook/status', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
app = makeApp();
|
|
});
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.expect(401);
|
|
expect(res.body.error).toBe('Not authenticated');
|
|
});
|
|
|
|
it('returns disabled status when SOFARR_BASE_URL is missing', async () => {
|
|
delete process.env.SOFARR_BASE_URL;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(false);
|
|
expect(res.body.webhookUrl).toBeNull();
|
|
expect(res.body.applicationToken).toBeNull();
|
|
expect(res.body.triggers).toEqual({
|
|
requestAvailable: false,
|
|
requestApproved: false,
|
|
requestDeclined: false,
|
|
requestPending: false,
|
|
requestProcessing: false
|
|
});
|
|
expect(res.body.stats).toBeNull();
|
|
});
|
|
|
|
it('returns disabled status when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(false);
|
|
expect(res.body.webhookUrl).toBeNull();
|
|
expect(res.body.applicationToken).toBeNull();
|
|
expect(res.body.triggers).toEqual({
|
|
requestAvailable: false,
|
|
requestApproved: false,
|
|
requestDeclined: false,
|
|
requestPending: false,
|
|
requestProcessing: false
|
|
});
|
|
expect(res.body.stats).toBeNull();
|
|
});
|
|
|
|
it('returns disabled status when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are missing', async () => {
|
|
delete process.env.SOFARR_BASE_URL;
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(false);
|
|
expect(res.body.webhookUrl).toBeNull();
|
|
expect(res.body.applicationToken).toBeNull();
|
|
expect(res.body.triggers).toEqual({
|
|
requestAvailable: false,
|
|
requestApproved: false,
|
|
requestDeclined: false,
|
|
requestPending: false,
|
|
requestProcessing: false
|
|
});
|
|
expect(res.body.stats).toBeNull();
|
|
});
|
|
|
|
it('returns disabled status when Ombi not configured', async () => {
|
|
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(false);
|
|
expect(res.body.webhookUrl).toBeNull();
|
|
expect(res.body.applicationToken).toBeNull();
|
|
expect(res.body.triggers).toEqual({
|
|
requestAvailable: false,
|
|
requestApproved: false,
|
|
requestDeclined: false,
|
|
requestPending: false,
|
|
requestProcessing: false
|
|
});
|
|
expect(res.body.stats).toBeNull();
|
|
});
|
|
|
|
it('returns enabled status with triggers when Ombi configured', async () => {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(200, OMBI_WEBHOOK_CONFIG);
|
|
|
|
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(true);
|
|
expect(res.body.webhookUrl).toBe(OMBI_WEBHOOK_CONFIG.webhookUrl);
|
|
expect(res.body.applicationToken).toBe(OMBI_WEBHOOK_CONFIG.applicationToken);
|
|
expect(res.body.triggers).toEqual({
|
|
requestAvailable: true,
|
|
requestApproved: true,
|
|
requestDeclined: true,
|
|
requestPending: true,
|
|
requestProcessing: true
|
|
});
|
|
expect(res.body.stats).toBeNull();
|
|
});
|
|
|
|
it('returns stats when metrics available in cache', async () => {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(200, OMBI_WEBHOOK_CONFIG);
|
|
|
|
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(OMBI_WEBHOOK_METRICS);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(true);
|
|
expect(res.body.stats).toEqual({
|
|
eventsReceived: 10,
|
|
pollsSkipped: 5,
|
|
lastWebhookTimestamp: 1716326400000
|
|
});
|
|
});
|
|
|
|
it('returns disabled triggers when webhook disabled in Ombi', async () => {
|
|
const disabledConfig = { ...OMBI_WEBHOOK_CONFIG, enabled: false };
|
|
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(200, disabledConfig);
|
|
|
|
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(false);
|
|
expect(res.body.triggers).toEqual({
|
|
requestAvailable: false,
|
|
requestApproved: false,
|
|
requestDeclined: false,
|
|
requestPending: false,
|
|
requestProcessing: false
|
|
});
|
|
});
|
|
|
|
it('handles Ombi API errors gracefully', async () => {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(500, { error: 'Internal server error' });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(500);
|
|
|
|
expect(res.body.error).toBe('Failed to fetch Ombi webhook status');
|
|
});
|
|
|
|
it('handles missing webhookUrl and applicationToken in Ombi response', async () => {
|
|
const incompleteConfig = { enabled: true };
|
|
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(200, incompleteConfig);
|
|
|
|
vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/ombi/webhook/status')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.enabled).toBe(true);
|
|
expect(res.body.webhookUrl).toBeNull();
|
|
expect(res.body.applicationToken).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/ombi/webhook/enable
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/ombi/webhook/enable', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
app = makeApp();
|
|
});
|
|
|
|
it('returns 403 when not authenticated (CSRF check before auth)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.expect(403);
|
|
expect(res.body.error).toBe('CSRF token missing');
|
|
});
|
|
|
|
it('returns 400 when SOFARR_BASE_URL is missing', async () => {
|
|
delete process.env.SOFARR_BASE_URL;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBe('SOFARR_BASE_URL not configured');
|
|
});
|
|
|
|
it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured');
|
|
});
|
|
|
|
it('returns 400 when Ombi not configured', async () => {
|
|
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBe('Ombi not configured');
|
|
});
|
|
|
|
it('enables webhook successfully', async () => {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(200, { id: 42, enabled: false, webhookUrl: null, applicationToken: null });
|
|
nock(OMBI_BASE)
|
|
.post('/api/v1/Settings/notifications/webhook', {
|
|
id: 42,
|
|
enabled: true,
|
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
|
applicationToken: 'test-ombi-key'
|
|
})
|
|
.reply(200, { success: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.webhookUrl).toBe(`${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`);
|
|
expect(res.body.applicationToken).toBe('test-ombi-key');
|
|
});
|
|
|
|
it('enables webhook successfully even if GET settings fails', async () => {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(500, { error: 'Failed to fetch settings' });
|
|
nock(OMBI_BASE)
|
|
.post('/api/v1/Settings/notifications/webhook', {
|
|
id: 0,
|
|
enabled: true,
|
|
webhookUrl: `${SOFARR_BASE}/api/webhook/ombi?secret=test-webhook-secret`,
|
|
applicationToken: 'test-ombi-key'
|
|
})
|
|
.reply(200, { success: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
|
|
it('handles Ombi API errors gracefully', async () => {
|
|
nock(OMBI_BASE)
|
|
.get('/api/v1/Settings/notifications/webhook')
|
|
.reply(200, { id: 42 });
|
|
nock(OMBI_BASE)
|
|
.post('/api/v1/Settings/notifications/webhook')
|
|
.reply(500, { error: 'Internal server error' });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/enable')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(500);
|
|
|
|
expect(res.body.error).toBe('Failed to enable Ombi webhook');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/ombi/webhook/test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('POST /api/ombi/webhook/test', () => {
|
|
let app;
|
|
|
|
beforeEach(() => {
|
|
app = makeApp();
|
|
});
|
|
|
|
it('returns 403 when not authenticated (CSRF check before auth)', async () => {
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.expect(403);
|
|
expect(res.body.error).toBe('CSRF token missing');
|
|
});
|
|
|
|
it('returns 400 when SOFARR_BASE_URL is missing', async () => {
|
|
delete process.env.SOFARR_BASE_URL;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBe('SOFARR_BASE_URL not configured');
|
|
});
|
|
|
|
it('returns 400 when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBe('SOFARR_WEBHOOK_SECRET not configured');
|
|
});
|
|
|
|
it('returns 400 when Ombi not configured', async () => {
|
|
process.env.OMBI_INSTANCES = JSON.stringify([]);
|
|
app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toBe('Ombi not configured');
|
|
});
|
|
|
|
it('sends test webhook successfully', async () => {
|
|
nock(SOFARR_BASE)
|
|
.post('/api/webhook/ombi')
|
|
.reply(200, { received: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
|
|
it('sends test webhook with correct payload', async () => {
|
|
const webhookScope = nock(SOFARR_BASE)
|
|
.post('/api/webhook/ombi')
|
|
.reply(200, { received: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(200);
|
|
|
|
// Verify the request was made with correct headers and payload
|
|
expect(webhookScope.isDone()).toBe(true);
|
|
});
|
|
|
|
it('handles webhook send errors gracefully when both public and loopback fail', async () => {
|
|
nock(SOFARR_BASE)
|
|
.post('/api/webhook/ombi')
|
|
.reply(500, { error: 'Internal server error' });
|
|
nock('http://127.0.0.1:3001')
|
|
.post('/api/webhook/ombi')
|
|
.reply(500, { error: 'Internal server error' });
|
|
nock('https://127.0.0.1:3001')
|
|
.post('/api/webhook/ombi')
|
|
.reply(500, { error: 'Internal server error' });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(500);
|
|
|
|
expect(res.body.error).toBe('Failed to test Ombi webhook');
|
|
});
|
|
|
|
it('falls back to local loopback when public URL request fails', async () => {
|
|
nock(SOFARR_BASE)
|
|
.post('/api/webhook/ombi')
|
|
.replyWithError('Connection refused');
|
|
nock('http://127.0.0.1:3001')
|
|
.post('/api/webhook/ombi')
|
|
.reply(200, { received: true });
|
|
nock('https://127.0.0.1:3001')
|
|
.post('/api/webhook/ombi')
|
|
.reply(200, { received: true });
|
|
|
|
const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.post('/api/ombi/webhook/test')
|
|
.set('Cookie', cookies)
|
|
.set('X-CSRF-Token', csrfToken)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
}); |