aec04474be
- 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
258 lines
9.8 KiB
JavaScript
258 lines
9.8 KiB
JavaScript
// 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
|
|
});
|
|
});
|
|
});
|