Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498eabc7bc | ||
|
|
6b73727d4e | ||
|
|
593ad79670 | ||
|
|
b4a9d7187b | ||
|
|
691d101e56 | ||
|
|
6f2901b08c | ||
|
|
a4af16064b | ||
|
|
d6907f42d3 | ||
|
|
aec04474be |
+26
-1
@@ -4,6 +4,31 @@ 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.32] - YYYY-MM-DD
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **qBittorrentClient Peer Data & Response Safety (Issue #64)** — `QBittorrentClient.normalizeDownload()` now exposes two new fields on every torrent record: `seeds` (sourced from qBittorrent's `num_seeds`, the count of connected seed peers) and `peers` (sourced from `num_leechs`, the count of connected leecher peers). The connected counts were chosen deliberately over the swarm totals `num_complete`/`num_incomplete` so the values remain consistent with what other clients (Transmission via `peersConnected`/`peersSendingToUs`, rTorrent via `d.peers_connected`) report on the same normalised contract. `QBittorrentClient.getMainData()` now also defensively returns the existing in-memory torrent map (rather than dereferencing a null) when the qBittorrent server responds with an empty body to `/api/v2/sync/maindata`, eliminating a crash class observed against transiently-restarting qBittorrent instances. A regression test verifies the new fields are populated from `num_seeds`/`num_leechs` and not from the swarm-total fields. Resolves Gitea Issue [#64](https://git.i3omb.com/Gandalf/sofarr/issues/64).
|
||||||
|
- **Season Pack Queue Handling & Crash Prevention (Issue #61)** — Extracted a shared `buildArrQueueCache(queues, instances, mediaKey)` helper at `server/utils/arrQueueHelpers.js` covering both Sonarr and Radarr, replacing four previously-divergent inline `flatMap` blocks across the background poller (`server/utils/poller.js`) and the webhook event processor (`server/routes/webhook.js`) that built the `poll:sonarr-queue` and `poll:radarr-queue` cache entries. Sonarr queue records that share a `downloadId` (the canonical fingerprint for a season-pack release) are now annotated with `isSeasonPack: true` and `episodeCount: <n>` so downstream consumers — including the active-downloads matching service — can identify and de-duplicate season packs without re-deriving the grouping. The helper is wrapped in per-record and per-instance `try`/`catch` guards: malformed records (`null`, missing `data`, unknown instance ids) are skipped with a warning rather than throwing, eliminating a class of crashes that previously bubbled out of the `flatMap` and tore down the entire poll cycle or webhook refresh. Movies (Radarr) skip season-pack annotation by design. A new unit test suite at `tests/unit/utils/arrQueueHelpers.test.js` covers tagging, season-pack grouping, null-safety, and unknown-instance fallback. Resolves Gitea Issue [#61](https://git.i3omb.com/Gandalf/sofarr/issues/61).
|
||||||
|
- **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62).
|
||||||
|
|
||||||
|
## [1.7.31] - 2026-05-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Frontend Connection Remediation** — Staged and committed dynamic proxy target configurations and startup pipeline orchestrations. Rebuilt the production build of `public/app.js` to ensure dynamic SSL bypass and dynamic local network address resolution are fully compiled and deployed.
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Testing Scope Expansion (Issue #60)** — Significantly expanded unit and integration test coverage targeting the background polling engine, rate limiting, and frontend rendering logic to secure core behaviors.
|
||||||
|
- **Background Poller Tests (`poller.test.js`)**: Implemented robust unit tests verifying concurrency guards, subscriber registries (`onPollComplete`/`offPollComplete`), graceful error recovery, webhook bypasses, and global fallbacks using a hybrid testing approach with Vitest fake timers.
|
||||||
|
- **Isolated Rate Limiting (`rateLimiter.test.js`)**: Designed a dedicated integration test that unsets `SKIP_RATE_LIMIT` and asserts `429 Too Many Requests` responses under rapid login requests while maintaining test suite stability.
|
||||||
|
- **Active Downloads Decoration (`ombiDecoration.test.js`)**: Covered deep-link generation (`arrLink`) for active download matching under admin and non-admin states.
|
||||||
|
- **Frontend UI Rendering (`downloads.test.js`)**: Expanded coverage for the downloads rendering engine, specifically targeting conditional administrative icon construction (`createServiceIcons()`) and client logo loading fallbacks (`createClientLogo()`).
|
||||||
|
- **SSE Connection Lifecycle & Payload Contracts (`dashboard.test.js`)**: Added stream tests checking Server-Sent Event heartbeat emission, payload schema schemas, and client close event listeners (`req.on('close')`) to verify callback cleanup and prevent connection-based memory leaks.
|
||||||
|
|
||||||
## [1.7.29] - 2026-05-27
|
## [1.7.29] - 2026-05-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -26,7 +51,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Missing Ombi & *Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and *Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
- **Missing Ombi & \*Arr Request Links (Issue #56)** — Resolved an issue where the Ombi and \*Arr lookup buttons failed to appear against request cards. Added `decorateRequestsWithArrLinks` to aggregate IDs and query Radarr/Sonarr libraries during SSE stream decoration and backend REST fetching. Also fixed a frontend condition failing to generate Ombi links for TV requests by checking a broader set of ID properties (`theTvDbId`, `theTmdbId`, `imdbId`).
|
||||||
|
|
||||||
## [1.7.25] - 2026-05-27
|
## [1.7.25] - 2026-05-27
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copy application source owned by root (read-only at runtime)
|
# Copy application source owned by root (read-only at runtime)
|
||||||
COPY --chown=root:root server/ ./server/
|
COPY --chown=root:root server/ ./server/
|
||||||
COPY --chown=root:root public/ ./public/
|
COPY --chown=root:root public/ ./public/
|
||||||
COPY --from=client-build --chown=root:root /app/public/app.js ./public/app.js
|
COPY --from=client-build --chown=root:root /app/public/ ./public/
|
||||||
COPY --chown=root:root package.json ./
|
COPY --chown=root:root package.json ./
|
||||||
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
# Bundled snakeoil certificate for out-of-the-box TLS (self-signed, localhost only).
|
||||||
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
# Mount your own cert/key over /app/certs/ or set TLS_CERT/TLS_KEY env vars.
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+36
-24
@@ -1,28 +1,40 @@
|
|||||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
build: {
|
// Load env variables from root directory to match backend TLS configuration
|
||||||
outDir: '../public',
|
const env = loadEnv(mode, path.resolve(__dirname, '..'), '');
|
||||||
emptyOutDir: false,
|
|
||||||
rollupOptions: {
|
const port = env.PORT || 3001;
|
||||||
input: {
|
const tlsEnabled = (env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
|
||||||
main: './src/main.js'
|
const target = `${tlsEnabled ? 'https' : 'http'}://localhost:${port}`;
|
||||||
},
|
|
||||||
output: {
|
return {
|
||||||
entryFileNames: 'app.js',
|
build: {
|
||||||
chunkFileNames: '[name].js',
|
outDir: '../public',
|
||||||
assetFileNames: '[name][extname]'
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: './src/main.js'
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'app.js',
|
||||||
|
chunkFileNames: '[name].js',
|
||||||
|
assetFileNames: '[name][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true, // Listen on all network interfaces
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: target,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false // Allow self-signed certificate in development
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
server: {
|
});
|
||||||
port: 5173,
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.29",
|
"version": "1.7.31",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.29",
|
"version": "1.7.31",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+5
-2
@@ -1,11 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.29",
|
"version": "1.7.31",
|
||||||
"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": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "nodemon server/index.js",
|
||||||
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
"dev": "concurrently -n 'server,client' -c 'blue,green' 'npm run dev:server' 'npm run dev:client'",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
|
"build": "npm run build --prefix client",
|
||||||
"install:all": "npm install",
|
"install:all": "npm install",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
|||||||
+15
-15
File diff suppressed because one or more lines are too long
+1
-1
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.29"
|
* example: "1.7.31"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`);
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
logToFile(`[qBittorrent:${this.name}] Empty response from sync/maindata`);
|
||||||
|
return Array.from(this.torrentMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
if (data.full_update) {
|
if (data.full_update) {
|
||||||
// Full refresh: rebuild the entire map
|
// Full refresh: rebuild the entire map
|
||||||
this.torrentMap.clear();
|
this.torrentMap.clear();
|
||||||
@@ -249,6 +254,14 @@ class QBittorrentClient extends DownloadClient {
|
|||||||
downloaded: downloadedSize,
|
downloaded: downloadedSize,
|
||||||
speed: torrent.dlspeed,
|
speed: torrent.dlspeed,
|
||||||
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta,
|
||||||
|
// Connected peer counts (Issue #64). qBittorrent exposes:
|
||||||
|
// num_seeds — connected seeds (peers we have a connection to)
|
||||||
|
// num_leechs — connected leechers (peers downloading from us)
|
||||||
|
// num_complete / num_incomplete — *swarm* totals reported by tracker
|
||||||
|
// We expose the connected counts to stay consistent with what other
|
||||||
|
// clients (e.g. Transmission via peersConnected/peersSendingToUs) report.
|
||||||
|
seeds: torrent.num_seeds ?? 0,
|
||||||
|
peers: torrent.num_leechs ?? 0,
|
||||||
category: torrent.category || undefined,
|
category: torrent.category || undefined,
|
||||||
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [],
|
||||||
savePath: torrent.content_path || torrent.save_path || undefined,
|
savePath: torrent.content_path || torrent.save_path || undefined,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.29
|
version: 1.7.31
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
+30
-30
@@ -5,6 +5,7 @@ const { logToFile } = require('../utils/logger');
|
|||||||
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
const { getWebhookSecret, getSonarrInstances, getRadarrInstances, getOmbiInstances, getSofarrBaseUrl } = require('../utils/config');
|
||||||
const cache = require('../utils/cache');
|
const cache = require('../utils/cache');
|
||||||
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
const arrRetrieverRegistry = require('../utils/arrRetrievers');
|
||||||
|
const { buildArrQueueCache } = require('../utils/arrQueueHelpers');
|
||||||
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
const { pollAllServices, POLL_INTERVAL, POLLING_ENABLED } = require('../utils/poller');
|
||||||
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser } = require('../utils/ombiHelpers');
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
@@ -106,9 +107,14 @@ function pruneReplayCache() {
|
|||||||
// Prune the replay cache once per minute
|
// Prune the replay cache once per minute
|
||||||
setInterval(pruneReplayCache, 60 * 1000).unref();
|
setInterval(pruneReplayCache, 60 * 1000).unref();
|
||||||
|
|
||||||
function isReplay(eventType, instanceName, eventDate) {
|
function isReplay(eventType, instanceName, eventDate, contentId) {
|
||||||
if (!eventDate) return false;
|
if (!eventDate) return false;
|
||||||
const key = `${eventType}:${instanceName || ''}:${eventDate}`;
|
// Content-aware replay key: incorporates downloadId / series.id / movie.id when
|
||||||
|
// available so that distinct events sharing the same `date` (e.g. multiple
|
||||||
|
// Grab events for episodes in a season pack fired in the same second) do not
|
||||||
|
// falsely collide. Falls back to the prior shape when contentId is absent
|
||||||
|
// (e.g. Test events) so existing behaviour is preserved.
|
||||||
|
const key = `${eventType}:${instanceName || ''}:${contentId || ''}:${eventDate}`;
|
||||||
if (recentEvents.has(key)) return true;
|
if (recentEvents.has(key)) return true;
|
||||||
recentEvents.set(key, Date.now());
|
recentEvents.set(key, Date.now());
|
||||||
return false;
|
return false;
|
||||||
@@ -202,17 +208,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
|
|||||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||||
const sonarrQueues = queuesByType.sonarr || [];
|
const sonarrQueues = queuesByType.sonarr || [];
|
||||||
cache.set('poll:sonarr-queue', {
|
cache.set('poll:sonarr-queue', {
|
||||||
records: sonarrQueues.flatMap(q => {
|
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.series) r.series._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:sonarr-queue (${sonarrQueues.length} instance(s))`);
|
||||||
}
|
}
|
||||||
@@ -232,17 +228,7 @@ async function processWebhookEvent(serviceType, eventType, payload = null) {
|
|||||||
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
const queuesByType = await arrRetrieverRegistry.getQueuesByType();
|
||||||
const radarrQueues = queuesByType.radarr || [];
|
const radarrQueues = queuesByType.radarr || [];
|
||||||
cache.set('poll:radarr-queue', {
|
cache.set('poll:radarr-queue', {
|
||||||
records: radarrQueues.flatMap(q => {
|
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, CACHE_TTL);
|
}, CACHE_TTL);
|
||||||
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
logToFile(`[Webhook] Refreshed poll:radarr-queue (${radarrQueues.length} instance(s))`);
|
||||||
}
|
}
|
||||||
@@ -480,11 +466,18 @@ router.post('/sonarr', webhookLimiter, (req, res) => {
|
|||||||
const { eventType, instanceName, eventDate } = validation;
|
const { eventType, instanceName, eventDate } = validation;
|
||||||
|
|
||||||
const sonarrInstances = getSonarrInstances();
|
const sonarrInstances = getSonarrInstances();
|
||||||
const inst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName) || sonarrInstances[0];
|
const matchedInst = sonarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
||||||
|
const inst = matchedInst || sonarrInstances[0];
|
||||||
|
if (!matchedInst && instanceName) {
|
||||||
|
logToFile(`[Webhook] Sonarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
||||||
|
}
|
||||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||||
|
|
||||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
// Content-aware replay key components (Issue #62)
|
||||||
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
const contentId = req.body.downloadId || req.body.series?.id || null;
|
||||||
|
|
||||||
|
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||||
|
logToFile(`[Webhook] Sonarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,11 +627,18 @@ router.post('/radarr', webhookLimiter, (req, res) => {
|
|||||||
const { eventType, instanceName, eventDate } = validation;
|
const { eventType, instanceName, eventDate } = validation;
|
||||||
|
|
||||||
const radarrInstances = getRadarrInstances();
|
const radarrInstances = getRadarrInstances();
|
||||||
const inst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName) || radarrInstances[0];
|
const matchedInst = radarrInstances.find(i => i.name === instanceName || i.id === instanceName);
|
||||||
|
const inst = matchedInst || radarrInstances[0];
|
||||||
|
if (!matchedInst && instanceName) {
|
||||||
|
logToFile(`[Webhook] Radarr instanceName "${instanceName}" did not match any configured instance; falling back to first instance (${inst ? inst.name : 'none'})`);
|
||||||
|
}
|
||||||
const resolvedInstanceName = inst ? inst.name : instanceName;
|
const resolvedInstanceName = inst ? inst.name : instanceName;
|
||||||
|
|
||||||
if (isReplay(eventType, resolvedInstanceName, eventDate)) {
|
// Content-aware replay key components (Issue #62)
|
||||||
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate}`);
|
const contentId = req.body.downloadId || req.body.movie?.id || null;
|
||||||
|
|
||||||
|
if (isReplay(eventType, resolvedInstanceName, eventDate, contentId)) {
|
||||||
|
logToFile(`[Webhook] Radarr duplicate event ignored: ${eventType} @ ${eventDate} (contentId=${contentId || 'none'})`);
|
||||||
return res.status(200).json({ received: true, duplicate: true });
|
return res.status(200).json({ received: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
//
|
||||||
|
// Shared helpers for assembling the cached *arr queue payload.
|
||||||
|
//
|
||||||
|
// Both the background poller (`server/utils/poller.js`) and the webhook
|
||||||
|
// processor (`server/routes/webhook.js`) build the `poll:sonarr-queue` and
|
||||||
|
// `poll:radarr-queue` cache entries from an array of per-instance queue
|
||||||
|
// responses. Historically the same `flatMap` block was duplicated across all
|
||||||
|
// four call sites (Sonarr + Radarr × poller + webhook) and had begun to drift.
|
||||||
|
//
|
||||||
|
// This module centralises that logic, adds defensive null-guards, and — for
|
||||||
|
// Sonarr only — annotates season-pack records (queue entries sharing a
|
||||||
|
// `downloadId`) with `isSeasonPack` and `episodeCount`. See Issue #61.
|
||||||
|
//
|
||||||
|
const { logToFile } = require('./logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the flattened, instance-tagged `records` array for the
|
||||||
|
* `poll:sonarr-queue` / `poll:radarr-queue` cache entry.
|
||||||
|
*
|
||||||
|
* @param {Array<{ instance: string, data: { records?: Array<object> } }>} queues
|
||||||
|
* Per-instance queue responses as returned by
|
||||||
|
* `arrRetrieverRegistry.getQueuesByType()` (or the equivalent batched
|
||||||
|
* retrieval in the poller).
|
||||||
|
* @param {Array<{ id: string, url: string, apiKey: string, name?: string }>} instances
|
||||||
|
* Configured instances; used to resolve `_instanceUrl` / `_instanceKey`.
|
||||||
|
* @param {'series'|'movie'} mediaKey
|
||||||
|
* Sonarr records embed a `series` object; Radarr records embed a `movie`
|
||||||
|
* object. The embedded object is annotated with `_instanceUrl` so that
|
||||||
|
* downstream link builders work.
|
||||||
|
* @returns {Array<object>} The flattened, annotated records array.
|
||||||
|
*/
|
||||||
|
function buildArrQueueCache(queues, instances, mediaKey) {
|
||||||
|
if (!Array.isArray(queues) || queues.length === 0) return [];
|
||||||
|
if (mediaKey !== 'series' && mediaKey !== 'movie') {
|
||||||
|
logToFile(`[arrQueueHelpers] Invalid mediaKey "${mediaKey}"; expected 'series' or 'movie'`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const safeInstances = Array.isArray(instances) ? instances : [];
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
for (const q of queues) {
|
||||||
|
try {
|
||||||
|
if (!q || !q.data) continue;
|
||||||
|
const inst = safeInstances.find(i => i.id === q.instance);
|
||||||
|
const url = inst ? inst.url : null;
|
||||||
|
const key = inst ? inst.apiKey : null;
|
||||||
|
const records = Array.isArray(q.data.records) ? q.data.records : [];
|
||||||
|
for (const r of records) {
|
||||||
|
try {
|
||||||
|
if (!r) continue;
|
||||||
|
if (r[mediaKey]) {
|
||||||
|
r[mediaKey]._instanceUrl = url;
|
||||||
|
}
|
||||||
|
r._instanceUrl = url;
|
||||||
|
r._instanceKey = key;
|
||||||
|
out.push(r);
|
||||||
|
} catch (perRecordErr) {
|
||||||
|
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} record: ${perRecordErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (perInstanceErr) {
|
||||||
|
logToFile(`[arrQueueHelpers] Skipping malformed ${mediaKey} queue payload: ${perInstanceErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sonarr-only: season pack annotation. Group by downloadId; entries that
|
||||||
|
// share a downloadId are episodes belonging to the same release (a season
|
||||||
|
// pack). Movies (mediaKey === 'movie') are single-record by nature.
|
||||||
|
if (mediaKey === 'series') {
|
||||||
|
try {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const r of out) {
|
||||||
|
const dlId = r && r.downloadId;
|
||||||
|
if (!dlId) continue;
|
||||||
|
if (!groups.has(dlId)) groups.set(dlId, []);
|
||||||
|
groups.get(dlId).push(r);
|
||||||
|
}
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
if (group.length > 1) {
|
||||||
|
for (const r of group) {
|
||||||
|
r.isSeasonPack = true;
|
||||||
|
r.episodeCount = group.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (annotateErr) {
|
||||||
|
logToFile(`[arrQueueHelpers] Season-pack annotation failed: ${annotateErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildArrQueueCache
|
||||||
|
};
|
||||||
+3
-22
@@ -3,6 +3,7 @@ const axios = require('axios');
|
|||||||
const cache = require('./cache');
|
const cache = require('./cache');
|
||||||
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
const { initializeClients, getAllDownloads, getDownloadsByClientType } = require('./downloadClients');
|
||||||
const arrRetrieverRegistry = require('./arrRetrievers');
|
const arrRetrieverRegistry = require('./arrRetrievers');
|
||||||
|
const { buildArrQueueCache } = require('./arrQueueHelpers');
|
||||||
const {
|
const {
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
getRadarrInstances,
|
getRadarrInstances,
|
||||||
@@ -237,17 +238,7 @@ async function pollAllServices() {
|
|||||||
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
cache.set('poll:sonarr-tags', sonarrTagsResults, cacheTTL);
|
||||||
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
// Tag queue/history records with _instanceUrl so embedded series/movie objects can build links
|
||||||
cache.set('poll:sonarr-queue', {
|
cache.set('poll:sonarr-queue', {
|
||||||
records: sonarrQueues.flatMap(q => {
|
records: buildArrQueueCache(sonarrQueues, sonarrInstances, 'series')
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.series) r.series._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, cacheTTL);
|
}, cacheTTL);
|
||||||
cache.set('poll:sonarr-history', {
|
cache.set('poll:sonarr-history', {
|
||||||
records: sonarrHistories.flatMap(h => h.data.records || [])
|
records: sonarrHistories.flatMap(h => h.data.records || [])
|
||||||
@@ -265,17 +256,7 @@ async function pollAllServices() {
|
|||||||
// Radarr
|
// Radarr
|
||||||
if (shouldPollRadarr) {
|
if (shouldPollRadarr) {
|
||||||
cache.set('poll:radarr-queue', {
|
cache.set('poll:radarr-queue', {
|
||||||
records: radarrQueues.flatMap(q => {
|
records: buildArrQueueCache(radarrQueues, radarrInstances, 'movie')
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
|
||||||
const url = inst ? inst.url : null;
|
|
||||||
const key = inst ? inst.apiKey : null;
|
|
||||||
return (q.data.records || []).map(r => {
|
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
|
||||||
r._instanceUrl = url;
|
|
||||||
r._instanceKey = key;
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, cacheTTL);
|
}, cacheTTL);
|
||||||
cache.set('poll:radarr-history', {
|
cache.set('poll:radarr-history', {
|
||||||
records: radarrHistories.flatMap(h => h.data.records || [])
|
records: radarrHistories.flatMap(h => h.data.records || [])
|
||||||
|
|||||||
@@ -98,3 +98,139 @@ describe('renderTagBadges', () => {
|
|||||||
expect(result.childNodes.length).toBe(0);
|
expect(result.childNodes.length).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { createDownloadCard } from '../../../client/src/ui/downloads.js';
|
||||||
|
import { state } from '../../../client/src/state.js';
|
||||||
|
|
||||||
|
describe('createDownloadCard rendering details', () => {
|
||||||
|
let originalState;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalState = { ...state };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset global state
|
||||||
|
Object.assign(state, originalState);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createClientLogo and fallbacks', () => {
|
||||||
|
it('renders client logo img tag when client is configured', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'qbittorrent',
|
||||||
|
instanceName: 'Qbit Main'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
expect(wrapper).toBeTruthy();
|
||||||
|
|
||||||
|
const img = wrapper.querySelector('img.download-client-logo');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.src).toContain('/images/clients/qbittorrent.svg');
|
||||||
|
expect(img.alt).toBe('Qbit Main icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to character avatar text on img load error', () => {
|
||||||
|
const dl = {
|
||||||
|
title: 'Test Download',
|
||||||
|
type: 'series',
|
||||||
|
client: 'transmission'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const wrapper = card.querySelector('.download-client-logo-wrapper');
|
||||||
|
const img = wrapper.querySelector('img');
|
||||||
|
|
||||||
|
// Trigger the onerror event programmatically to simulate missing/broken SVG
|
||||||
|
img.onerror();
|
||||||
|
|
||||||
|
expect(wrapper.classList.contains('fallback')).toBe(true);
|
||||||
|
expect(wrapper.textContent).toBe('T');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createServiceIcons deep-linking', () => {
|
||||||
|
it('renders Ombi icon link for all users when ombiLink exists', () => {
|
||||||
|
state.isAdmin = false; // Non-admin should still see Ombi icon
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
ombiLink: 'https://ombi.test/request/42',
|
||||||
|
ombiTooltip: 'View on Ombi'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const ombiLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(ombiLinkEl).toBeTruthy();
|
||||||
|
expect(ombiLinkEl.href).toBe('https://ombi.test/request/42');
|
||||||
|
|
||||||
|
const img = ombiLinkEl.querySelector('img.service-icon.ombi');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('View on Ombi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Sonarr icon link for administrator when arrType is sonarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Sonarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://sonarr.test/series/the-mandalorian');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.sonarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Radarr icon link for administrator when arrType is radarr and arrLink exists', () => {
|
||||||
|
state.isAdmin = true; // Admin required for Radarr link
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Blade Runner 2049',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrType: 'radarr',
|
||||||
|
arrLink: 'https://radarr.test/movie/blade-runner-2049'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-movie a');
|
||||||
|
expect(arrLinkEl).toBeTruthy();
|
||||||
|
expect(arrLinkEl.href).toBe('https://radarr.test/movie/blade-runner-2049');
|
||||||
|
|
||||||
|
const img = arrLinkEl.querySelector('img.service-icon.radarr');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.title).toBe('Radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Sonarr/Radarr links if the user is a non-admin', () => {
|
||||||
|
state.isAdmin = false; // Non-admin
|
||||||
|
|
||||||
|
const dl = {
|
||||||
|
title: 'Mandalorian S01E01',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrType: 'sonarr',
|
||||||
|
arrLink: 'https://sonarr.test/series/the-mandalorian'
|
||||||
|
};
|
||||||
|
|
||||||
|
const card = createDownloadCard(dl);
|
||||||
|
const arrLinkEl = card.querySelector('.download-series a');
|
||||||
|
expect(arrLinkEl).toBeNull(); // Admin gate successfully hides Sonarr icon
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1134,5 +1134,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () =>
|
|||||||
expect(data.ombiRequests.movie).toHaveLength(2);
|
expect(data.ombiRequests.movie).toHaveLength(2);
|
||||||
expect(data.ombiRequests.tv).toHaveLength(2);
|
expect(data.ombiRequests.tv).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verifies SSE payload structure contract against the frontend schema', async () => {
|
||||||
|
const { cookies } = await loginAs(appInstance);
|
||||||
|
const res = await request(appInstance)
|
||||||
|
.get('/api/dashboard/stream')
|
||||||
|
.query({ testClose: 'true' })
|
||||||
|
.set('Cookie', cookies);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const text = res.text;
|
||||||
|
expect(text).toContain('data:');
|
||||||
|
|
||||||
|
const dataStr = text.substring(text.indexOf('{'));
|
||||||
|
const data = JSON.parse(dataStr.trim());
|
||||||
|
|
||||||
|
// Payload Contract Validation
|
||||||
|
expect(data).toHaveProperty('user');
|
||||||
|
expect(data).toHaveProperty('isAdmin');
|
||||||
|
expect(data).toHaveProperty('downloads');
|
||||||
|
expect(data).toHaveProperty('downloadClients');
|
||||||
|
expect(data).toHaveProperty('ombiRequests');
|
||||||
|
expect(data).toHaveProperty('ombiBaseUrl');
|
||||||
|
|
||||||
|
expect(Array.isArray(data.downloads)).toBe(true);
|
||||||
|
expect(Array.isArray(data.downloadClients)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.movie)).toBe(true);
|
||||||
|
expect(Array.isArray(data.ombiRequests.tv)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends heartbeat comment over active stream and cleans up on close', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// 1. Get the route handler from the dashboard router stack
|
||||||
|
const dashboardRouter = require('../../server/routes/dashboard.js');
|
||||||
|
const route = dashboardRouter.stack.find(layer => layer.route && layer.route.path === '/stream');
|
||||||
|
// Get the final handler (after requireAuth middleware)
|
||||||
|
const streamHandler = route.route.stack[route.route.stack.length - 1].handle;
|
||||||
|
|
||||||
|
// 2. Setup mock req and res
|
||||||
|
const mockUser = { name: 'Alice', isAdmin: false };
|
||||||
|
const reqOnCallbacks = {};
|
||||||
|
const mockReq = {
|
||||||
|
user: mockUser,
|
||||||
|
query: { showAll: 'false', testClose: 'false' },
|
||||||
|
on: vi.fn((event, cb) => {
|
||||||
|
reqOnCallbacks[event] = cb;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const resWrites = [];
|
||||||
|
const mockRes = {
|
||||||
|
setHeader: vi.fn(),
|
||||||
|
flushHeaders: vi.fn(),
|
||||||
|
write: vi.fn((data) => {
|
||||||
|
resWrites.push(data);
|
||||||
|
}),
|
||||||
|
end: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Call the handler
|
||||||
|
await streamHandler(mockReq, mockRes);
|
||||||
|
|
||||||
|
// Initial payload should be written
|
||||||
|
expect(resWrites.length).toBeGreaterThan(0);
|
||||||
|
expect(resWrites[0]).toContain('data:');
|
||||||
|
|
||||||
|
// 4. Advance time by 25s to trigger the heartbeat setInterval
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
|
||||||
|
// Check that heartbeat was written
|
||||||
|
expect(resWrites).toContain(': heartbeat\n\n');
|
||||||
|
|
||||||
|
// 5. Simulate client disconnect by triggering the 'close' event callback
|
||||||
|
expect(reqOnCallbacks['close']).toBeDefined();
|
||||||
|
reqOnCallbacks['close']();
|
||||||
|
|
||||||
|
// Check that advancing time again does NOT write another heartbeat
|
||||||
|
const beforeLength = resWrites.length;
|
||||||
|
vi.advanceTimersByTime(25000);
|
||||||
|
expect(resWrites.length).toBe(beforeLength); // No new writes!
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import nock from 'nock';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||||
|
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||||
|
|
||||||
|
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||||
|
const RADARR_BASE = 'https://radarr-decor.test';
|
||||||
|
|
||||||
|
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
nock.cleanAll();
|
||||||
|
|
||||||
|
// Reset the singleton retrievers registry so we can inject our test instances
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// Configure test environment variables for retrievers
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
delete process.env.RADARR_INSTANCES;
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||||
|
// Mock Sonarr series query
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock Radarr movie query (empty)
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian',
|
||||||
|
arrSeriesId: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||||
|
expect(downloads[0].arrType).toBe('sonarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||||
|
// Mock Sonarr series query (empty)
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, []);
|
||||||
|
|
||||||
|
// Mock Radarr movie query with matching ID
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.reply(200, [
|
||||||
|
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'Blade.Runner.2049.2017.1080p',
|
||||||
|
type: 'movie',
|
||||||
|
movieName: 'Blade Runner 2049',
|
||||||
|
arrInstanceUrl: RADARR_BASE,
|
||||||
|
arrContentId: 99
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, true);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||||
|
expect(downloads[0].arrType).toBe('radarr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips decoration entirely when isAdmin is false', async () => {
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// No nocks are set up, so any HTTP calls would throw or error
|
||||||
|
await decorateDownloadsWithArrLinks(downloads, false);
|
||||||
|
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty downloads array gracefully', async () => {
|
||||||
|
// No mock setups needed, should complete without throwing
|
||||||
|
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||||
|
// Mock Sonarr series query throwing connection error
|
||||||
|
nock(SONARR_BASE)
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.replyWithError('connection refused');
|
||||||
|
|
||||||
|
// Mock Radarr movie query throwing timeout error
|
||||||
|
nock(RADARR_BASE)
|
||||||
|
.get('/api/v3/movie')
|
||||||
|
.replyWithError('timeout');
|
||||||
|
|
||||||
|
const downloads = [
|
||||||
|
{
|
||||||
|
title: 'The.Mandalorian.S01E01.1080p',
|
||||||
|
type: 'series',
|
||||||
|
seriesName: 'The Mandalorian'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||||
|
|
||||||
|
// No links decorated since the fetch failed
|
||||||
|
expect(downloads[0].arrLink).toBeUndefined();
|
||||||
|
expect(downloads[0].arrType).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
describe('Rate Limiting Integration Tests', () => {
|
||||||
|
let app;
|
||||||
|
let originalSkipRateLimit;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Save current rate limiting skip flag
|
||||||
|
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||||
|
// Explicitly delete it before loading the app so rate limiters are active
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
process.env.EMBY_URL = 'https://emby.test';
|
||||||
|
|
||||||
|
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||||
|
const appModule = await import('../../server/app.js');
|
||||||
|
const createApp = appModule.createApp;
|
||||||
|
|
||||||
|
// Create a new app instance with rate limiting enabled
|
||||||
|
app = createApp({ skipRateLimits: false });
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore rate limit skip flag
|
||||||
|
if (originalSkipRateLimit !== undefined) {
|
||||||
|
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||||
|
} else {
|
||||||
|
delete process.env.SKIP_RATE_LIMIT;
|
||||||
|
}
|
||||||
|
delete process.env.EMBY_URL;
|
||||||
|
nock.cleanAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||||
|
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||||
|
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||||
|
// count toward the rate limit window of 10 requests.
|
||||||
|
nock('https://emby.test')
|
||||||
|
.post('/Users/authenticatebyname')
|
||||||
|
.reply(401, { error: 'Unauthorized' })
|
||||||
|
.persist();
|
||||||
|
|
||||||
|
// Fire 10 rapid failed login requests (the limit is 10)
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.error).toBe('Invalid username or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 11th request must be rate limited and return 429
|
||||||
|
const limitRes = await request(app)
|
||||||
|
.post('/api/auth/login')
|
||||||
|
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||||
|
|
||||||
|
expect(limitRes.status).toBe(429);
|
||||||
|
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -130,6 +130,8 @@ describe('QBittorrentClient', () => {
|
|||||||
downloaded: 750000000,
|
downloaded: 750000000,
|
||||||
speed: 1048576,
|
speed: 1048576,
|
||||||
eta: 3600,
|
eta: 3600,
|
||||||
|
seeds: 0,
|
||||||
|
peers: 0,
|
||||||
category: 'movies',
|
category: 'movies',
|
||||||
tags: ['movie', 'hd'],
|
tags: ['movie', 'hd'],
|
||||||
savePath: '/downloads/test',
|
savePath: '/downloads/test',
|
||||||
@@ -138,6 +140,28 @@ describe('QBittorrentClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should expose connected seeds/peers from num_seeds and num_leechs (Issue #64)', () => {
|
||||||
|
const torrent = {
|
||||||
|
hash: 'def456',
|
||||||
|
name: 'Swarm Torrent',
|
||||||
|
state: 'downloading',
|
||||||
|
progress: 0.1,
|
||||||
|
size: 1000,
|
||||||
|
completed: 100,
|
||||||
|
dlspeed: 0,
|
||||||
|
eta: 0,
|
||||||
|
num_seeds: 7,
|
||||||
|
num_leechs: 3,
|
||||||
|
// Swarm totals — must NOT be picked up as connected counts
|
||||||
|
num_complete: 200,
|
||||||
|
num_incomplete: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = client.normalizeDownload(torrent);
|
||||||
|
expect(normalized.seeds).toBe(7);
|
||||||
|
expect(normalized.peers).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle unknown torrent states', () => {
|
it('should handle unknown torrent states', () => {
|
||||||
const torrent = {
|
const torrent = {
|
||||||
hash: 'abc123',
|
hash: 'abc123',
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
// Tests for the shared `buildArrQueueCache` helper (Issue #61).
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { buildArrQueueCache } = require('../../../server/utils/arrQueueHelpers');
|
||||||
|
|
||||||
|
const sonarrInstances = [
|
||||||
|
{ id: 's1', url: 'http://sonarr-1', apiKey: 'KEY_S1', name: 'Sonarr 1' }
|
||||||
|
];
|
||||||
|
const radarrInstances = [
|
||||||
|
{ id: 'r1', url: 'http://radarr-1', apiKey: 'KEY_R1', name: 'Radarr 1' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('buildArrQueueCache', () => {
|
||||||
|
it('returns empty array for empty / missing input', () => {
|
||||||
|
expect(buildArrQueueCache([], sonarrInstances, 'series')).toEqual([]);
|
||||||
|
expect(buildArrQueueCache(null, sonarrInstances, 'series')).toEqual([]);
|
||||||
|
expect(buildArrQueueCache(undefined, sonarrInstances, 'series')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for invalid mediaKey', () => {
|
||||||
|
const queues = [{ instance: 's1', data: { records: [{ id: 1 }] } }];
|
||||||
|
expect(buildArrQueueCache(queues, sonarrInstances, 'bogus')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags Sonarr records with _instanceUrl/_instanceKey and decorates embedded series', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'dl-1', series: { id: 100, title: 'X' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBe('http://sonarr-1');
|
||||||
|
expect(out[0]._instanceKey).toBe('KEY_S1');
|
||||||
|
expect(out[0].series._instanceUrl).toBe('http://sonarr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags Radarr records and decorates embedded movie', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'r1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 11, downloadId: 'dl-r1', movie: { id: 555, title: 'M' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBe('http://radarr-1');
|
||||||
|
expect(out[0]._instanceKey).toBe('KEY_R1');
|
||||||
|
expect(out[0].movie._instanceUrl).toBe('http://radarr-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotates Sonarr season pack records (multiple entries sharing downloadId)', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'pack-A', episodeId: 101 },
|
||||||
|
{ id: 2, downloadId: 'pack-A', episodeId: 102 },
|
||||||
|
{ id: 3, downloadId: 'pack-A', episodeId: 103 },
|
||||||
|
{ id: 4, downloadId: 'single-B', episodeId: 200 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(4);
|
||||||
|
const packMembers = out.filter(r => r.downloadId === 'pack-A');
|
||||||
|
expect(packMembers).toHaveLength(3);
|
||||||
|
for (const r of packMembers) {
|
||||||
|
expect(r.isSeasonPack).toBe(true);
|
||||||
|
expect(r.episodeCount).toBe(3);
|
||||||
|
}
|
||||||
|
const single = out.find(r => r.downloadId === 'single-B');
|
||||||
|
expect(single.isSeasonPack).toBeUndefined();
|
||||||
|
expect(single.episodeCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not annotate Radarr records as season packs even if downloadId repeats', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'r1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
{ id: 1, downloadId: 'dup', movie: { id: 1 } },
|
||||||
|
{ id: 2, downloadId: 'dup', movie: { id: 2 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, radarrInstances, 'movie');
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
for (const r of out) {
|
||||||
|
expect(r.isSeasonPack).toBeUndefined();
|
||||||
|
expect(r.episodeCount).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips malformed records and continues', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 's1',
|
||||||
|
data: {
|
||||||
|
records: [
|
||||||
|
null,
|
||||||
|
{ id: 1, downloadId: 'ok', series: { id: 1 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ instance: 's1' } // no data property
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].id).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown instance id gracefully (null url/key)', () => {
|
||||||
|
const queues = [
|
||||||
|
{
|
||||||
|
instance: 'unknown-instance',
|
||||||
|
data: { records: [{ id: 1, downloadId: 'x' }] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const out = buildArrQueueCache(queues, sonarrInstances, 'series');
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0]._instanceUrl).toBeNull();
|
||||||
|
expect(out[0]._instanceKey).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
// Set environment variables before requiring any modules
|
||||||
|
process.env.POLL_INTERVAL = '5000';
|
||||||
|
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||||
|
|
||||||
|
const cache = require('../../../server/utils/cache.js');
|
||||||
|
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||||
|
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||||
|
const config = require('../../../server/utils/config.js');
|
||||||
|
|
||||||
|
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||||
|
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||||
|
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||||
|
sabnzbd: [],
|
||||||
|
qbittorrent: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||||
|
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||||
|
sonarr: [],
|
||||||
|
radarr: []
|
||||||
|
});
|
||||||
|
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||||
|
movie: [],
|
||||||
|
tv: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||||
|
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||||
|
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||||
|
|
||||||
|
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||||
|
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||||
|
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||||
|
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: null
|
||||||
|
});
|
||||||
|
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Now require the poller
|
||||||
|
const poller = require('../../../server/utils/poller.js');
|
||||||
|
|
||||||
|
describe('Background Poller Utility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Re-apply standard resolved values
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||||
|
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||||
|
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||||
|
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||||
|
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([]);
|
||||||
|
getOmbiInstancesSpy.mockReturnValue([]);
|
||||||
|
|
||||||
|
cacheSetSpy.mockImplementation(() => {});
|
||||||
|
cacheGetSpy.mockReturnValue(null);
|
||||||
|
getWebhookMetricsSpy.mockReturnValue(null);
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||||
|
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
poller.stopPoller();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Poller Core Logic', () => {
|
||||||
|
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||||
|
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||||
|
expect(poller.POLLING_ENABLED).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||||
|
let callbackFired = false;
|
||||||
|
const callback = () => {
|
||||||
|
callbackFired = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
poller.onPollComplete(callback);
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(callbackFired).toBe(true);
|
||||||
|
|
||||||
|
// Clean up/Deregister callback
|
||||||
|
poller.offPollComplete(callback);
|
||||||
|
callbackFired = false;
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(callbackFired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||||
|
// Stub initializeClients to delay using a promise
|
||||||
|
let resolveInit;
|
||||||
|
const delayPromise = new Promise((resolve) => {
|
||||||
|
resolveInit = resolve;
|
||||||
|
});
|
||||||
|
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||||
|
|
||||||
|
// Start the first poll (which remains pending on initializeClients)
|
||||||
|
const firstPollPromise = poller.pollAllServices();
|
||||||
|
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Trigger second poll immediately while first is in progress
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||||
|
|
||||||
|
// Resolve the delay to let the first poll finish
|
||||||
|
resolveInit();
|
||||||
|
await firstPollPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||||
|
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||||
|
|
||||||
|
// Setup error spy
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||||
|
|
||||||
|
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||||
|
initializeClientsSpy.mockResolvedValue(true);
|
||||||
|
await poller.pollAllServices();
|
||||||
|
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Webhook-Based Instance Bypassing', () => {
|
||||||
|
it('skips polling for an instance with recent active webhook events', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Verify that skips are incremented for both
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||||
|
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||||
|
|
||||||
|
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||||
|
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||||
|
const staleTimestamp = Date.now() - 11 * 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Should bypass the skip and perform a full poll
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||||
|
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||||
|
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||||
|
|
||||||
|
// Mock recent metrics on individual level but stale globally
|
||||||
|
const recentTimestamp = Date.now() - 60000;
|
||||||
|
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||||
|
if (url === 'https://sonarr.test') {
|
||||||
|
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global webhook is stale
|
||||||
|
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||||
|
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||||
|
});
|
||||||
|
|
||||||
|
await poller.pollAllServices();
|
||||||
|
|
||||||
|
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||||
|
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
|
||||||
|
// Triggered immediately on start (flush microtasks)
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Advance time by 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Advance by another 5000ms
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||||
|
poller.startPoller();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
poller.stopPoller();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10000);
|
||||||
|
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user