// 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 }); }); });