e8037afbb8
Build and Push Docker Image / build (push) Successful in 1m7s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m20s
CI / Security audit (push) Successful in 2m45s
CI / Tests & coverage (push) Successful in 3m18s
CI / Swagger Validation & Coverage (push) Successful in 3m29s
Add arrType field to Sonarr and Radarr history items for admin users to enable proper icon display in the recently downloaded section. This mirrors the existing behavior in active downloads where arrType is set by DownloadMatcher. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
603 lines
23 KiB
JavaScript
603 lines
23 KiB
JavaScript
// 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');
|
|
});
|
|
|
|
it('includes arrType for admin on Sonarr and Radarr records', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS_WITH_ADMIN }], 60000);
|
|
cache.set('poll:radarr-tags', [{ id: 1, label: 'alice' }], 60000);
|
|
setHistory([SONARR_RECORD_IMPORTED], [RADARR_RECORD_IMPORTED]);
|
|
const { cookies } = await loginAs(app, EMBY_ADMIN, EMBY_AUTH_ADMIN);
|
|
const res = await request(app)
|
|
.get('/api/history/recent?showAll=true')
|
|
.set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
const sonarrItem = res.body.history.find(h => h.type === 'series');
|
|
expect(sonarrItem).toBeDefined();
|
|
expect(sonarrItem.arrType).toBe('sonarr');
|
|
const radarrItem = res.body.history.find(h => h.type === 'movie');
|
|
expect(radarrItem).toBeDefined();
|
|
expect(radarrItem.arrType).toBe('radarr');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('staged loading - race conditions', () => {
|
|
it('handles concurrent requests without data loss', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
// Set up 150 records with unique episodeIds to test staged loading
|
|
const sonarrRecords = Array.from({ length: 150 }, (_, i) => ({
|
|
id: i + 1,
|
|
eventType: 'downloadFolderImported',
|
|
sourceTitle: `Show.S01E${i + 1}`,
|
|
date: new Date(Date.now() - i * 60000).toISOString(),
|
|
episodeId: i + 1, // Unique episodeId for each record
|
|
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
|
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
}));
|
|
setHistory(sonarrRecords, []);
|
|
const { cookies } = await loginAs(app);
|
|
|
|
// Make concurrent requests
|
|
const [res1, res2, res3] = await Promise.all([
|
|
request(app).get('/api/history/recent').set('Cookie', cookies),
|
|
request(app).get('/api/history/recent').set('Cookie', cookies),
|
|
request(app).get('/api/history/recent').set('Cookie', cookies)
|
|
]);
|
|
|
|
// All requests should succeed
|
|
expect(res1.status).toBe(200);
|
|
expect(res2.status).toBe(200);
|
|
expect(res3.status).toBe(200);
|
|
|
|
// All should return the same data (cache hit)
|
|
expect(res1.body.history).toEqual(res2.body.history);
|
|
expect(res2.body.history).toEqual(res3.body.history);
|
|
|
|
// Verify no duplicate episodeIds
|
|
const episodeIds = res1.body.history.map(h => h.title);
|
|
const uniqueEpisodeIds = new Set(episodeIds);
|
|
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
|
|
});
|
|
|
|
it('maintains cache consistency during background fetch', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
// Start with 100 records with unique episodeIds
|
|
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(),
|
|
episodeId: i + 1,
|
|
episode: { seasonNumber: 1, episodeNumber: i + 1, title: `Episode ${i + 1}`, hasFile: true },
|
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
}));
|
|
setHistory(initialRecords, []);
|
|
const { cookies } = await loginAs(app);
|
|
|
|
// First request populates cache
|
|
const res1 = await request(app)
|
|
.get('/api/history/recent')
|
|
.set('Cookie', cookies);
|
|
expect(res1.status).toBe(200);
|
|
expect(res1.body.history).toHaveLength(100);
|
|
|
|
// Add more records to simulate background fetch
|
|
const additionalRecords = Array.from({ length: 50 }, (_, i) => ({
|
|
id: i + 101,
|
|
eventType: 'downloadFolderImported',
|
|
sourceTitle: `Show.S01E${i + 101}`,
|
|
date: new Date(Date.now() - (i + 100) * 60000).toISOString(),
|
|
episodeId: i + 101,
|
|
episode: { seasonNumber: 1, episodeNumber: i + 101, title: `Episode ${i + 101}`, hasFile: true },
|
|
series: { id: 10, title: 'My Show', titleSlug: 'my-show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
}));
|
|
setHistory([...initialRecords, ...additionalRecords], []);
|
|
|
|
// Invalidate cache to simulate background fetch completion
|
|
cache.invalidate('history:sonarr');
|
|
cache.set('history:sonarr', [...initialRecords, ...additionalRecords].map(r => ({
|
|
...r,
|
|
_instanceName: 'Main Sonarr',
|
|
series: r.series ? { ...r.series, _instanceUrl: 'https://sonarr.test', _instanceName: 'Main Sonarr' } : undefined
|
|
})), CACHE_TTL);
|
|
|
|
// Second request should get updated data
|
|
const res2 = await request(app)
|
|
.get('/api/history/recent')
|
|
.set('Cookie', cookies);
|
|
expect(res2.status).toBe(200);
|
|
expect(res2.body.history).toHaveLength(150);
|
|
|
|
// Verify no duplicates
|
|
const episodeIds = res2.body.history.map(h => h.title);
|
|
const uniqueEpisodeIds = new Set(episodeIds);
|
|
expect(episodeIds.length).toBe(uniqueEpisodeIds.size);
|
|
});
|
|
|
|
it('handles duplicate records gracefully', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
// Create records with duplicate IDs (simulating race condition)
|
|
const records = [
|
|
{
|
|
id: 1,
|
|
eventType: 'downloadFolderImported',
|
|
sourceTitle: 'Show.S01E01',
|
|
date: new Date().toISOString(),
|
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
},
|
|
{
|
|
id: 2,
|
|
eventType: 'downloadFolderImported',
|
|
sourceTitle: 'Show.S01E02',
|
|
date: new Date(Date.now() - 60000).toISOString(),
|
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
},
|
|
{
|
|
id: 1, // Duplicate ID
|
|
eventType: 'downloadFolderImported',
|
|
sourceTitle: 'Show.S01E01',
|
|
date: new Date(Date.now() - 120000).toISOString(),
|
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
}
|
|
];
|
|
setHistory(records, []);
|
|
const { cookies } = await loginAs(app);
|
|
|
|
const res = await request(app)
|
|
.get('/api/history/recent')
|
|
.set('Cookie', cookies);
|
|
expect(res.status).toBe(200);
|
|
|
|
// The deduplication in history.js should handle this
|
|
// We should get 2 unique items, not 3
|
|
const uniqueSeries = new Set(res.body.history.map(h => h.title));
|
|
expect(uniqueSeries.size).toBeLessThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
describe('staged loading - edge cases', () => {
|
|
it('handles empty history', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
setHistory([], []);
|
|
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([]);
|
|
});
|
|
|
|
it('handles single record', 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);
|
|
});
|
|
|
|
it('handles exactly 100 records (batch boundary)', async () => {
|
|
const app = createApp({ skipRateLimits: true });
|
|
const records = Array.from({ length: 100 }, (_, i) => ({
|
|
id: i + 1,
|
|
eventType: 'downloadFolderImported',
|
|
sourceTitle: `Show.S01E${(i % 10) + 1}`,
|
|
date: new Date(Date.now() - i * 60000).toISOString(),
|
|
series: { id: 10, title: 'My Show', tags: [1], images: [] },
|
|
seriesId: 10
|
|
}));
|
|
setHistory(records, []);
|
|
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(100);
|
|
});
|
|
});
|
|
});
|