added e2e for sonarr search modes

This commit is contained in:
Flaminel committed 2026-10-21 22:24:25 +03:00
1 parent fe184daa56
commit 570895f6d5
1 file changed
+183
@@ -0,0 +1,183 @@
import { rmSync, truncateSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { test, expect } from '../fixtures/base';
import type { CleanuparrApi } from '../helpers/api';
import { indexerMock, type LiveArrCommand } from '../helpers/live-arr';
import {
SONARR,
arrangeInstance,
listSearchEvents,
resetLiveArrState,
restoreLibrary,
snapshotLibrary,
teardownInstances,
triggerSeeker,
} from '../helpers/seeker-live';
import { torznabSearchStub } from '../helpers/mocks/torznab-stubs';
import { mkdirShared, writeFileShared } from '../helpers/shared-volume';
/**
* How the Sonarr search mode picks between a season search and an episode search.
*
* The seeded season has nine aired monitored episodes and no files.
*/
const SETTLE_MS = 20_000;
/** A file on S01E01 leaves the season partly wanted. */
const IMPORTED_EPISODE_FILE = 'Agatha All Along - S01E01 - WEBDL-1080p.mkv';
/** The arr ignores a file small enough to look like a sample. */
const FILE_SIZE_BYTES = 300 * 1024 * 1024;
const TV_DIR = resolve(__dirname, '..', '..', 'test-data', 'sonarr-tv');
interface Episode {
id: number;
seasonNumber: number;
episodeNumber: number;
hasFile: boolean;
episodeFileId: number;
monitored: boolean;
}
async function searchCommandsAfter(lastId: number): Promise<LiveArrCommand[]> {
const commands = await SONARR.arr.commands();
return commands.filter((c) => c.id > lastId && (c.name === 'EpisodeSearch' || c.name === 'SeasonSearch'));
}
async function lastCommandId(): Promise<number> {
const commands = await SONARR.arr.commands();
return Math.max(0, ...commands.map((c) => c.id));
}
async function listEpisodes(): Promise<Episode[]> {
return SONARR.arr.get<Episode[]>(`/api/v3/episode?seriesId=${SONARR.itemId}`);
}
function findEpisode(episodes: Episode[], episodeNumber: number): Episode {
const episode = episodes.find((e) => e.seasonNumber === 1 && e.episodeNumber === episodeNumber);
expect(episode, `the seed lost S01E${episodeNumber}`).toBeTruthy();
return episode!;
}
async function seasonFolder(): Promise<string> {
const series = await SONARR.arr.get<{ path: string }>(`/api/v3/series/${SONARR.itemId}`);
return join(TV_DIR, series.path.replace(/^\/tv\/?/, ''), 'Season 01');
}
/** Gives S01E01 a real file, so the arr counts it in the season but no longer wants it. */
async function importFirstEpisode(): Promise<void> {
const folder = await seasonFolder();
mkdirShared(folder);
const file = join(folder, IMPORTED_EPISODE_FILE);
writeFileShared(file, Buffer.alloc(0));
truncateSync(file, FILE_SIZE_BYTES);
await SONARR.arr.post('/api/v3/command', { name: 'RescanSeries', seriesId: SONARR.itemId });
await expect
.poll(async () => findEpisode(await listEpisodes(), 1).hasFile, { timeout: 90_000 })
.toBe(true);
}
/** Runs even when no test imported anything, because a half-done import still leaves a file. */
async function dropImportedEpisode(): Promise<void> {
const first = findEpisode(await listEpisodes(), 1);
if (first.hasFile) {
await SONARR.arr.delete(`/api/v3/episodefile/${first.episodeFileId}`);
}
rmSync(await seasonFolder(), { recursive: true, force: true });
}
/** Sonarr hands back an identical SeasonSearch still running instead of queueing a new one. */
async function waitForIdleSonarr(): Promise<void> {
await expect
.poll(async () => (await SONARR.arr.commands()).filter((c) => c.status === 'queued' || c.status === 'started').length, {
timeout: 60_000,
})
.toBe(0);
}
/** Runs the Seeker once and returns the one search command it sent. */
async function searchOnce(api: CleanuparrApi, instanceId: string): Promise<LiveArrCommand> {
const before = await lastCommandId();
await triggerSeeker(api);
await expect.poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }).toBe(1);
await expect.poll(async () => (await searchCommandsAfter(before)).length, { timeout: SETTLE_MS }).toBe(1);
return (await searchCommandsAfter(before))[0];
}
test.describe('Seeker Sonarr search mode', () => {
let library: Array<Record<string, unknown>>;
let episodes: Episode[];
test.beforeEach(async () => {
await waitForIdleSonarr();
await resetLiveArrState();
await indexerMock.stub(torznabSearchStub(SONARR.searchMode, []));
library = await snapshotLibrary(SONARR);
episodes = await listEpisodes();
});
test.afterEach(async ({ api }) => {
await teardownInstances(api);
await dropImportedEpisode();
for (const monitored of [true, false]) {
const episodeIds = episodes.filter((e) => e.monitored === monitored).map((e) => e.id);
if (episodeIds.length > 0) {
await SONARR.arr.put('/api/v3/episode/monitor', { episodeIds, monitored });
}
}
await restoreLibrary(SONARR, library);
await resetLiveArrState();
});
test('auto searches a partly wanted season one episode at a time', async ({ api }) => {
test.setTimeout(240_000);
await importFirstEpisode();
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Auto' } });
const command = await searchOnce(api, instanceId);
expect(command.name).toBe('EpisodeSearch');
expect(command.body?.episodeIds).toEqual([findEpisode(episodes, 2).id]);
});
test('auto searches a fully wanted aired season as a whole', async ({ api }) => {
test.setTimeout(180_000);
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Auto' } });
const command = await searchOnce(api, instanceId);
expect(command.name).toBe('SeasonSearch');
expect(command.body?.seasonNumber).toBe(1);
});
test('season mode searches a partly wanted season as a whole', async ({ api }) => {
test.setTimeout(240_000);
await importFirstEpisode();
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Season' } });
const command = await searchOnce(api, instanceId);
expect(command.name).toBe('SeasonSearch');
expect(command.body?.seasonNumber).toBe(1);
});
test('episode mode searches a fully wanted season one episode at a time', async ({ api }) => {
test.setTimeout(180_000);
// S01E01's id equals the series id, which would hide an id mix-up.
await SONARR.arr.put('/api/v3/episode/monitor', { episodeIds: [findEpisode(episodes, 1).id], monitored: false });
const instanceId = await arrangeInstance(api, SONARR, { instance: { sonarrSearchMode: 'Episode' } });
const command = await searchOnce(api, instanceId);
expect(command.name).toBe('EpisodeSearch');
expect(command.body?.episodeIds).toEqual([findEpisode(episodes, 2).id]);
});
});