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
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import nock from 'nock';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { decorateDownloadsWithArrLinks } = require('../../server/utils/ombiHelpers.js');
|
||||
const arrRetrieverRegistry = require('../../server/utils/arrRetrievers.js');
|
||||
|
||||
const SONARR_BASE = 'https://sonarr-decor.test';
|
||||
const RADARR_BASE = 'https://radarr-decor.test';
|
||||
|
||||
describe('decorateDownloadsWithArrLinks Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
nock.cleanAll();
|
||||
|
||||
// Reset the singleton retrievers registry so we can inject our test instances
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
|
||||
// Configure test environment variables for retrievers
|
||||
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'sonarr-1', name: 'Test Sonarr', url: SONARR_BASE, apiKey: 'sonarr-key' }
|
||||
]);
|
||||
process.env.RADARR_INSTANCES = JSON.stringify([
|
||||
{ id: 'radarr-1', name: 'Test Radarr', url: RADARR_BASE, apiKey: 'radarr-key' }
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
delete process.env.SONARR_INSTANCES;
|
||||
delete process.env.RADARR_INSTANCES;
|
||||
arrRetrieverRegistry.retrievers.clear();
|
||||
arrRetrieverRegistry.initialized = false;
|
||||
});
|
||||
|
||||
it('decorates a series download with Sonarr link matching on title', async () => {
|
||||
// Mock Sonarr series query
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.reply(200, [
|
||||
{ id: 42, title: 'The Mandalorian', titleSlug: 'the-mandalorian' }
|
||||
]);
|
||||
|
||||
// Mock Radarr movie query (empty)
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.reply(200, []);
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
title: 'The.Mandalorian.S01E01.1080p',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian',
|
||||
arrSeriesId: null
|
||||
}
|
||||
];
|
||||
|
||||
await decorateDownloadsWithArrLinks(downloads, true);
|
||||
|
||||
expect(downloads[0].arrLink).toBe(`${SONARR_BASE}/series/the-mandalorian`);
|
||||
expect(downloads[0].arrType).toBe('sonarr');
|
||||
});
|
||||
|
||||
it('decorates a movie download with Radarr link matching on content ID', async () => {
|
||||
// Mock Sonarr series query (empty)
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.reply(200, []);
|
||||
|
||||
// Mock Radarr movie query with matching ID
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.reply(200, [
|
||||
{ id: 99, title: 'Blade Runner 2049', titleSlug: 'blade-runner-2049' }
|
||||
]);
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
title: 'Blade.Runner.2049.2017.1080p',
|
||||
type: 'movie',
|
||||
movieName: 'Blade Runner 2049',
|
||||
arrInstanceUrl: RADARR_BASE,
|
||||
arrContentId: 99
|
||||
}
|
||||
];
|
||||
|
||||
await decorateDownloadsWithArrLinks(downloads, true);
|
||||
|
||||
expect(downloads[0].arrLink).toBe(`${RADARR_BASE}/movie/blade-runner-2049`);
|
||||
expect(downloads[0].arrType).toBe('radarr');
|
||||
});
|
||||
|
||||
it('skips decoration entirely when isAdmin is false', async () => {
|
||||
const downloads = [
|
||||
{
|
||||
title: 'The.Mandalorian.S01E01.1080p',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian'
|
||||
}
|
||||
];
|
||||
|
||||
// No nocks are set up, so any HTTP calls would throw or error
|
||||
await decorateDownloadsWithArrLinks(downloads, false);
|
||||
|
||||
expect(downloads[0].arrLink).toBeUndefined();
|
||||
expect(downloads[0].arrType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty downloads array gracefully', async () => {
|
||||
// No mock setups needed, should complete without throwing
|
||||
await expect(decorateDownloadsWithArrLinks([], true)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('handles external API fetch failures gracefully without failing the decoration pipeline', async () => {
|
||||
// Mock Sonarr series query throwing connection error
|
||||
nock(SONARR_BASE)
|
||||
.get('/api/v3/series')
|
||||
.replyWithError('connection refused');
|
||||
|
||||
// Mock Radarr movie query throwing timeout error
|
||||
nock(RADARR_BASE)
|
||||
.get('/api/v3/movie')
|
||||
.replyWithError('timeout');
|
||||
|
||||
const downloads = [
|
||||
{
|
||||
title: 'The.Mandalorian.S01E01.1080p',
|
||||
type: 'series',
|
||||
seriesName: 'The Mandalorian'
|
||||
}
|
||||
];
|
||||
|
||||
await expect(decorateDownloadsWithArrLinks(downloads, true)).resolves.not.toThrow();
|
||||
|
||||
// No links decorated since the fetch failed
|
||||
expect(downloads[0].arrLink).toBeUndefined();
|
||||
expect(downloads[0].arrType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
|
||||
describe('Rate Limiting Integration Tests', () => {
|
||||
let app;
|
||||
let originalSkipRateLimit;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Save current rate limiting skip flag
|
||||
originalSkipRateLimit = process.env.SKIP_RATE_LIMIT;
|
||||
// Explicitly delete it before loading the app so rate limiters are active
|
||||
delete process.env.SKIP_RATE_LIMIT;
|
||||
process.env.EMBY_URL = 'https://emby.test';
|
||||
|
||||
// Dynamically import createApp so that routes/auth.js evaluates process.env.SKIP_RATE_LIMIT as undefined
|
||||
const appModule = await import('../../server/app.js');
|
||||
const createApp = appModule.createApp;
|
||||
|
||||
// Create a new app instance with rate limiting enabled
|
||||
app = createApp({ skipRateLimits: false });
|
||||
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore rate limit skip flag
|
||||
if (originalSkipRateLimit !== undefined) {
|
||||
process.env.SKIP_RATE_LIMIT = originalSkipRateLimit;
|
||||
} else {
|
||||
delete process.env.SKIP_RATE_LIMIT;
|
||||
}
|
||||
delete process.env.EMBY_URL;
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('triggers a 429 Too Many Requests error on the auth endpoint after 10 failed requests', async () => {
|
||||
// Mock Emby server auth endpoint to return 401 (failed credentials).
|
||||
// The login rate limiter has `skipSuccessfulRequests: true`, meaning ONLY failed login attempts
|
||||
// count toward the rate limit window of 10 requests.
|
||||
nock('https://emby.test')
|
||||
.post('/Users/authenticatebyname')
|
||||
.reply(401, { error: 'Unauthorized' })
|
||||
.persist();
|
||||
|
||||
// Fire 10 rapid failed login requests (the limit is 10)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toBe('Invalid username or password');
|
||||
}
|
||||
|
||||
// The 11th request must be rate limited and return 429
|
||||
const limitRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'TestUser', password: 'wrongpassword' });
|
||||
|
||||
expect(limitRes.status).toBe(429);
|
||||
expect(limitRes.body.error).toContain('Too many login attempts');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Set environment variables before requiring any modules
|
||||
process.env.POLL_INTERVAL = '5000';
|
||||
process.env.WEBHOOK_FALLBACK_TIMEOUT = '10';
|
||||
|
||||
const cache = require('../../../server/utils/cache.js');
|
||||
const downloadClients = require('../../../server/utils/downloadClients.js');
|
||||
const arrRetrieverRegistry = require('../../../server/utils/arrRetrievers.js');
|
||||
const config = require('../../../server/utils/config.js');
|
||||
|
||||
// Set up mock/spies before requiring poller so destructuring in poller captures the spied versions!
|
||||
const initializeClientsSpy = vi.spyOn(downloadClients, 'initializeClients').mockResolvedValue(true);
|
||||
const getDownloadsByClientTypeSpy = vi.spyOn(downloadClients, 'getDownloadsByClientType').mockResolvedValue({
|
||||
sabnzbd: [],
|
||||
qbittorrent: []
|
||||
});
|
||||
|
||||
const arrRegistryInitializeSpy = vi.spyOn(arrRetrieverRegistry, 'initialize').mockResolvedValue(true);
|
||||
const getTagsByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getTagsByType').mockResolvedValue({
|
||||
sonarr: [],
|
||||
radarr: []
|
||||
});
|
||||
const getQueuesByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getQueuesByType').mockResolvedValue({
|
||||
sonarr: [],
|
||||
radarr: []
|
||||
});
|
||||
const getHistoryByTypeSpy = vi.spyOn(arrRetrieverRegistry, 'getHistoryByType').mockResolvedValue({
|
||||
sonarr: [],
|
||||
radarr: []
|
||||
});
|
||||
const getOmbiRequestsSpy = vi.spyOn(arrRetrieverRegistry, 'getOmbiRequests').mockResolvedValue({
|
||||
movie: [],
|
||||
tv: []
|
||||
});
|
||||
|
||||
const getSonarrInstancesSpy = vi.spyOn(config, 'getSonarrInstances').mockReturnValue([]);
|
||||
const getRadarrInstancesSpy = vi.spyOn(config, 'getRadarrInstances').mockReturnValue([]);
|
||||
const getOmbiInstancesSpy = vi.spyOn(config, 'getOmbiInstances').mockReturnValue([]);
|
||||
|
||||
const cacheSetSpy = vi.spyOn(cache, 'set').mockImplementation(() => {});
|
||||
const cacheGetSpy = vi.spyOn(cache, 'get').mockReturnValue(null);
|
||||
const getWebhookMetricsSpy = vi.spyOn(cache, 'getWebhookMetrics').mockReturnValue(null);
|
||||
const getGlobalWebhookMetricsSpy = vi.spyOn(cache, 'getGlobalWebhookMetrics').mockReturnValue({
|
||||
lastGlobalWebhookTimestamp: null
|
||||
});
|
||||
const incrementPollsSkippedSpy = vi.spyOn(cache, 'incrementPollsSkipped').mockImplementation(() => {});
|
||||
|
||||
// Now require the poller
|
||||
const poller = require('../../../server/utils/poller.js');
|
||||
|
||||
describe('Background Poller Utility', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Re-apply standard resolved values
|
||||
initializeClientsSpy.mockResolvedValue(true);
|
||||
getDownloadsByClientTypeSpy.mockResolvedValue({ sabnzbd: [], qbittorrent: [] });
|
||||
arrRegistryInitializeSpy.mockResolvedValue(true);
|
||||
getTagsByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||
getQueuesByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||
getHistoryByTypeSpy.mockResolvedValue({ sonarr: [], radarr: [] });
|
||||
getOmbiRequestsSpy.mockResolvedValue({ movie: [], tv: [] });
|
||||
|
||||
getSonarrInstancesSpy.mockReturnValue([]);
|
||||
getRadarrInstancesSpy.mockReturnValue([]);
|
||||
getOmbiInstancesSpy.mockReturnValue([]);
|
||||
|
||||
cacheSetSpy.mockImplementation(() => {});
|
||||
cacheGetSpy.mockReturnValue(null);
|
||||
getWebhookMetricsSpy.mockReturnValue(null);
|
||||
getGlobalWebhookMetricsSpy.mockReturnValue({ lastGlobalWebhookTimestamp: null });
|
||||
incrementPollsSkippedSpy.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
poller.stopPoller();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Poller Core Logic', () => {
|
||||
it('POLL_INTERVAL matches parsed environment variable', () => {
|
||||
expect(poller.POLL_INTERVAL).toBe(5000);
|
||||
expect(poller.POLLING_ENABLED).toBe(true);
|
||||
});
|
||||
|
||||
it('successfully registers and notifies SSE subscribers on poll completion', async () => {
|
||||
let callbackFired = false;
|
||||
const callback = () => {
|
||||
callbackFired = true;
|
||||
};
|
||||
|
||||
poller.onPollComplete(callback);
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
expect(callbackFired).toBe(true);
|
||||
|
||||
// Clean up/Deregister callback
|
||||
poller.offPollComplete(callback);
|
||||
callbackFired = false;
|
||||
|
||||
await poller.pollAllServices();
|
||||
expect(callbackFired).toBe(false);
|
||||
});
|
||||
|
||||
it('prevents concurrent executions of pollAllServices via the polling guard', async () => {
|
||||
// Stub initializeClients to delay using a promise
|
||||
let resolveInit;
|
||||
const delayPromise = new Promise((resolve) => {
|
||||
resolveInit = resolve;
|
||||
});
|
||||
initializeClientsSpy.mockImplementation(() => delayPromise);
|
||||
|
||||
// Start the first poll (which remains pending on initializeClients)
|
||||
const firstPollPromise = poller.pollAllServices();
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// Trigger second poll immediately while first is in progress
|
||||
await poller.pollAllServices();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('[Poller] Previous poll still running, skipping');
|
||||
|
||||
// Resolve the delay to let the first poll finish
|
||||
resolveInit();
|
||||
await firstPollPromise;
|
||||
});
|
||||
|
||||
it('resets the polling guard flag on error so future polls can run', async () => {
|
||||
initializeClientsSpy.mockRejectedValue(new Error('Initialization failed'));
|
||||
|
||||
// Setup error spy
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('[Poller] Poll error:', 'Initialization failed');
|
||||
|
||||
// Verify polling flag has been reset in the finally block by running a successful poll
|
||||
initializeClientsSpy.mockResolvedValue(true);
|
||||
await poller.pollAllServices();
|
||||
expect(initializeClientsSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Webhook-Based Instance Bypassing', () => {
|
||||
it('skips polling for an instance with recent active webhook events', async () => {
|
||||
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||
const mockRadarrInstance = { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test' };
|
||||
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||
getRadarrInstancesSpy.mockReturnValue([mockRadarrInstance]);
|
||||
|
||||
// Mock recent webhook activity within the 10 min window (Date.now() - 1 min)
|
||||
const recentTimestamp = Date.now() - 60000;
|
||||
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||
if (url === 'https://sonarr.test' || url === 'https://radarr.test') {
|
||||
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
// Verify that skips are incremented for both
|
||||
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://sonarr.test');
|
||||
expect(incrementPollsSkippedSpy).toHaveBeenCalledWith('https://radarr.test');
|
||||
|
||||
// Verify that Sonarr/Radarr-specific API retrievers were not called
|
||||
expect(getTagsByTypeSpy).not.toHaveBeenCalled();
|
||||
expect(getQueuesByTypeSpy).not.toHaveBeenCalled();
|
||||
expect(getHistoryByTypeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces polling if the last webhook timestamp exceeds fallback timeout', async () => {
|
||||
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||
|
||||
// Mock stale webhook activity (Date.now() - 11 mins, fallback is 10 mins)
|
||||
const staleTimestamp = Date.now() - 11 * 60000;
|
||||
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||
if (url === 'https://sonarr.test') {
|
||||
return { eventsReceived: 5, lastWebhookTimestamp: staleTimestamp };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
// Should bypass the skip and perform a full poll
|
||||
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forces polling via global webhook fallback if no global webhooks received for timeout duration', async () => {
|
||||
const mockSonarrInstance = { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test' };
|
||||
getSonarrInstancesSpy.mockReturnValue([mockSonarrInstance]);
|
||||
|
||||
// Mock recent metrics on individual level but stale globally
|
||||
const recentTimestamp = Date.now() - 60000;
|
||||
getWebhookMetricsSpy.mockImplementation((url) => {
|
||||
if (url === 'https://sonarr.test') {
|
||||
return { eventsReceived: 5, lastWebhookTimestamp: recentTimestamp };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Global webhook is stale
|
||||
getGlobalWebhookMetricsSpy.mockReturnValue({
|
||||
lastGlobalWebhookTimestamp: Date.now() - 12 * 60000
|
||||
});
|
||||
|
||||
await poller.pollAllServices();
|
||||
|
||||
// Stale global webhooks should trigger fallback, bypassing the individual skip
|
||||
expect(getTagsByTypeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hybrid Timer Behavior (Fake Timers)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it('schedules periodic polls in startPoller on standard interval', async () => {
|
||||
poller.startPoller();
|
||||
|
||||
// Triggered immediately on start (flush microtasks)
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time by 5000ms
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Advance by another 5000ms
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(3);
|
||||
|
||||
poller.stopPoller();
|
||||
});
|
||||
|
||||
it('clears intervals cleanly when stopPoller is called', async () => {
|
||||
poller.startPoller();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
poller.stopPoller();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(getDownloadsByClientTypeSpy).toHaveBeenCalledTimes(1); // No new execution since poller was stopped
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user