feat: deduplicate history — suppress failed records superseded by successful import, flag failed+hasFile as availableForUpgrade
Some checks failed
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 1m14s
Licence Check / Dependency licence compatibility (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 2m16s
Some checks failed
Build and Push Docker Image / build (push) Successful in 58s
CI / Security audit (push) Has been cancelled
CI / Tests & coverage (push) Has been cancelled
Docs Check / Markdown lint (push) Successful in 1m14s
Licence Check / Dependency licence compatibility (push) Successful in 1m36s
Docs Check / Mermaid diagram parse check (push) Successful in 2m16s
This commit is contained in:
@@ -97,6 +97,60 @@ const RADARR_RECORD_IMPORTED = {
|
||||
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);
|
||||
@@ -271,6 +325,63 @@ describe('GET /api/history/recent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user