// Copyright (c) 2026 Gordon Bolton. MIT License. /** * 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 }; // Deduplication fixtures — same episodeId 55, episode 1 failed then imported const SONARR_RECORD_FAILED_EP55 = { id: 110, eventType: 'downloadFailed', sourceTitle: 'Show.S02E01.720p', date: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago quality: { quality: { name: '720p' } }, data: { message: 'Download failed' }, episodeId: 55, episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: false }, series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, seriesId: 10 }; const SONARR_RECORD_IMPORTED_EP55 = { id: 111, eventType: 'downloadFolderImported', sourceTitle: 'Show.S02E01.720p', date: new Date().toISOString(), // now (more recent) quality: { quality: { name: '720p' } }, episodeId: 55, episode: { seasonNumber: 2, episodeNumber: 1, title: 'Pilot', hasFile: true }, series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, seriesId: 10 }; // Failed, still failing (hasFile=false) — most recent is a failure with no file const SONARR_RECORD_FAILED_EP56 = { id: 112, eventType: 'downloadFailed', sourceTitle: 'Show.S02E02.720p', date: new Date().toISOString(), quality: { quality: { name: '720p' } }, data: { message: 'No seeders' }, episodeId: 56, episode: { seasonNumber: 2, episodeNumber: 2, title: 'Episode 2', hasFile: false }, series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, seriesId: 10 }; // Failed but hasFile=true — episode is available, failure is an upgrade attempt const SONARR_RECORD_FAILED_EP57_HAS_FILE = { id: 113, eventType: 'downloadFailed', sourceTitle: 'Show.S02E03.720p', date: new Date().toISOString(), quality: { quality: { name: '720p' } }, data: { message: 'Upgrade failed' }, episodeId: 57, episode: { seasonNumber: 2, episodeNumber: 3, title: 'Episode 3', hasFile: true }, series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] }, seriesId: 10 }; // --- 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('deduplication', () => { it('suppresses a failed record when the same episode was subsequently imported', async () => { const app = createApp({ skipRateLimits: true }); // API returns newest-first: imported (now) before failed (1hr ago) setHistory([SONARR_RECORD_IMPORTED_EP55, SONARR_RECORD_FAILED_EP55], []); const { cookies } = await loginAs(app); const res = await request(app) .get('/api/history/recent') .set('Cookie', cookies); expect(res.status).toBe(200); const ep55Items = res.body.history.filter(h => h.seriesName === 'My Show' && h.title.includes('S02E01')); expect(ep55Items).toHaveLength(1); expect(ep55Items[0].outcome).toBe('imported'); }); it('shows a failed record as-is when there is no successful import and hasFile is false', async () => { const app = createApp({ skipRateLimits: true }); setHistory([SONARR_RECORD_FAILED_EP56], []); const { cookies } = await loginAs(app); const res = await request(app) .get('/api/history/recent') .set('Cookie', cookies); expect(res.status).toBe(200); const item = res.body.history.find(h => h.title && h.title.includes('S02E02')); expect(item).toBeDefined(); expect(item.outcome).toBe('failed'); expect(item.availableForUpgrade).toBeFalsy(); }); it('flags a failed record as availableForUpgrade when the episode hasFile is true', async () => { const app = createApp({ skipRateLimits: true }); setHistory([SONARR_RECORD_FAILED_EP57_HAS_FILE], []); const { cookies } = await loginAs(app); const res = await request(app) .get('/api/history/recent') .set('Cookie', cookies); expect(res.status).toBe(200); const item = res.body.history.find(h => h.title && h.title.includes('S02E03')); expect(item).toBeDefined(); expect(item.outcome).toBe('failed'); expect(item.availableForUpgrade).toBe(true); }); it('does not expose _contentId in the response', async () => { const app = createApp({ skipRateLimits: true }); setHistory([SONARR_RECORD_IMPORTED_EP55], []); const { cookies } = await loginAs(app); const res = await request(app) .get('/api/history/recent') .set('Cookie', cookies); expect(res.status).toBe(200); for (const item of res.body.history) { expect(item).not.toHaveProperty('_contentId'); } }); }); 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); }); }); });