// Copyright (c) 2026 Gordon Bolton. MIT License. /** * Unit tests for server/utils/historyFetcher.js * * Covers: * - classifySonarrEvent / classifyRadarrEvent event classification * - fetchSonarrHistory / fetchRadarrHistory: successful fetch, cache hit, per-instance errors * - invalidateHistoryCache */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import nock from 'nock'; // Env must be set before importing modules that read it at load time process.env.SONARR_INSTANCES = JSON.stringify([ { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' } ]); process.env.RADARR_INSTANCES = JSON.stringify([ { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'radarr-key' } ]); const { classifySonarrEvent, classifyRadarrEvent, fetchSonarrHistory, fetchRadarrHistory, invalidateHistoryCache, onHistoryUpdate, offHistoryUpdate } = await import('../../server/utils/historyFetcher.js'); const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); afterEach(() => { nock.cleanAll(); invalidateHistoryCache(); }); describe('classifySonarrEvent', () => { it('returns imported for downloadFolderImported', () => { expect(classifySonarrEvent('downloadFolderImported')).toBe('imported'); }); it('returns imported for downloadImported', () => { expect(classifySonarrEvent('downloadImported')).toBe('imported'); }); it('returns failed for downloadFailed', () => { expect(classifySonarrEvent('downloadFailed')).toBe('failed'); }); it('returns failed for importFailed', () => { expect(classifySonarrEvent('importFailed')).toBe('failed'); }); it('returns other for grabbed', () => { expect(classifySonarrEvent('grabbed')).toBe('other'); }); it('returns other for unknown event', () => { expect(classifySonarrEvent('someFutureEvent')).toBe('other'); }); }); describe('classifyRadarrEvent', () => { it('returns imported for downloadFolderImported', () => { expect(classifyRadarrEvent('downloadFolderImported')).toBe('imported'); }); it('returns failed for downloadFailed', () => { expect(classifyRadarrEvent('downloadFailed')).toBe('failed'); }); it('returns other for grabbed', () => { expect(classifyRadarrEvent('grabbed')).toBe('other'); }); }); describe('fetchSonarrHistory', () => { const mockRecords = [ { id: 1, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01', date: new Date().toISOString(), series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }, seriesId: 10 } ]; it('fetches records and tags them with _instanceUrl and _instanceName', async () => { nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); const result = await fetchSonarrHistory(since); expect(result).toHaveLength(1); expect(result[0].series._instanceUrl).toBe('https://sonarr.test'); expect(result[0].series._instanceName).toBe('Main Sonarr'); expect(result[0]._instanceName).toBe('Main Sonarr'); }); it('returns cached data on second call without making a new HTTP request', async () => { nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); const first = await fetchSonarrHistory(since); // Second call — nock would throw if a second request was made const second = await fetchSonarrHistory(since); expect(second).toEqual(first); }); it('returns empty array and does not throw when instance errors', async () => { nock('https://sonarr.test') .get('/api/v3/history') .query(true) .replyWithError('ECONNREFUSED'); const result = await fetchSonarrHistory(since); expect(result).toEqual([]); }); it('handles missing records key gracefully', async () => { nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, {}); const result = await fetchSonarrHistory(since); expect(result).toEqual([]); }); }); describe('fetchRadarrHistory', () => { const mockRecords = [ { id: 2, eventType: 'downloadFolderImported', sourceTitle: 'My.Movie.2024', date: new Date().toISOString(), movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [] }, movieId: 20 } ]; it('fetches records and tags them with _instanceUrl and _instanceName', async () => { nock('https://radarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); const result = await fetchRadarrHistory(since); expect(result).toHaveLength(1); expect(result[0].movie._instanceUrl).toBe('https://radarr.test'); expect(result[0].movie._instanceName).toBe('Main Radarr'); }); it('returns empty array on network error', async () => { nock('https://radarr.test') .get('/api/v3/history') .query(true) .replyWithError('timeout'); const result = await fetchRadarrHistory(since); expect(result).toEqual([]); }); }); describe('invalidateHistoryCache', () => { it('forces a fresh fetch after invalidation', async () => { nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: [] }); await fetchSonarrHistory(since); invalidateHistoryCache(); // Should make a second HTTP request — nock will satisfy it nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: [] }); const result = await fetchSonarrHistory(since); expect(result).toEqual([]); expect(nock.isDone()).toBe(true); }); }); describe('Staged Loading - Initial Batch', () => { it('fetches initial batch of 100 records', async () => { const mockRecords = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, eventType: 'downloadFolderImported', sourceTitle: `Show.S01E${i + 1}`, date: new Date(Date.now() - i * 60000).toISOString(), series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }, seriesId: 10 })); nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); const result = await fetchSonarrHistory(since); expect(result).toHaveLength(100); expect(result[0].id).toBe(1); expect(result[99].id).toBe(100); }); it('uses pageSize=100 for initial fetch', async () => { nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: [] }); await fetchSonarrHistory(since); expect(nock.isDone()).toBe(true); }); }); describe('Staged Loading - Background Fetch', () => { it('triggers background fetch after initial batch', async () => { const mockRecords = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, eventType: 'downloadFolderImported', sourceTitle: `Show.S01E${i + 1}`, date: new Date(Date.now() - i * 60000).toISOString(), series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }, seriesId: 10 })); nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); // Background fetch will make additional requests nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: [] }); await fetchSonarrHistory(since); // Background fetch is fire-and-forget, so we just verify it doesn't throw await new Promise(resolve => setTimeout(resolve, 50)); }); it('prevents concurrent background fetches', async () => { const mockRecords = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, eventType: 'downloadFolderImported', sourceTitle: `Show.S01E${i + 1}`, date: new Date(Date.now() - i * 60000).toISOString(), series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [] }, seriesId: 10 })); nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); // First request await fetchSonarrHistory(since); // Second request should not trigger additional background fetch await fetchSonarrHistory(since); // Verify only one initial request was made expect(nock.isDone()).toBe(true); }); }); describe('Deduplication', () => { it('filters out duplicate records by ID', async () => { const mockRecords = [ { id: 1, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01', date: new Date().toISOString(), series: { id: 10, title: 'Show', tags: [] } }, { id: 2, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E02', date: new Date().toISOString(), series: { id: 10, title: 'Show', tags: [] } }, { id: 1, eventType: 'downloadFolderImported', sourceTitle: 'Show.S01E01', date: new Date().toISOString(), series: { id: 10, title: 'Show', tags: [] } }, // Duplicate ]; nock('https://sonarr.test') .get('/api/v3/history') .reply(200, { records: mockRecords }); const result = await fetchSonarrHistory(since); const ids = result.map(r => r.id); const uniqueIds = new Set(ids); expect(ids.length).toBe(uniqueIds.size); // No duplicates }); it('handles empty record set without errors', async () => { nock('https://sonarr.test') .get('/api/v3/history') .reply(200, { records: [] }); const result = await fetchSonarrHistory(since); expect(result).toEqual([]); }); }); describe('Event Subscription', () => { it('subscribes to history updates', () => { let receivedType = null; const callback = (type) => { receivedType = type; }; onHistoryUpdate(callback); // Manually trigger an update (we'll need to expose emitHistoryUpdate for testing) // For now, just verify subscription doesn't throw expect(() => onHistoryUpdate(callback)).not.toThrow(); }); it('unsubscribes from history updates', () => { const callback = () => {}; onHistoryUpdate(callback); offHistoryUpdate(callback); // Verify unsubscribe doesn't throw expect(() => offHistoryUpdate(callback)).not.toThrow(); }); it('handles subscriber errors gracefully', () => { const errorCallback = () => { throw new Error('Subscriber error'); }; const normalCallback = () => {}; onHistoryUpdate(errorCallback); onHistoryUpdate(normalCallback); // If emitHistoryUpdate were exposed, we'd verify it doesn't crash // For now, just verify subscriptions work expect(() => onHistoryUpdate(() => {})).not.toThrow(); }); }); describe('Pagination', () => { it('respects max records limit of 1000', async () => { // Mock initial batch const initialRecords = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, eventType: 'downloadFolderImported', sourceTitle: `Show.S01E${i + 1}`, date: new Date(Date.now() - i * 60000).toISOString(), series: { id: 10, title: 'My Show', tags: [] } })); nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: initialRecords }); const result = await fetchSonarrHistory(since); expect(result.length).toBeLessThanOrEqual(1000); }); it('uses batch size of 100 for background fetches', async () => { const mockRecords = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, eventType: 'downloadFolderImported', sourceTitle: `Show.S01E${i + 1}`, date: new Date(Date.now() - i * 60000).toISOString(), series: { id: 10, title: 'My Show', tags: [] } })); nock('https://sonarr.test') .get('/api/v3/history') .query(true) .reply(200, { records: mockRecords }); await fetchSonarrHistory(since); expect(nock.isDone()).toBe(true); }); });