82b3824658
Build and Push Docker Image / build (push) Successful in 2m17s
Docs Check / Markdown lint (push) Successful in 2m27s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m44s
CI / Security audit (push) Successful in 3m10s
Docs Check / Mermaid diagram parse check (push) Successful in 3m54s
CI / Tests & coverage (push) Successful in 4m6s
CI / Swagger Validation & Coverage (push) Successful in 4m23s
652 lines
22 KiB
JavaScript
652 lines
22 KiB
JavaScript
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
|
/**
|
|
* Integration tests for webhook endpoints:
|
|
* POST /api/webhook/sonarr
|
|
* POST /api/webhook/radarr
|
|
*
|
|
* Uses supertest against createApp() (no real server).
|
|
* processWebhookEvent() makes outbound *arr API calls — those are blocked by
|
|
* nock so tests remain hermetic (fire-and-forget, not awaited by the handler).
|
|
*
|
|
* Covers:
|
|
* - 401 when X-Sofarr-Webhook-Secret is missing or wrong
|
|
* - 400 when payload is invalid (missing/unknown eventType, non-object body)
|
|
* - 200 + { received: true } for valid events
|
|
* - Replay protection: second identical event returns { duplicate: true }
|
|
* - Test event (eventType=Test) is accepted and short-circuits the cache refresh
|
|
* - cache.updateWebhookMetrics is called when a known instance name is provided
|
|
* - cache.getGlobalWebhookMetrics reflects the recorded event
|
|
*/
|
|
|
|
import request from 'supertest';
|
|
import nock from 'nock';
|
|
import { beforeEach, afterEach } 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 VALID_SECRET = 'test-webhook-secret-abc';
|
|
const EMBY_BASE = 'https://emby.test';
|
|
|
|
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 }
|
|
};
|
|
|
|
// Minimal valid Sonarr Grab payload
|
|
const SONARR_GRAB = {
|
|
eventType: 'Grab',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T10:00:00.000Z',
|
|
series: { id: 1, title: 'Test Show' },
|
|
episodes: [{ id: 10, episodeNumber: 1, seasonNumber: 1 }]
|
|
};
|
|
|
|
// Minimal valid Radarr Grab payload
|
|
const RADARR_GRAB = {
|
|
eventType: 'Grab',
|
|
instanceName: 'Main Radarr',
|
|
date: '2026-05-19T10:00:01.000Z',
|
|
movie: { id: 1, title: 'Test Movie' }
|
|
};
|
|
|
|
// Minimal Test event (sent by *arr "Test" button in notifications settings)
|
|
const SONARR_TEST = {
|
|
eventType: 'Test',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T10:00:02.000Z'
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
async function authenticateUser(app, username = 'TestUser', isAdmin = false) {
|
|
const userBody = isAdmin ? { ...EMBY_USER_BODY, Policy: { IsAdministrator: true } } : 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 };
|
|
}
|
|
|
|
function makeApp() {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
|
{ id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' }
|
|
]);
|
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
|
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
|
]);
|
|
process.env.OMBI_INSTANCES = JSON.stringify([
|
|
{ id: 'ombi-1', name: 'Main Ombi', url: 'https://ombi.test', apiKey: 'ok' }
|
|
]);
|
|
return createApp({ skipRateLimits: true });
|
|
}
|
|
|
|
function postSonarr(app, payload, secret = VALID_SECRET) {
|
|
const req = request(app).post('/api/webhook/sonarr').send(payload);
|
|
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
|
return req;
|
|
}
|
|
|
|
function postRadarr(app, payload, secret = VALID_SECRET) {
|
|
const req = request(app).post('/api/webhook/radarr').send(payload);
|
|
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
|
return req;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
|
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
|
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
|
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
|
});
|
|
|
|
afterEach(() => {
|
|
nock.cleanAll();
|
|
delete process.env.EMBY_URL;
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
delete process.env.SOFARR_BASE_URL;
|
|
delete process.env.SONARR_INSTANCES;
|
|
delete process.env.RADARR_INSTANCES;
|
|
delete process.env.OMBI_INSTANCES;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secret validation
|
|
// ---------------------------------------------------------------------------
|
|
describe('POST /api/webhook/sonarr — secret validation', () => {
|
|
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, SONARR_GRAB, null);
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, SONARR_GRAB, 'wrong-secret');
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns 401 when SOFARR_WEBHOOK_SECRET is not configured', async () => {
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
const app = createApp({ skipRateLimits: true });
|
|
const res = await postSonarr(app, SONARR_GRAB, 'anything');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webhook/radarr — secret validation', () => {
|
|
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
|
const app = makeApp();
|
|
const res = await postRadarr(app, RADARR_GRAB, null);
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
|
const app = makeApp();
|
|
const res = await postRadarr(app, RADARR_GRAB, 'bad-secret');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input validation
|
|
// ---------------------------------------------------------------------------
|
|
describe('POST /api/webhook/sonarr — input validation', () => {
|
|
it('returns 400 when body is not a JSON object (array)', async () => {
|
|
const app = makeApp();
|
|
const res = await request(app)
|
|
.post('/api/webhook/sonarr')
|
|
.set('X-Sofarr-Webhook-Secret', VALID_SECRET)
|
|
.send([{ eventType: 'Grab' }]);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 when eventType is missing', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, { instanceName: 'Main Sonarr' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/eventType/);
|
|
});
|
|
|
|
it('returns 400 when eventType is an unknown value', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, { eventType: 'HackerThing', date: '2026-01-01T00:00:00Z' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Unknown eventType/);
|
|
});
|
|
|
|
it('returns 400 when eventType is not a string', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, { eventType: 42 });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 when eventType exceeds 64 characters', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, { eventType: 'G'.repeat(65) });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 when instanceName is not a string', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, { eventType: 'Grab', instanceName: 99 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/instanceName/);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webhook/radarr — input validation', () => {
|
|
it('returns 400 when eventType is missing', async () => {
|
|
const app = makeApp();
|
|
const res = await postRadarr(app, { instanceName: 'Main Radarr' });
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('returns 400 when eventType is unknown', async () => {
|
|
const app = makeApp();
|
|
const res = await postRadarr(app, { eventType: 'Injected', date: '2026-01-01T00:00:00Z' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toMatch(/Unknown eventType/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Happy path — valid events
|
|
// ---------------------------------------------------------------------------
|
|
describe('POST /api/webhook/sonarr — valid events', () => {
|
|
it('returns 200 { received: true } for a valid Grab event', async () => {
|
|
const app = makeApp();
|
|
const payload = { ...SONARR_GRAB, date: '2026-05-19T11:00:00.000Z' };
|
|
const res = await postSonarr(app, payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
expect(res.body.duplicate).toBeUndefined();
|
|
});
|
|
|
|
it('returns 200 { received: true } for a Test event', async () => {
|
|
const app = makeApp();
|
|
const payload = { ...SONARR_TEST, date: '2026-05-19T11:01:00.000Z' };
|
|
const res = await postSonarr(app, payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it('accepts DownloadFolderImported event', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, {
|
|
eventType: 'DownloadFolderImported',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T11:02:00.000Z'
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it('accepts event without instanceName field', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, {
|
|
eventType: 'Grab',
|
|
date: '2026-05-19T11:03:00.000Z'
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/webhook/radarr — valid events', () => {
|
|
it('returns 200 { received: true } for a valid Grab event', async () => {
|
|
const app = makeApp();
|
|
const payload = { ...RADARR_GRAB, date: '2026-05-19T12:00:00.000Z' };
|
|
const res = await postRadarr(app, payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it('accepts Download event', async () => {
|
|
const app = makeApp();
|
|
const res = await postRadarr(app, {
|
|
eventType: 'Download',
|
|
instanceName: 'Main Radarr',
|
|
date: '2026-05-19T12:01:00.000Z'
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Replay protection
|
|
// ---------------------------------------------------------------------------
|
|
describe('Replay protection', () => {
|
|
it('sonarr: second identical event (same date) returns duplicate:true', async () => {
|
|
const app = makeApp();
|
|
const payload = {
|
|
eventType: 'Grab',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T13:00:00.000Z'
|
|
};
|
|
const first = await postSonarr(app, payload);
|
|
expect(first.status).toBe(200);
|
|
expect(first.body.duplicate).toBeUndefined();
|
|
|
|
const second = await postSonarr(app, payload);
|
|
expect(second.status).toBe(200);
|
|
expect(second.body.duplicate).toBe(true);
|
|
});
|
|
|
|
it('sonarr: event with different date is not considered a duplicate', async () => {
|
|
const app = makeApp();
|
|
const first = await postSonarr(app, {
|
|
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:00:00.000Z'
|
|
});
|
|
expect(first.body.duplicate).toBeUndefined();
|
|
|
|
const second = await postSonarr(app, {
|
|
eventType: 'Grab', instanceName: 'Main Sonarr', date: '2026-05-19T14:01:00.000Z'
|
|
});
|
|
expect(second.body.duplicate).toBeUndefined();
|
|
});
|
|
|
|
it('radarr: second identical event returns duplicate:true', async () => {
|
|
const app = makeApp();
|
|
const payload = {
|
|
eventType: 'Download',
|
|
instanceName: 'Main Radarr',
|
|
date: '2026-05-19T15:00:00.000Z'
|
|
};
|
|
await postRadarr(app, payload);
|
|
const second = await postRadarr(app, payload);
|
|
expect(second.body.duplicate).toBe(true);
|
|
});
|
|
|
|
it('event without date field is never considered a duplicate', async () => {
|
|
const app = makeApp();
|
|
const payload = { eventType: 'Grab', instanceName: 'Main Sonarr' };
|
|
const first = await postSonarr(app, payload);
|
|
const second = await postSonarr(app, payload);
|
|
// Neither should be flagged as duplicate (no date = no replay key)
|
|
expect(first.body.duplicate).toBeUndefined();
|
|
expect(second.body.duplicate).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Webhook metrics (Phase 5.1 integration)
|
|
// ---------------------------------------------------------------------------
|
|
describe('Webhook metrics — cache.updateWebhookMetrics integration', () => {
|
|
it('sonarr: increments eventsReceived for a known instance', async () => {
|
|
const app = makeApp();
|
|
const instanceUrl = 'https://sonarr.test';
|
|
const before = cache.getWebhookMetrics(instanceUrl);
|
|
const countBefore = before ? before.eventsReceived : 0;
|
|
|
|
await postSonarr(app, {
|
|
eventType: 'Grab',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T16:00:00.000Z'
|
|
});
|
|
|
|
const after = cache.getWebhookMetrics(instanceUrl);
|
|
expect(after.eventsReceived).toBe(countBefore + 1);
|
|
expect(after.lastWebhookTimestamp).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('radarr: increments eventsReceived for a known instance', async () => {
|
|
const app = makeApp();
|
|
const instanceUrl = 'https://radarr.test';
|
|
const before = cache.getWebhookMetrics(instanceUrl);
|
|
const countBefore = before ? before.eventsReceived : 0;
|
|
|
|
await postRadarr(app, {
|
|
eventType: 'Download',
|
|
instanceName: 'Main Radarr',
|
|
date: '2026-05-19T16:01:00.000Z'
|
|
});
|
|
|
|
const after = cache.getWebhookMetrics(instanceUrl);
|
|
expect(after.eventsReceived).toBe(countBefore + 1);
|
|
});
|
|
|
|
it('does not crash when instanceName does not match a configured instance', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, {
|
|
eventType: 'Grab',
|
|
instanceName: 'Unknown Instance',
|
|
date: '2026-05-19T16:02:00.000Z'
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it('global metrics totalWebhookEventsReceived increments after valid event', async () => {
|
|
const app = makeApp();
|
|
const beforeGlobal = cache.getGlobalWebhookMetrics();
|
|
const beforeCount = beforeGlobal.totalWebhookEventsReceived;
|
|
|
|
await postSonarr(app, {
|
|
eventType: 'Grab',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T17:00:00.000Z'
|
|
});
|
|
|
|
const afterGlobal = cache.getGlobalWebhookMetrics();
|
|
expect(afterGlobal.totalWebhookEventsReceived).toBe(beforeCount + 1);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Secret not included in response
|
|
// ---------------------------------------------------------------------------
|
|
describe('Security — secret never leaks', () => {
|
|
it('sonarr: SOFARR_WEBHOOK_SECRET is not present in any response body', async () => {
|
|
const app = makeApp();
|
|
const res = await postSonarr(app, {
|
|
eventType: 'Grab',
|
|
instanceName: 'Main Sonarr',
|
|
date: '2026-05-19T18:00:00.000Z'
|
|
});
|
|
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
|
});
|
|
|
|
it('radarr: SOFARR_WEBHOOK_SECRET is not present in 401 response body', async () => {
|
|
const app = makeApp();
|
|
const res = await postRadarr(app, RADARR_GRAB, 'wrong');
|
|
expect(JSON.stringify(res.body)).not.toContain(VALID_SECRET);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/webhook/config
|
|
// ---------------------------------------------------------------------------
|
|
describe('GET /api/webhook/config', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
const app = makeApp();
|
|
|
|
const res = await request(app)
|
|
.get('/api/webhook/config')
|
|
.expect(401);
|
|
|
|
expect(res.body.error).toBe('Not authenticated');
|
|
});
|
|
|
|
it('returns valid: true when both SOFARR_BASE_URL and SOFARR_WEBHOOK_SECRET are configured', async () => {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
|
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
|
const app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/webhook/config')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.valid).toBe(true);
|
|
expect(res.body.missing).toEqual([]);
|
|
});
|
|
|
|
it('returns valid: false when SOFARR_BASE_URL is missing', async () => {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
delete process.env.SOFARR_BASE_URL;
|
|
process.env.SOFARR_WEBHOOK_SECRET = VALID_SECRET;
|
|
const app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/webhook/config')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.valid).toBe(false);
|
|
expect(res.body.missing).toEqual(['SOFARR_BASE_URL']);
|
|
});
|
|
|
|
it('returns valid: false when SOFARR_WEBHOOK_SECRET is missing', async () => {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
process.env.SOFARR_BASE_URL = 'https://sofarr.test';
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
const app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/webhook/config')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.valid).toBe(false);
|
|
expect(res.body.missing).toEqual(['SOFARR_WEBHOOK_SECRET']);
|
|
});
|
|
|
|
it('returns valid: false when both are missing', async () => {
|
|
process.env.EMBY_URL = EMBY_BASE;
|
|
delete process.env.SOFARR_BASE_URL;
|
|
delete process.env.SOFARR_WEBHOOK_SECRET;
|
|
const app = createApp({ skipRateLimits: true });
|
|
|
|
const { cookies } = await authenticateUser(app, 'TestUser', false);
|
|
|
|
const res = await request(app)
|
|
.get('/api/webhook/config')
|
|
.set('Cookie', cookies)
|
|
.expect(200);
|
|
|
|
expect(res.body.valid).toBe(false);
|
|
expect(res.body.missing).toContain('SOFARR_BASE_URL');
|
|
expect(res.body.missing).toContain('SOFARR_WEBHOOK_SECRET');
|
|
expect(res.body.missing).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ombi webhook receiver
|
|
// ---------------------------------------------------------------------------
|
|
describe('POST /api/webhook/ombi', () => {
|
|
function postOmbi(app, payload, secret = VALID_SECRET) {
|
|
const req = request(app).post('/api/webhook/ombi').send(payload);
|
|
if (secret !== null) req.set('X-Sofarr-Webhook-Secret', secret);
|
|
return req;
|
|
}
|
|
|
|
it('returns 401 when X-Sofarr-Webhook-Secret header is missing', async () => {
|
|
const app = makeApp();
|
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, null);
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns 401 when X-Sofarr-Webhook-Secret header is wrong', async () => {
|
|
const app = makeApp();
|
|
const res = await postOmbi(app, { notificationType: 'NewRequest', requestId: 1 }, 'wrong-secret');
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.error).toBe('Unauthorized');
|
|
});
|
|
|
|
it('returns 400 when notificationType is missing or invalid', async () => {
|
|
const app = makeApp();
|
|
const res = await postOmbi(app, { requestId: 1 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
|
});
|
|
|
|
it('returns 400 when notificationType is unknown', async () => {
|
|
const app = makeApp();
|
|
const res = await postOmbi(app, { notificationType: 'UnknownNotification', requestId: 1 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('Invalid or missing notificationType');
|
|
});
|
|
|
|
it('returns 200 { received: true } for a valid NewRequest event', async () => {
|
|
const app = makeApp();
|
|
|
|
// Nock requests endpoint since processWebhookEvent will fetch requests
|
|
nock('https://ombi.test')
|
|
.get('/api/v1/Request/movie')
|
|
.reply(200, []);
|
|
nock('https://ombi.test')
|
|
.get('/api/v1/Request/tv')
|
|
.reply(200, []);
|
|
|
|
const payload = {
|
|
notificationType: 'NewRequest',
|
|
requestId: 123,
|
|
requestedUser: 'gordon',
|
|
title: 'New Movie',
|
|
type: 'Movie',
|
|
requestStatus: 'Pending',
|
|
applicationUrl: 'https://ombi.test',
|
|
requestedDate: '2026-05-23T20:30:00.000Z'
|
|
};
|
|
|
|
const res = await postOmbi(app, payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
expect(res.body.duplicate).toBeUndefined();
|
|
});
|
|
|
|
it('returns 200 { received: true } for a valid RequestAvailable event', async () => {
|
|
const app = makeApp();
|
|
|
|
nock('https://ombi.test')
|
|
.get('/api/v1/Request/movie')
|
|
.reply(200, []);
|
|
nock('https://ombi.test')
|
|
.get('/api/v1/Request/tv')
|
|
.reply(200, []);
|
|
|
|
const payload = {
|
|
notificationType: 'RequestAvailable',
|
|
requestId: 124,
|
|
requestedUser: 'gordon',
|
|
title: 'Available Movie',
|
|
type: 'Movie',
|
|
requestStatus: 'Available',
|
|
applicationUrl: 'https://ombi.test',
|
|
requestedDate: '2026-05-23T20:31:00.000Z'
|
|
};
|
|
|
|
const res = await postOmbi(app, payload);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
|
|
it('returns duplicate: true for a replay of the same event', async () => {
|
|
const app = makeApp();
|
|
|
|
nock('https://ombi.test').persist()
|
|
.get('/api/v1/Request/movie')
|
|
.reply(200, []);
|
|
nock('https://ombi.test').persist()
|
|
.get('/api/v1/Request/tv')
|
|
.reply(200, []);
|
|
|
|
const payload = {
|
|
notificationType: 'NewRequest',
|
|
requestId: 125,
|
|
requestedUser: 'gordon',
|
|
title: 'New Movie',
|
|
type: 'Movie',
|
|
requestStatus: 'Pending',
|
|
applicationUrl: 'https://ombi.test',
|
|
requestedDate: '2026-05-23T20:32:00.000Z'
|
|
};
|
|
|
|
// First request
|
|
const res1 = await postOmbi(app, payload);
|
|
expect(res1.status).toBe(200);
|
|
expect(res1.body.duplicate).toBeUndefined();
|
|
|
|
// Replay
|
|
const res2 = await postOmbi(app, payload);
|
|
expect(res2.status).toBe(200);
|
|
expect(res2.body.duplicate).toBe(true);
|
|
});
|
|
});
|
|
|