Add comprehensive tests for staged history loading
Build and Push Docker Image / build (push) Successful in 50s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 58s
CI / Security audit (push) Successful in 1m6s
CI / Tests & coverage (push) Successful in 1m25s

Unit tests:
- Staged loading (initial batch, background fetch, concurrent requests)
- Deduplication (by record ID, empty record sets)
- Event subscription (subscribe/unsubscribe, error handling)
- Pagination (max records limit, batch sizes)

Integration tests:
- Race conditions (concurrent requests, cache consistency, duplicate handling)
- Edge cases (empty history, single record, batch boundaries)

Tests verify:
- No records are missed during staged loading
- No duplicates are created
- Cache remains consistent during concurrent operations
- Background fetch doesn't interfere with concurrent user requests
This commit is contained in:
2026-05-21 01:28:37 +01:00
parent f461c3669c
commit 05c9527189
2 changed files with 373 additions and 1 deletions
+183
View File
@@ -398,4 +398,187 @@ describe('GET /api/history/recent', () => {
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);
});
});
});