Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8aa90011e | |||
| 82b3824658 | |||
| 49e3261b59 | |||
| 2934becf32 | |||
| 6ff660b8af | |||
| 6ac0a8421e |
@@ -23,17 +23,34 @@ jobs:
|
|||||||
if [[ "$BRANCH" == develop* ]]; then
|
if [[ "$BRANCH" == develop* ]]; then
|
||||||
# Sanitise branch name for tag: replace slashes with dashes
|
# Sanitise branch name for tag: replace slashes with dashes
|
||||||
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
SAFE_BRANCH=$(echo "$BRANCH" | tr '/' '-')
|
||||||
echo "tags=reg.i3omb.com/sofarr:${SAFE_BRANCH}" >> $GITHUB_OUTPUT
|
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}"
|
||||||
echo "Building develop image ${SAFE_BRANCH} (version ${VERSION})"
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
|
||||||
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Building develop image tags: ${TAGS}"
|
||||||
else
|
else
|
||||||
RELEASE_NAME=${BRANCH#release/}
|
RELEASE_NAME=${BRANCH#release/}
|
||||||
|
|
||||||
|
# Primary registry tags
|
||||||
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
TAGS="reg.i3omb.com/sofarr:${VERSION}"
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
TAGS="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
|
||||||
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
|
||||||
|
|
||||||
|
# Gitea package registry tags
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
|
||||||
|
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
|
||||||
|
|
||||||
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
||||||
echo "Building release image ${VERSION} from branch ${BRANCH}"
|
echo "Building release image tags: ${TAGS}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Log into Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.i3omb.com
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.7.7] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Ombi webhook NewRequest parsing support** — Added `'NewRequest'` to `VALID_EVENT_TYPES` and `OMBI_EVENTS` sets in `server/routes/webhook.js`. When a user submits a new media request in Ombi, the backend now successfully parses the webhook payload, bypasses the request cache, fetches the latest request list, and broadcasts it to all connected dashboard screens via SSE in real time. Previously, these webhook payloads were rejected with a `400 Bad Request` error. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
- **Ombi webhook integration tests** — Implemented a robust integration test suite in `tests/integration/webhook.test.js` validating `POST /api/webhook/ombi` payloads, secret keys, duplicate/replay protection, input checks, and cache refresh triggers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.6] - 2026-05-23
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Bypass rate limiter for cover art** — Exempted `GET /api/dashboard/cover-art` requests from the global API rate limiter. Resolves Gitea Issue [#43](https://git.i3omb.com/Gandalf/sofarr/issues/43).
|
||||||
|
- **Ombi request cache bypass** — Added a `force` cache-bypassing flag to `OmbiRetriever`'s cache refresh mechanism, enabling real-time cache updates upon receiving Ombi webhooks or manual request list reloads. Resolves Gitea Issue [#42](https://git.i3omb.com/Gandalf/sofarr/issues/42).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [1.7.5] - 2026-05-23
|
## [1.7.5] - 2026-05-23
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.5",
|
"version": "1.7.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.5",
|
"version": "1.7.7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.5",
|
"version": "1.7.7",
|
||||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
max: skipRateLimits ? Number.MAX_SAFE_INTEGER : 300,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -87,10 +87,11 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh cached data from Ombi API
|
* Refresh cached data from Ombi API
|
||||||
|
* @param {boolean} force - Whether to force a refresh regardless of TTL
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async refreshCache() {
|
async refreshCache(force = false) {
|
||||||
if (!this.isCacheExpired()) {
|
if (!force && !this.isCacheExpired()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,19 +142,21 @@ class OmbiRetriever extends ArrRetriever {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all movie requests
|
* Get all movie requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Array>} Array of movie request objects
|
* @returns {Promise<Array>} Array of movie request objects
|
||||||
*/
|
*/
|
||||||
async getMovieRequests() {
|
async getMovieRequests(force = false) {
|
||||||
await this.refreshCache();
|
await this.refreshCache(force);
|
||||||
return this.cache.movieRequests;
|
return this.cache.movieRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all TV requests
|
* Get all TV requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Array>} Array of TV request objects
|
* @returns {Promise<Array>} Array of TV request objects
|
||||||
*/
|
*/
|
||||||
async getTvRequests() {
|
async getTvRequests(force = false) {
|
||||||
await this.refreshCache();
|
await this.refreshCache(force);
|
||||||
return this.cache.tvRequests;
|
return this.cache.tvRequests;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ const apiLimiter = rateLimit({
|
|||||||
max: 300, // 300 requests per IP per window (generous for polling)
|
max: 300, // 300 requests per IP per window (generous for polling)
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
|
||||||
message: { error: 'Too many requests, please try again later' }
|
message: { error: 'Too many requests, please try again later' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
// initialize() is idempotent - cheap no-op if already initialized
|
// initialize() is idempotent - cheap no-op if already initialized
|
||||||
await arrRetrieverRegistry.initialize();
|
await arrRetrieverRegistry.initialize();
|
||||||
|
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
|
|
||||||
// Filter by user if not admin or if showAll is false
|
// Filter by user if not admin or if showAll is false
|
||||||
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ const VALID_EVENT_TYPES = new Set([
|
|||||||
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
'Rename', 'SeriesAdd', 'SeriesDelete', 'MovieAdd', 'MovieDelete',
|
||||||
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
'MovieFileDelete', 'Health', 'ApplicationUpdate', 'HealthRestored',
|
||||||
// Ombi notification types
|
// Ombi notification types
|
||||||
'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
'NewRequest', 'RequestAvailable', 'RequestApproved', 'RequestDeclined', 'RequestPending', 'RequestProcessing'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
// Replay protection — cache recently-seen (eventType+instanceName+timestamp) keys.
|
||||||
@@ -135,6 +135,7 @@ const HISTORY_EVENTS = new Set([
|
|||||||
|
|
||||||
// Ombi event types — all Ombi events refresh the requests cache
|
// Ombi event types — all Ombi events refresh the requests cache
|
||||||
const OMBI_EVENTS = new Set([
|
const OMBI_EVENTS = new Set([
|
||||||
|
'NewRequest',
|
||||||
'RequestAvailable',
|
'RequestAvailable',
|
||||||
'RequestApproved',
|
'RequestApproved',
|
||||||
'RequestDeclined',
|
'RequestDeclined',
|
||||||
@@ -258,7 +259,7 @@ async function processWebhookEvent(serviceType, eventType) {
|
|||||||
const ombiInstances = getOmbiInstances();
|
const ombiInstances = getOmbiInstances();
|
||||||
|
|
||||||
if (affectsOmbi) {
|
if (affectsOmbi) {
|
||||||
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests();
|
const ombiRequests = await arrRetrieverRegistry.getOmbiRequests(true);
|
||||||
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
cache.set('poll:ombi-requests', ombiRequests, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
logToFile(`[Webhook] Refreshed poll:ombi-requests (${ombiRequests.movie?.length || 0} movies, ${ombiRequests.tv?.length || 0} TV shows)`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,9 +322,10 @@ const arrRetrieverRegistry = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all Ombi requests
|
* Get all Ombi requests
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Object>} Object with movie and TV request arrays
|
* @returns {Promise<Object>} Object with movie and TV request arrays
|
||||||
*/
|
*/
|
||||||
async getOmbiRequests() {
|
async getOmbiRequests(force = false) {
|
||||||
const ombiRetrievers = this.getOmbiRetrievers();
|
const ombiRetrievers = this.getOmbiRetrievers();
|
||||||
if (ombiRetrievers.length === 0) {
|
if (ombiRetrievers.length === 0) {
|
||||||
return { movie: [], tv: [] };
|
return { movie: [], tv: [] };
|
||||||
@@ -333,8 +334,8 @@ const arrRetrieverRegistry = {
|
|||||||
// Use the first Ombi retriever (single instance expected)
|
// Use the first Ombi retriever (single instance expected)
|
||||||
const retriever = ombiRetrievers[0];
|
const retriever = ombiRetrievers[0];
|
||||||
try {
|
try {
|
||||||
const movieRequests = await retriever.getMovieRequests();
|
const movieRequests = await retriever.getMovieRequests(force);
|
||||||
const tvRequests = await retriever.getTvRequests();
|
const tvRequests = await retriever.getTvRequests(false);
|
||||||
return { movie: movieRequests, tv: tvRequests };
|
return { movie: movieRequests, tv: tvRequests };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
logToFile(`[ArrRetrieverRegistry] Error fetching Ombi requests: ${error.message}`);
|
||||||
@@ -344,10 +345,11 @@ const arrRetrieverRegistry = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Ombi requests grouped by type
|
* Get Ombi requests grouped by type
|
||||||
|
* @param {boolean} force - Whether to force refresh from API
|
||||||
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
* @returns {Promise<Object>} Requests grouped by type (movie, tv)
|
||||||
*/
|
*/
|
||||||
async getOmbiRequestsByType() {
|
async getOmbiRequestsByType(force = false) {
|
||||||
return await this.getOmbiRequests();
|
return await this.getOmbiRequests(force);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ function makeApp() {
|
|||||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
{ id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' }
|
{ 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 });
|
return createApp({ skipRateLimits: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +116,10 @@ function postRadarr(app, payload, secret = VALID_SECRET) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Block outbound *arr calls made by processWebhookEvent (fire-and-forget)
|
// Block outbound *arr and Ombi calls made by processWebhookEvent (fire-and-forget)
|
||||||
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://sonarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
nock('https://radarr.test').persist().get(/.*/).reply(200, { records: [] });
|
||||||
|
nock('https://ombi.test').persist().get(/.*/).reply(200, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -125,6 +129,7 @@ afterEach(() => {
|
|||||||
delete process.env.SOFARR_BASE_URL;
|
delete process.env.SOFARR_BASE_URL;
|
||||||
delete process.env.SONARR_INSTANCES;
|
delete process.env.SONARR_INSTANCES;
|
||||||
delete process.env.RADARR_INSTANCES;
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
delete process.env.OMBI_INSTANCES;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -518,3 +523,129 @@ describe('GET /api/webhook/config', () => {
|
|||||||
expect(res.body.missing).toHaveLength(2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,39 @@ describe('OmbiRetriever', () => {
|
|||||||
expect(retriever.cache.movieRequests).toHaveLength(2);
|
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should refresh if cache is not expired but force is true', async () => {
|
||||||
|
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||||
|
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||||
|
const mockTvShows = [];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies1);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
|
||||||
|
// First refresh
|
||||||
|
await retriever.refreshCache();
|
||||||
|
expect(retriever.cache.movieRequests).toHaveLength(1);
|
||||||
|
|
||||||
|
// Set up new mocks for second refresh without advancing time
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies2);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
// Second refresh with force=true should make API calls
|
||||||
|
await retriever.refreshCache(true);
|
||||||
|
expect(retriever.cache.movieRequests).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('should build movie map with TMDB and IMDB IDs', async () => {
|
it('should build movie map with TMDB and IMDB IDs', async () => {
|
||||||
const mockMovies = [
|
const mockMovies = [
|
||||||
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
{ id: 1, title: 'Movie 1', theMovieDbId: '12345', imdbId: 'tt12345' },
|
||||||
@@ -372,6 +405,35 @@ describe('OmbiRetriever', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockMovies);
|
expect(result).toEqual(mockMovies);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should force refresh and return movie requests even when cache is not expired if force is true', async () => {
|
||||||
|
const mockMovies1 = [{ id: 1, title: 'Movie 1', theMovieDbId: '12345' }];
|
||||||
|
const mockMovies2 = [{ id: 1, title: 'Movie 1' }, { id: 2, title: 'Movie 2', theMovieDbId: '67890' }];
|
||||||
|
const mockTvShows = [];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies1);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
await retriever.refreshCache();
|
||||||
|
|
||||||
|
// Set up new mocks for second fetch
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies2);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows);
|
||||||
|
|
||||||
|
const result = await retriever.getMovieRequests(true);
|
||||||
|
expect(result).toEqual(mockMovies2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTvRequests', () => {
|
describe('getTvRequests', () => {
|
||||||
@@ -414,6 +476,35 @@ describe('OmbiRetriever', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(mockTvShows);
|
expect(result).toEqual(mockTvShows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should force refresh and return TV requests even when cache is not expired if force is true', async () => {
|
||||||
|
const mockMovies = [];
|
||||||
|
const mockTvShows1 = [{ id: 1, title: 'Show 1', theTvDbId: '11111' }];
|
||||||
|
const mockTvShows2 = [{ id: 1, title: 'Show 1' }, { id: 2, title: 'Show 2', theTvDbId: '22222' }];
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows1);
|
||||||
|
|
||||||
|
const retriever = new OmbiRetriever(instanceConfig);
|
||||||
|
await retriever.refreshCache();
|
||||||
|
|
||||||
|
// Set up new mocks for second fetch
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/movie')
|
||||||
|
.reply(200, mockMovies);
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/api/v1/Request/tv')
|
||||||
|
.reply(200, mockTvShows2);
|
||||||
|
|
||||||
|
const result = await retriever.getTvRequests(true);
|
||||||
|
expect(result).toEqual(mockTvShows2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findMovieRequest', () => {
|
describe('findMovieRequest', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user