Compare commits

...

21 Commits

Author SHA1 Message Date
gronod ed7e207abb Merge branch 'main' into release/1.7.30
Build and Push Docker Image / build (push) Successful in 57s
2026-05-28 08:04:49 +01:00
gronod e726fbe33f merge branch 'develop' into 'main' - Release v1.7.30
Create Release / release (push) Successful in 42s
CI / Security audit (push) Successful in 1m36s
CI / Swagger Validation & Coverage (push) Successful in 3m20s
CI / Tests & coverage (push) Successful in 3m53s
2026-05-28 08:03:41 +01:00
gronod 6f2901b08c fix: resolve frontend connection issues by introducing concurrent startup and dynamic proxy configuration
Build and Push Docker Image / build (push) Successful in 2m6s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m23s
CI / Security audit (push) Successful in 3m6s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
CI / Tests & coverage (push) Failing after 3m51s
2026-05-28 08:03:29 +01:00
gronod 585092ad8f merge branch 'main' into 'release/1.7.30' - Fix CHANGELOG formatting
Build and Push Docker Image / build (push) Successful in 48s
2026-05-28 07:03:09 +01:00
gronod 4107bdf611 merge branch 'develop' into 'main' - Fix CHANGELOG formatting for Release v1.7.30
CI / Security audit (push) Successful in 1m18s
CI / Tests & coverage (push) Successful in 1m55s
CI / Swagger Validation & Coverage (push) Successful in 1m24s
Create Release / release (push) Successful in 14s
2026-05-28 07:02:57 +01:00
gronod a4af16064b docs: fix markdownlint formatting error on CHANGELOG.md line 40
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m39s
CI / Swagger Validation & Coverage (push) Successful in 1m15s
Docs Check / Markdown lint (push) Successful in 35s
Docs Check / Mermaid diagram parse check (push) Successful in 1m24s
2026-05-28 07:02:51 +01:00
gronod 52806d00dc merge branch 'develop' into 'main' - Release v1.7.30
CI / Tests & coverage (push) Successful in 2m9s
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 1m3s
CI / Security audit (push) Successful in 2m52s
CI / Swagger Validation & Coverage (push) Successful in 2m4s
2026-05-28 01:39:13 +01:00
gronod d6907f42d3 chore: bump version to 1.7.30 and update CHANGELOG and docs
Docs Check / Markdown lint (push) Failing after 1m32s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m59s
CI / Security audit (push) Successful in 2m30s
Docs Check / Mermaid diagram parse check (push) Successful in 3m10s
CI / Swagger Validation & Coverage (push) Successful in 3m18s
CI / Tests & coverage (push) Successful in 1m20s
2026-05-28 01:39:01 +01:00
gronod aec04474be tests: expand coverage for poller, rate limiter, ombi decoration, downloads UI, and SSE streaming lifecycle (closes #60)
- Add tests/unit/utils/poller.test.js covering background polling lock, registry, error recovery, webhook bypasses, and global fallbacks
- Add tests/integration/rateLimiter.test.js verifying 429 response rate-limiting in an isolated production environment
- Add tests/integration/ombiDecoration.test.js covering deep links and admin role checks
- Expand tests/frontend/ui/downloads.test.js covering createServiceIcons() and createClientLogo() fallbacks
- Expand tests/integration/dashboard.test.js verifying SSE heartbeats, payload schema contract, and listener cleanup on client disconnect
2026-05-28 01:38:30 +01:00
gronod dcb77dd27f merge branch 'develop' into 'main' - Release v1.7.29
CI / Security audit (push) Successful in 2m47s
Create Release / release (push) Successful in 40s
CI / Swagger Validation & Coverage (push) Successful in 1m55s
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 3m32s
2026-05-27 23:51:12 +01:00
gronod f5315e5ceb chore: bump version to 1.7.29 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m39s
Docs Check / Markdown lint (push) Failing after 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m11s
CI / Swagger Validation & Coverage (push) Successful in 2m51s
CI / Security audit (push) Successful in 3m9s
Docs Check / Mermaid diagram parse check (push) Successful in 3m21s
CI / Tests & coverage (push) Failing after 3m44s
2026-05-27 23:46:40 +01:00
gronod 13f3d767c5 fix: resolve missing Radarr and Sonarr links on active downloads (fixes #59) 2026-05-27 23:46:35 +01:00
gronod 6c3ffb9b77 merge branch 'develop' into 'main' - Release v1.7.28
Create Release / release (push) Successful in 22s
CI / Swagger Validation & Coverage (push) Successful in 1m52s
Build and Push Docker Image / build (push) Successful in 49s
CI / Security audit (push) Successful in 3m1s
CI / Tests & coverage (push) Successful in 3m38s
2026-05-27 23:26:11 +01:00
gronod a37874c553 chore: bump version to 1.7.28 and update CHANGELOG and docs
CI / Security audit (push) Successful in 1m27s
Build and Push Docker Image / build (push) Successful in 2m0s
Docs Check / Markdown lint (push) Failing after 2m0s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 3m3s
Docs Check / Mermaid diagram parse check (push) Successful in 3m24s
CI / Tests & coverage (push) Successful in 3m34s
2026-05-27 23:25:57 +01:00
gronod 5933e09652 fix: resolve missing Sonarr link button on TV request cards (fixes #58) 2026-05-27 23:25:53 +01:00
gronod 7226404221 merge branch 'develop' into 'main' - Release v1.7.27
Create Release / release (push) Successful in 37s
CI / Swagger Validation & Coverage (push) Successful in 2m5s
CI / Security audit (push) Successful in 2m59s
Build and Push Docker Image / build (push) Successful in 1m39s
CI / Tests & coverage (push) Successful in 3m48s
2026-05-27 23:11:37 +01:00
gronod 1ee2a8044b chore: bump version to 1.7.27 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m25s
Docs Check / Markdown lint (push) Failing after 1m24s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m53s
Docs Check / Mermaid diagram parse check (push) Successful in 2m46s
CI / Security audit (push) Successful in 3m14s
CI / Swagger Validation & Coverage (push) Successful in 3m32s
CI / Tests & coverage (push) Successful in 3m55s
2026-05-27 23:11:23 +01:00
gronod 86277e2059 fix: serve frontend static files and handle SPA routes (fixes #57) 2026-05-27 23:11:19 +01:00
gronod 0eaa54cf4a merge branch 'develop' into 'main' - Release v1.7.26
Create Release / release (push) Successful in 8s
CI / Security audit (push) Successful in 3m0s
CI / Swagger Validation & Coverage (push) Successful in 1m57s
Build and Push Docker Image / build (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 3m37s
2026-05-27 22:50:31 +01:00
gronod 865cf1f57a chore: bump version to 1.7.26 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m25s
Docs Check / Markdown lint (push) Failing after 1m47s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m12s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m7s
Docs Check / Mermaid diagram parse check (push) Successful in 3m18s
CI / Tests & coverage (push) Successful in 3m28s
2026-05-27 22:50:29 +01:00
gronod ff5f50cc3a chore: remove reg.i3omb.com from build-image workflow
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Successful in 1m43s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
CI / Tests & coverage (push) Successful in 2m26s
Only publish container images to git.i3omb.com registry.
2026-05-27 21:46:34 +01:00
18 changed files with 1070 additions and 394 deletions
+2 -8
View File
@@ -27,20 +27,14 @@ 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 '/' '-')
TAGS="reg.i3omb.com/sofarr:${SAFE_BRANCH}" TAGS="git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${SAFE_BRANCH}"
echo "tags=${TAGS}" >> $GITHUB_OUTPUT echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Building develop image tags: ${TAGS}" 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="${TAGS},reg.i3omb.com/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},reg.i3omb.com/sofarr:latest"
# Gitea package registry tags # Gitea package registry tags
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${VERSION}" TAGS="git.i3omb.com/gandalf/sofarr:${VERSION}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}" TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:${RELEASE_NAME}"
TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest" TAGS="${TAGS},git.i3omb.com/gandalf/sofarr:latest"
+35
View File
@@ -4,6 +4,41 @@ 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.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
### Fixed
- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads.
## [1.7.28] - 2026-05-27
### Fixed
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
## [1.7.27] - 2026-05-27
### Fixed
- **Frontend Dashboard Serve Regression (Issue #57)** — Resolved a critical regression where the Vite-built frontend dashboard UI was not served. Consolidated Express configurations by migrating static files serving and SPA routing into the `createApp` factory in `server/app.js`. Cleaned up `server/index.js` to import and instantiate the app from the factory, successfully eliminating over 300 lines of duplicate route and middleware registrations, and ensuring alignment between development testing and production runtime configurations.
## [1.7.26] - 2026-05-27
### 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`).
## [1.7.25] - 2026-05-27 ## [1.7.25] - 2026-05-27
### Fixed ### Fixed
+3 -2
View File
@@ -194,10 +194,11 @@ function createRequestCard(request) {
const actions = document.createElement('span'); const actions = document.createElement('span');
actions.className = 'service-icons-container'; actions.className = 'service-icons-container';
if (state.ombiBaseUrl && request.theMovieDbId) { const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
if (state.ombiBaseUrl && id) {
const ombiLink = document.createElement('a'); const ombiLink = document.createElement('a');
ombiLink.className = 'ombi-link'; ombiLink.className = 'ombi-link';
ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${request.theMovieDbId}`; ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType || 'movie'}/${id}`;
ombiLink.target = '_blank'; ombiLink.target = '_blank';
ombiLink.title = 'View in Ombi'; ombiLink.title = 'View in Ombi';
+36 -24
View File
@@ -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
}
}
}
})
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.25", "version": "1.7.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.25", "version": "1.7.30",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+4 -2
View File
@@ -1,10 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.25", "version": "1.7.30",
"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",
"install:all": "npm install", "install:all": "npm install",
"test": "vitest run", "test": "vitest run",
+40 -1
View File
@@ -15,6 +15,7 @@ const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc'); const swaggerJsdoc = require('swagger-jsdoc');
const YAML = require('yamljs'); const YAML = require('yamljs');
const path = require('path'); const path = require('path');
const fs = require('fs');
const { version } = require('../package.json'); const { version } = require('../package.json');
const sabnzbdRoutes = require('./routes/sabnzbd'); const sabnzbdRoutes = require('./routes/sabnzbd');
@@ -132,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version: * version:
* type: string * type: string
* description: sofarr version * description: sofarr version
* example: "1.7.25" * example: "1.7.30"
* x-code-samples: * x-code-samples:
* - lang: curl * - lang: curl
* label: cURL * label: cURL
@@ -232,6 +233,44 @@ function createApp({ skipRateLimits = false } = {}) {
app.use('/api/status', statusRoutes); app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes); app.use('/api/history', historyRoutes);
// ---------------------------------------------------------------------------
// Static files — served before API routes
// index.html is served manually so we can inject the CSP nonce
// ---------------------------------------------------------------------------
const PUBLIC_DIR = path.join(__dirname, '../public');
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
// Serve all static assets (js, css, images, icons) except index.html.
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
// avoids re-downloading unchanged files; only a deploy changes the ETag).
app.use(express.static(PUBLIC_DIR, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// SPA catch-all — serve index.html for any unmatched path
app.get('*', serveIndex);
// Global error handler // Global error handler
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
+2 -287
View File
@@ -82,20 +82,9 @@ console.error = function(...args) {
logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`); logFile.write(`[${new Date().toISOString()}] ERROR: ${message}\n`);
}; };
const sabnzbdRoutes = require('./routes/sabnzbd');
const sonarrRoutes = require('./routes/sonarr');
const radarrRoutes = require('./routes/radarr');
const embyRoutes = require('./routes/emby');
const dashboardRoutes = require('./routes/dashboard');
const statusRoutes = require('./routes/status');
const historyRoutes = require('./routes/history');
const authRoutes = require('./routes/auth');
const webhookRoutes = require('./routes/webhook');
const ombiRoutes = require('./routes/ombi');
const debugRoutes = require('./routes/debug');
const verifyCsrf = require('./middleware/verifyCsrf');
const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller'); const { startPoller, POLL_INTERVAL, POLLING_ENABLED } = require('./utils/poller');
const { validateInstanceUrl } = require('./utils/config'); const { validateInstanceUrl } = require('./utils/config');
const { createApp } = require('./app');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Startup environment validation // Startup environment validation
@@ -117,284 +106,10 @@ if (process.env.EMBY_URL) {
validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL'); validateInstanceUrl(process.env.EMBY_URL, 'EMBY_URL');
} }
const app = express(); const app = createApp();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Load OpenAPI spec from YAML
const openapiSpec = YAML.load(path.join(__dirname, 'openapi.yaml'));
// Configure swagger-jsdoc to merge JSDoc comments from route files
const swaggerOptions = {
definition: {
...openapiSpec,
openapi: '3.1.0'
},
apis: [
path.join(__dirname, 'routes/*.js'),
path.join(__dirname, 'index.js')
]
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
// Resolve TLS_ENABLED early — used in Helmet CSP and server startup
const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false'; const TLS_ENABLED = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false';
// ---------------------------------------------------------------------------
// Trust proxy — required when behind Nginx/Caddy/Traefik so that
// req.ip reflects the real client IP (not 127.0.0.1) and
// req.secure is true when the upstream TLS is terminated by the proxy.
// Set TRUST_PROXY=1 (or a specific IP/CIDR) via env.
// ---------------------------------------------------------------------------
if (process.env.TRUST_PROXY) {
const trustValue = /^\d+$/.test(process.env.TRUST_PROXY)
? parseInt(process.env.TRUST_PROXY, 10)
: process.env.TRUST_PROXY;
app.set('trust proxy', trustValue);
}
// ---------------------------------------------------------------------------
// Helmet v7 — security response headers
// CSP uses a per-request nonce injected into index.html so inline scripts
// and styles are allowed only with a valid nonce, not blanket unsafe-inline.
// ---------------------------------------------------------------------------
app.use((req, res, next) => {
// Generate a fresh nonce for every request
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use((req, res, next) => {
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
styleSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
upgradeInsecureRequests: (process.env.TRUST_PROXY || TLS_ENABLED) ? [] : null
}
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false // not needed for this SPA
})(req, res, next);
});
// Permissions-Policy — disable powerful browser features not needed by the app
app.use((req, res, next) => {
res.setHeader(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=(), payment=(), usb=()'
);
next();
});
// ---------------------------------------------------------------------------
// General API rate limiter — applies to all /api/* routes
// More specific limiters (e.g. login) apply on top of this.
// ---------------------------------------------------------------------------
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 300, // 300 requests per IP per window (generous for polling)
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.originalUrl && req.originalUrl.startsWith('/api/dashboard/cover-art'),
message: { error: 'Too many requests, please try again later' }
});
// ---------------------------------------------------------------------------
// Body parsing & cookies
// ---------------------------------------------------------------------------
app.use(cookieParser(cookieSecret || undefined));
app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
// ---------------------------------------------------------------------------
// Health / readiness endpoints (no auth, no rate-limit)
// Used by Docker HEALTHCHECK and orchestrators.
// ---------------------------------------------------------------------------
/**
* @openapi
* /health:
* get:
* tags: [Health]
* summary: Health check
* description: Returns server uptime and status. No authentication required. Used for liveness probes.
* security: []
* responses:
* '200':
* description: Server is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ok"
* uptime:
* type: number
* description: Server uptime in seconds
* example: 3600.5
* version:
* type: string
* description: sofarr version
* example: "1.7.25"
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version });
});
/**
* @openapi
* /ready:
* get:
* tags: [Health]
* summary: Readiness check
* description: Checks if critical configuration (EMBY_URL) is present. Used by Docker HEALTHCHECK and orchestrators. No authentication required.
* security: []
* responses:
* '200':
* description: Server is ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "ready"
* '503':
* description: Server not ready
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: "not ready"
* reason:
* type: string
* example: "EMBY_URL not configured"
*/
app.get('/ready', (req, res) => {
// Confirm critical config is present
const ready = !!(process.env.EMBY_URL);
if (ready) {
res.json({ status: 'ready' });
} else {
res.status(503).json({ status: 'not ready', reason: 'EMBY_URL not configured' });
}
});
// ---------------------------------------------------------------------------
// Swagger UI - publicly accessible API documentation
// ---------------------------------------------------------------------------
app.use('/api/swagger', swaggerUi.serve, swaggerUi.setup(null, {
customSiteTitle: 'sofarr API Documentation',
customCss: '.swagger-ui .topbar { display: none }',
customJs: [
'/swagger-auth-banner.js'
],
swaggerOptions: {
url: '/api/swagger.json'
}
}));
// Serve the raw OpenAPI spec as JSON with dynamic server URL
app.get('/api/swagger.json', (req, res) => {
// Clone the spec to avoid modifying the original
const specCopy = JSON.parse(JSON.stringify(swaggerSpec));
// Replace the server URL with the current request's origin
if (specCopy.servers && specCopy.servers.length > 0) {
const protocol = req.protocol;
const host = req.get('host');
specCopy.servers[0].url = `${protocol}://${host}`;
}
res.json(specCopy);
});
// ---------------------------------------------------------------------------
// Static files — served before API routes
// index.html is served manually so we can inject the CSP nonce
// ---------------------------------------------------------------------------
const PUBLIC_DIR = path.join(__dirname, '../public');
const INDEX_HTML = path.join(PUBLIC_DIR, 'index.html');
// Serve all static assets (js, css, images, icons) except index.html.
// JS and CSS get no-cache so browsers revalidate on every load (ETag still
// avoids re-downloading unchanged files; only a deploy changes the ETag).
app.use(express.static(PUBLIC_DIR, {
index: false,
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
}
}
}));
// Serve index.html with CSP nonce injected into <script> tags
function serveIndex(req, res) {
fs.readFile(INDEX_HTML, 'utf8', (err, html) => {
if (err) return res.status(500).send('Internal Server Error');
const nonce = res.locals.cspNonce;
// Only inject nonce into <script> tags — style-src 'self' already permits
// same-origin <link rel=stylesheet> without a nonce, and injecting a nonce
// onto <link> breaks mobile browsers / caching proxies (stale HTML carries
// the old nonce which no longer matches the per-request CSP header).
const patched = html
.replace(/<script([^>]*)>/gi, `<script nonce="${nonce}"$1>`);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(patched);
});
}
// ---------------------------------------------------------------------------
// API routes (rate-limited; auth routes exempt CSRF for login/csrf endpoints)
// CSRF protection applies to all state-changing /api/* requests except
// /api/auth/login (pre-auth) and /api/auth/csrf (issues the token).
// ---------------------------------------------------------------------------
app.use('/api', apiLimiter);
app.use('/api/auth', authRoutes);
app.use('/api/webhook', webhookRoutes);
app.use('/api/debug', debugRoutes);
// All routes below this point require CSRF validation on mutating methods
app.use('/api', verifyCsrf);
app.use('/api/sabnzbd', sabnzbdRoutes);
app.use('/api/sonarr', sonarrRoutes);
app.use('/api/radarr', radarrRoutes);
app.use('/api/emby', embyRoutes);
app.use('/api/ombi', ombiRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/status', statusRoutes);
app.use('/api/history', historyRoutes);
// SPA catch-all — serve index.html for any unmatched path
app.get('*', serveIndex);
// ---------------------------------------------------------------------------
// Global error handler — never leak stack traces to clients
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error('[Server] Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TLS / HTTPS support // TLS / HTTPS support
// Set TLS_CERT and TLS_KEY to paths of your certificate and private key. // Set TLS_CERT and TLS_KEY to paths of your certificate and private key.
+1 -1
View File
@@ -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.24 version: 1.7.30
contact: contact:
name: sofarr name: sofarr
license: license:
+17 -3
View File
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher'); const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
const arrRetrieverRegistry = require('../utils/arrRetrievers'); const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config'); const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers'); const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
const { canBlocklist } = require('../services/DownloadAssembler'); const { canBlocklist } = require('../services/DownloadAssembler');
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
ombiBaseUrl ombiBaseUrl
}); });
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
res.json({ res.json({
user: user.name, user: user.name,
isAdmin, isAdmin,
@@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
ombiBaseUrl ombiBaseUrl
}); });
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`); console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({ const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(), id: c.getInstanceId(),
@@ -525,8 +533,14 @@ router.get('/stream', requireAuth, async (req, res) => {
const showAllOmbi = showAll; // Use the same showAll flag for Ombi const showAllOmbi = showAll; // Use the same showAll flag for Ombi
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi); const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi); const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
}
const ombiRequestsFiltered = { const ombiRequestsFiltered = {
movie: filteredOmbiMovieRequests, movie: filteredOmbiMovieRequests,
+6 -61
View File
@@ -4,7 +4,7 @@ const { logToFile } = require('../utils/logger');
const cache = require('../utils/cache'); const cache = require('../utils/cache');
const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config'); const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config');
const requireAuth = require('../middleware/requireAuth'); const requireAuth = require('../middleware/requireAuth');
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers'); const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers');
const { applyRequestFilters } = require('../utils/ombiFilters'); const { applyRequestFilters } = require('../utils/ombiFilters');
const router = express.Router(); const router = express.Router();
@@ -120,72 +120,17 @@ router.get('/requests', requireAuth, async (req, res) => {
const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll); const filteredMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAll);
const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll); const filteredTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAll);
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
// Fetch all series and movies in parallel to match
const [sonarrData, radarrData] = await Promise.all([
Promise.all(sonarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/series`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, series: response.data || [] };
} catch {
return { instance: r, series: [] };
}
})),
Promise.all(radarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, movies: response.data || [] };
} catch {
return { instance: r, movies: [] };
}
}))
]);
// For TV requests, find match in Sonarr
filteredTvRequests.forEach(req => {
const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId;
if (!tvdbId) return;
for (const instData of sonarrData) {
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
if (match && match.titleSlug) {
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
req.arrType = 'sonarr';
break;
}
}
});
// For Movie requests, find match in Radarr
filteredMovieRequests.forEach(req => {
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
if (!tmdbId) return;
for (const instData of radarrData) {
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
if (match && match.titleSlug) {
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
req.arrType = 'radarr';
break;
}
}
});
}
// Tag with mediaType and flatten for filtering/sorting // Tag with mediaType and flatten for filtering/sorting
const allRequests = [ const allRequests = [
...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })), ...filteredMovieRequests.map(r => ({ ...r, mediaType: 'movie' })),
...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' })) ...filteredTvRequests.map(r => ({ ...r, mediaType: 'tv' }))
]; ];
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
await decorateRequestsWithArrLinks(allRequests, isAdmin);
}
// Parse query params // Parse query params
let types = req.query.type; let types = req.query.type;
let statuses = req.query.status; let statuses = req.query.status;
+154 -1
View File
@@ -80,7 +80,160 @@ function filterRequestsByUser(requests, username, showAll) {
}); });
} }
async function decorateRequestsWithArrLinks(requests, isAdmin) {
if (!isAdmin || !Array.isArray(requests)) return;
const arrRetrieverRegistry = require('./arrRetrievers');
await arrRetrieverRegistry.initialize();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
const [sonarrData, radarrData] = await Promise.all([
Promise.all(sonarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/series`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, series: response.data || [] };
} catch {
return { instance: r, series: [] };
}
})),
Promise.all(radarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, movies: response.data || [] };
} catch {
return { instance: r, movies: [] };
}
}))
]);
requests.forEach(req => {
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
// Fallback to checking for TV specific IDs.
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
if (isTv) {
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
if (!tvdbId) return;
for (const instData of sonarrData) {
const match = instData.series.find(s => s && (s.tvdbId === parseInt(tvdbId, 10) || s.tmdbId === parseInt(tvdbId, 10)));
if (match && match.titleSlug) {
req.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
req.arrType = 'sonarr';
break;
}
}
} else {
const tmdbId = req.theMovieDbId || req.imdbId || req.theTmdbId || req.TheMovieDbId || req.ImdbId;
if (!tmdbId) return;
for (const instData of radarrData) {
const match = instData.movies.find(m => m && (m.tmdbId === parseInt(tmdbId, 10) || m.imdbId === tmdbId));
if (match && match.titleSlug) {
req.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
req.arrType = 'radarr';
break;
}
}
}
});
}
async function decorateDownloadsWithArrLinks(downloads, isAdmin) {
if (!isAdmin || !Array.isArray(downloads)) return;
const arrRetrieverRegistry = require('./arrRetrievers');
await arrRetrieverRegistry.initialize();
const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || [];
const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || [];
const [sonarrData, radarrData] = await Promise.all([
Promise.all(sonarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/series`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, series: response.data || [] };
} catch {
return { instance: r, series: [] };
}
})),
Promise.all(radarrRetrievers.map(async r => {
try {
const response = await require('axios').get(`${r.url}/api/v3/movie`, {
headers: { 'X-Api-Key': r.apiKey }
});
return { instance: r, movies: response.data || [] };
} catch {
return { instance: r, movies: [] };
}
}))
]);
downloads.forEach(dl => {
// Determine if it's TV (series) or Movie
const isTv = dl.type === 'series' || dl.arrType === 'sonarr';
if (isTv) {
// Look for a match in Sonarr instances
for (const instData of sonarrData) {
const match = instData.series.find(s => {
if (!s) return false;
// Match by database series ID if the instance matches
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) {
return true;
}
// Fallback to seriesName matching
if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) {
return true;
}
return false;
});
if (match && match.titleSlug) {
dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`;
dl.arrType = 'sonarr';
break;
}
}
} else if (dl.type === 'movie' || dl.arrType === 'radarr') {
// Look for a match in Radarr instances
for (const instData of radarrData) {
const match = instData.movies.find(m => {
if (!m) return false;
// Match by database movie ID if instance matches
const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url;
if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) {
return true;
}
// Fallback to movieName matching
if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) {
return true;
}
return false;
});
if (match && match.titleSlug) {
dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`;
dl.arrType = 'radarr';
break;
}
}
}
});
}
module.exports = { module.exports = {
extractRequestedUser, extractRequestedUser,
filterRequestsByUser filterRequestsByUser,
decorateRequestsWithArrLinks,
decorateDownloadsWithArrLinks
}; };
+136
View File
@@ -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
});
});
});
+124
View File
@@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.canBlocklist).toBe(true); expect(dl.canBlocklist).toBe(true);
}); });
}); });
describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => {
it('decorates active series downloads with Sonarr links for administrator', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH);
// Seed cache: queue record exists and matches SABnzbd slot
cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL);
cache.set('poll:sab-history', { slots: [] }, CACHE_TTL);
cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL);
cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL);
cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-history', { records: [] }, CACHE_TTL);
cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL);
cache.set('poll:qbittorrent', [], CACHE_TTL);
// Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record
nock(SONARR_BASE)
.get('/api/v3/series')
.reply(200, [
{ id: 43, title: 'Admin Show', titleSlug: 'admin-show' }
]);
// Mock Radarr /api/v3/movie response
nock(RADARR_BASE)
.get('/api/v3/movie')
.reply(200, []);
const res = await request(app)
.get('/api/dashboard/user-downloads')
.set('Cookie', cookies);
expect(res.status).toBe(200);
const downloads = res.body.downloads;
const dl = downloads.find(d => d.type === 'series');
expect(dl).toBeDefined();
expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show');
expect(dl.arrType).toBe('sonarr');
});
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1093,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();
});
}); });
+42
View File
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser'); expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
}); });
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
// 1. Setup mock instance config
process.env.SONARR_INSTANCES = JSON.stringify([
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
]);
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
arrRetrieverRegistry.retrievers.clear();
arrRetrieverRegistry.initialized = false;
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
const tvRequestsWithTvDbId = [
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
];
nock.cleanAll();
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
nock('https://sonarr.test')
.get('/api/v3/series')
.reply(200, [
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
]);
const { cookies } = await authenticateUser(app, 'AdminUser', true);
const res = await request(app)
.get('/api/ombi/requests?showAll=true')
.set('Cookie', cookies)
.expect(200);
// 4. Assert decoration succeeded
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
expect(supermanShow).toBeDefined();
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
expect(supermanShow.arrType).toBe('sonarr');
// Clean up
delete process.env.SONARR_INSTANCES;
});
it('handles case-insensitive username matching', async () => { it('handles case-insensitive username matching', async () => {
const requestsWithMixedCase = [ const requestsWithMixedCase = [
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' }, { id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
+142
View File
@@ -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();
});
});
+65
View File
@@ -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');
});
});
+257
View File
@@ -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
});
});
});