diff --git a/CHANGELOG.md b/CHANGELOG.md index 810801e..45768a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this project will be documented in this file. 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). +## [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 diff --git a/package-lock.json b/package-lock.json index 43aa59f..6502cae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.29", + "version": "1.7.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.29", + "version": "1.7.30", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index df1094f..39b0fb7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.29", + "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", "main": "server/index.js", "scripts": { diff --git a/server/app.js b/server/app.js index d54a0e0..9129f18 100644 --- a/server/app.js +++ b/server/app.js @@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) { * version: * type: string * description: sofarr version - * example: "1.7.29" + * example: "1.7.30" * x-code-samples: * - lang: curl * label: cURL diff --git a/server/openapi.yaml b/server/openapi.yaml index 8bd1e8c..1a91acd 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -22,7 +22,7 @@ info: ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.7.29 + version: 1.7.30 contact: name: sofarr license: diff --git a/tests/frontend/ui/downloads.test.js b/tests/frontend/ui/downloads.test.js index 39dea22..9a92ca7 100644 --- a/tests/frontend/ui/downloads.test.js +++ b/tests/frontend/ui/downloads.test.js @@ -98,3 +98,139 @@ describe('renderTagBadges', () => { 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 + }); + }); +}); + diff --git a/tests/integration/dashboard.test.js b/tests/integration/dashboard.test.js index a932562..007de28 100644 --- a/tests/integration/dashboard.test.js +++ b/tests/integration/dashboard.test.js @@ -1134,5 +1134,88 @@ describe('GET /api/dashboard/stream — SSE with Ombi showAll filtering', () => expect(data.ombiRequests.movie).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(); + }); }); diff --git a/tests/integration/ombiDecoration.test.js b/tests/integration/ombiDecoration.test.js new file mode 100644 index 0000000..7271a6d --- /dev/null +++ b/tests/integration/ombiDecoration.test.js @@ -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(); + }); +}); diff --git a/tests/integration/rateLimiter.test.js b/tests/integration/rateLimiter.test.js new file mode 100644 index 0000000..e70e314 --- /dev/null +++ b/tests/integration/rateLimiter.test.js @@ -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'); + }); +}); diff --git a/tests/unit/utils/poller.test.js b/tests/unit/utils/poller.test.js new file mode 100644 index 0000000..ea2d233 --- /dev/null +++ b/tests/unit/utils/poller.test.js @@ -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 + }); + }); +});