From 57b3254f70459ec377802bdd2f06fcb5278c6a6d Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 12:05:45 +0100 Subject: [PATCH] test(history): add unit and integration tests for historyFetcher and /api/history/recent --- tests/integration/history.test.js | 289 ++++++++++++++++++++++++++++++ tests/unit/historyFetcher.test.js | 177 ++++++++++++++++++ 2 files changed, 466 insertions(+) create mode 100644 tests/integration/history.test.js create mode 100644 tests/unit/historyFetcher.test.js diff --git a/tests/integration/history.test.js b/tests/integration/history.test.js new file mode 100644 index 0000000..2cf9bed --- /dev/null +++ b/tests/integration/history.test.js @@ -0,0 +1,289 @@ +/** + * Integration tests for GET /api/history/recent + * + * Uses supertest against createApp() with vi.mock to stub historyFetcher + * (avoids nock/ESM-CJS interop issues with axios) and nock for Emby auth. + * Covers: + * - 401 when unauthenticated + * - Empty history response when arr returns no records + * - Filters out records whose eventType is not imported/failed + * - Returns imported and failed records for tagged series/movies + * - ?days= param is respected (default 7, capped at 90) + * - failureMessage included for admins on failed records + */ + +import request from 'supertest'; +import nock from 'nock'; +import { beforeEach, afterEach } from 'vitest'; +import { createRequire } from 'module'; +import { createApp } from '../../server/app.js'; + +// Use createRequire to get the same CJS singleton cache instance that +// server/utils/historyFetcher.js and server/routes/history.js use via +// require('./cache'). A plain ESM `import cache from '...'` resolves +// to a different module identity under vitest's ESM runtime. +const require = createRequire(import.meta.url); +const cache = require('../../server/utils/cache.js'); + +const EMBY_BASE = 'https://emby.test'; + +process.env.EMBY_URL = EMBY_BASE; +process.env.SONARR_INSTANCES = JSON.stringify([ + { id: 'sonarr-1', name: 'Main Sonarr', url: 'https://sonarr.test', apiKey: 'sk' } +]); +process.env.RADARR_INSTANCES = JSON.stringify([ + { id: 'radarr-1', name: 'Main Radarr', url: 'https://radarr.test', apiKey: 'rk' } +]); + +// --- Fixtures --- +const EMBY_AUTH = { AccessToken: 'tok', User: { Id: 'uid1', Name: 'alice' } }; +const EMBY_USER = { Id: 'uid1', Name: 'alice', Policy: { IsAdministrator: false } }; +const EMBY_ADMIN = { Id: 'uid2', Name: 'admin', Policy: { IsAdministrator: true } }; +const EMBY_AUTH_ADMIN = { AccessToken: 'tok2', User: { Id: 'uid2', Name: 'admin' } }; + +// Sonarr tag: id 1 → 'alice' +const SONARR_TAGS = [{ id: 1, label: 'alice' }]; + +const SONARR_RECORD_IMPORTED = { + id: 100, + eventType: 'downloadFolderImported', + sourceTitle: 'Show.S01E01.720p', + date: new Date().toISOString(), + quality: { quality: { name: '720p' } }, + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + +const SONARR_RECORD_FAILED = { + id: 101, + eventType: 'downloadFailed', + sourceTitle: 'Show.S01E02.720p', + date: new Date().toISOString(), + quality: { quality: { name: '720p' } }, + data: { message: 'Not enough disk space' }, + series: { id: 11, title: 'Admin Show', titleSlug: 'admin-show', tags: [2], images: [] }, +}; + +// Tag id 2 → 'admin' (used in the failed-import admin test) +const SONARR_TAGS_WITH_ADMIN = [{ id: 1, label: 'alice' }, { id: 2, label: 'admin' }]; + +const SONARR_RECORD_FAILED_ALICE = { // failed record tagged alice, for event-type filtering test + id: 103, + eventType: 'downloadFailed', + sourceTitle: 'Show.S01E02.720p', + date: new Date().toISOString(), + quality: { quality: { name: '720p' } }, + data: { message: 'Disk full' }, + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + +const SONARR_RECORD_GRABBED = { + id: 102, + eventType: 'grabbed', + sourceTitle: 'Show.S01E03.720p', + date: new Date().toISOString(), + series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, + seriesId: 10 +}; + +const RADARR_RECORD_IMPORTED = { + id: 200, + eventType: 'downloadFolderImported', + sourceTitle: 'My.Movie.2024.1080p', + date: new Date().toISOString(), + quality: { quality: { name: '1080p' } }, + movie: { id: 20, title: 'My Movie', titleSlug: 'my-movie-2024', tags: [1], images: [] }, + movieId: 20 +}; + +// --- Helpers --- +function interceptLogin(userBody = EMBY_USER, authBody = EMBY_AUTH) { + nock(EMBY_BASE).post('/Users/authenticatebyname').reply(200, authBody); + nock(EMBY_BASE).get(/\/Users\//).reply(200, userBody); +} + +// Pre-seed the history cache keys that fetchSonarrHistory/fetchRadarrHistory check +// first, so they return without making any HTTP calls. +const CACHE_TTL = 60 * 60 * 1000; // 1 hour — won't expire during a test run + +function setHistory(sonarrRecords = [], radarrRecords = []) { + cache.set('history:sonarr', sonarrRecords.map(r => ({ + ...r, + _instanceName: 'Main Sonarr', + series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined + })), CACHE_TTL); + cache.set('history:radarr', radarrRecords.map(r => ({ + ...r, + _instanceName: 'Main Radarr', + movie: r.movie ? { ...r.movie, _instanceUrl: 'https://radarr.test', _instanceName: 'Main Radarr' } : undefined + })), CACHE_TTL); +} + +async function loginAs(app, userBody = EMBY_USER, authBody = EMBY_AUTH) { + interceptLogin(userBody, authBody); + const res = await request(app) + .post('/api/auth/login') + .send({ username: userBody.Name, password: 'pw' }); + const cookies = res.headers['set-cookie']; + const csrf = res.body.csrfToken; + return { cookies, csrf }; +} + +beforeEach(() => { + // Clear history caches so each test controls its own data + cache.invalidate('history:sonarr'); + cache.invalidate('history:radarr'); + // Default: empty history + setHistory([], []); + // Seed poll tag caches so the route can resolve tags + cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], 60000); + cache.set('poll:radarr-tags', [], 60000); +}); + +afterEach(() => { + nock.cleanAll(); + cache.invalidate('history:sonarr'); + cache.invalidate('history:radarr'); +}); + +describe('GET /api/history/recent', () => { + describe('authentication', () => { + it('returns 401 when not logged in', async () => { + const app = createApp({ skipRateLimits: true }); + const res = await request(app).get('/api/history/recent'); + expect(res.status).toBe(401); + }); + }); + + describe('empty history', () => { + it('returns empty array when arr returns no records', async () => { + const app = createApp({ skipRateLimits: true }); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body.history).toEqual([]); + expect(res.body.days).toBe(7); + }); + }); + + describe('event type filtering', () => { + it('includes imported and failed records, excludes grabbed', async () => { + const app = createApp({ skipRateLimits: true }); + setHistory( + [SONARR_RECORD_IMPORTED, SONARR_RECORD_FAILED_ALICE, SONARR_RECORD_GRABBED], + [] + ); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + const outcomes = res.body.history.map(h => h.outcome); + expect(outcomes).toContain('imported'); + expect(outcomes).toContain('failed'); + expect(res.body.history).toHaveLength(2); + }); + }); + + describe('tag filtering', () => { + it('only returns records tagged for the current user', async () => { + const app = createApp({ skipRateLimits: true }); + setHistory([SONARR_RECORD_IMPORTED], []); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body.history).toHaveLength(1); + expect(res.body.history[0].seriesName).toBe('My Show'); + expect(res.body.history[0].outcome).toBe('imported'); + expect(res.body.history[0].quality).toBe('720p'); + }); + + it('excludes records tagged for a different user', async () => { + const app = createApp({ skipRateLimits: true }); + const bobAuth = { AccessToken: 'tok3', User: { Id: 'uid3', Name: 'bob' } }; + const bobUser = { Id: 'uid3', Name: 'bob', Policy: { IsAdministrator: false } }; + setHistory([SONARR_RECORD_IMPORTED], []); + const { cookies } = await loginAs(app, bobUser, bobAuth); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body.history).toHaveLength(0); + }); + }); + + describe('radarr records', () => { + it('returns movie history items', async () => { + const app = createApp({ skipRateLimits: true }); + cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000); + setHistory([], [RADARR_RECORD_IMPORTED]); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body.history).toHaveLength(1); + expect(res.body.history[0].type).toBe('movie'); + expect(res.body.history[0].movieName).toBe('My Movie'); + }); + }); + + describe('?days parameter', () => { + it('uses custom days value', async () => { + const app = createApp({ skipRateLimits: true }); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent?days=14') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body.days).toBe(14); + }); + + it('caps days at 90', async () => { + const app = createApp({ skipRateLimits: true }); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent?days=999') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body.days).toBe(7); // falls back to default when > 90 + }); + }); + + describe('failed import details', () => { + it('includes failureMessage for admin on failed records', async () => { + const app = createApp({ skipRateLimits: true }); + cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000); + setHistory([SONARR_RECORD_FAILED], []); + const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + const failed = res.body.history.find(h => h.outcome === 'failed'); + expect(failed).toBeDefined(); + expect(failed.failureMessage).toBe('Not enough disk space'); + }); + }); + + describe('response shape', () => { + it('returns correct top-level fields', async () => { + const app = createApp({ skipRateLimits: true }); + const { cookies } = await loginAs(app); + const res = await request(app) + .get('/api/history/recent') + .set('Cookie', cookies); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('user'); + expect(res.body).toHaveProperty('isAdmin'); + expect(res.body).toHaveProperty('days'); + expect(res.body).toHaveProperty('history'); + expect(Array.isArray(res.body.history)).toBe(true); + }); + }); +}); diff --git a/tests/unit/historyFetcher.test.js b/tests/unit/historyFetcher.test.js new file mode 100644 index 0000000..040a358 --- /dev/null +++ b/tests/unit/historyFetcher.test.js @@ -0,0 +1,177 @@ +/** + * 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 } = + 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); + }); +});