Files
sofarr/tests/integration/webhook.test.js
gronod 06818dbf29
Build and Push Docker Image / build (push) Has been cancelled
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
CI / Swagger Validation & Coverage (push) Has been cancelled
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m56s
fix(webhooks): skip replay protection for Test events (closes #71)
2026-05-28 18:11:45 +01:00

780 lines
26 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);
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/sonarr?secret=${VALID_SECRET}`)
.send(SONARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/sonarr?secret=wrong-query-secret')
.send(SONARR_GRAB);
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthorized');
});
});
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);
});
it('returns 200 when secret is provided as a query parameter instead of header', async () => {
const app = makeApp();
const res = await request(app)
.post(`/api/webhook/radarr?secret=${VALID_SECRET}`)
.send(RADARR_GRAB);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/radarr?secret=wrong-query-secret')
.send(RADARR_GRAB);
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();
});
it('sonarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
const app = makeApp();
const payload = {
eventType: 'Test',
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).toBeUndefined();
});
it('radarr: Test events bypass replay protection and are not flagged as duplicates', async () => {
const app = makeApp();
const payload = {
eventType: 'Test',
instanceName: 'Main Radarr',
date: '2026-05-19T13:00:00.000Z'
};
const first = await postRadarr(app, payload);
expect(first.status).toBe(200);
expect(first.body.duplicate).toBeUndefined();
const second = await postRadarr(app, payload);
expect(second.status).toBe(200);
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 200 when secret is provided as a query parameter instead of header', 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 res = await request(app)
.post(`/api/webhook/ombi?secret=${VALID_SECRET}`)
.send({
notificationType: 'NewRequest',
requestId: 127,
requestedUser: 'gordon',
title: 'Query Movie',
type: 'Movie',
requestStatus: 'Pending',
applicationUrl: 'https://ombi.test',
requestedDate: '2026-05-23T20:40:00.000Z'
});
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
});
it('returns 401 when secret is provided as an invalid query parameter', async () => {
const app = makeApp();
const res = await request(app)
.post('/api/webhook/ombi?secret=wrong-query-secret')
.send({ notificationType: 'NewRequest', requestId: 1 });
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);
});
it('returns 200 { received: true } for a valid NewRequest event with PascalCase payload', 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: 'NewRequest',
RequestId: 126,
RequestedUser: { UserName: 'gordon_pascal' },
Title: 'Pascal Movie',
Type: 'Movie',
RequestStatus: 'Pending',
ApplicationUrl: 'https://ombi.test',
RequestedDate: '2026-05-23T20:33:00.000Z'
};
const res = await postOmbi(app, payload);
expect(res.status).toBe(200);
expect(res.body.received).toBe(true);
expect(res.body.duplicate).toBeUndefined();
});
});