From 88e882f72ddae2249b9872e9d66e2eeb04991829 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sun, 16 Aug 2026 01:02:22 +0300 Subject: [PATCH] Add e2e coverage for search command status transitions (#724) --- e2e/tests/helpers/mocks/arr-stubs.ts | 52 +++++ .../seeker/search-command-flow.api.spec.ts | 208 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 e2e/tests/seeker/search-command-flow.api.spec.ts diff --git a/e2e/tests/helpers/mocks/arr-stubs.ts b/e2e/tests/helpers/mocks/arr-stubs.ts index b89efb5c..613cd0f9 100644 --- a/e2e/tests/helpers/mocks/arr-stubs.ts +++ b/e2e/tests/helpers/mocks/arr-stubs.ts @@ -108,6 +108,58 @@ export function arrCommandCompletedStub(commandId: number, status = 'completed') }; } +/** Serves the command list that SeekerCommandMonitor polls. An absent command is a forgotten command. */ +export function arrCommandListStub(commands: Array<{ id: number; status: string }>): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/command' }, + response: { status: 200, jsonBody: commands }, + }; +} + +export function arrCommandNotFoundStub(commandId: number): Mapping { + return { + request: { method: 'GET', urlPath: `/api/v3/command/${commandId}` }, + response: { status: 404, jsonBody: { message: 'Not Found' } }, + }; +} + +export interface SearchableMovie { + id: number; + title: string; + monitored?: boolean; + hasFile?: boolean; + status?: string; + qualityProfileId?: number; + tags?: number[]; + digitalRelease?: string; +} + +/** Serves the Radarr library. Defaults make each movie a proactive search candidate. */ +export function arrMoviesStub(movies: SearchableMovie[]): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/movie' }, + response: { + status: 200, + jsonBody: movies.map((movie) => ({ + monitored: true, + hasFile: false, + status: 'released', + qualityProfileId: 1, + tags: [], + digitalRelease: '2020-01-01T00:00:00Z', + ...movie, + })), + }, + }; +} + +export function arrQualityProfilesStub(): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/qualityprofile' }, + response: { status: 200, jsonBody: [{ id: 1, name: 'Any', cutoff: 1, items: [] }] }, + }; +} + export function arrTagsStub(tags: Array<{ id: number; label: string }> = []): Mapping { return { request: { method: 'GET', urlPath: '/api/v3/tag' }, diff --git a/e2e/tests/seeker/search-command-flow.api.spec.ts b/e2e/tests/seeker/search-command-flow.api.spec.ts new file mode 100644 index 00000000..64e0b735 --- /dev/null +++ b/e2e/tests/seeker/search-command-flow.api.spec.ts @@ -0,0 +1,208 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import type { CleanuparrApi } from '../helpers/api'; +import type { MockServers } from '../helpers/mocks/wiremock-client'; +import { + applyArrDefaults, + arrCommandListStub, + arrCommandNotFoundStub, + arrCommandTriggerStub, + arrMoviesStub, + arrQualityProfilesStub, +} from '../helpers/mocks/arr-stubs'; + +const COMMAND_ID = 4242; + +/** The monitor polls once a minute and the Seeker adds up to 30s of jitter. */ +const TRANSITION_TIMEOUT = 180_000; + +const createdInstanceIds: string[] = []; +let savedSearchSettings: Record | undefined; + +interface SearchEvent { + id: string; + itemTitle: string; + searchStatus: string | null; + isDryRun: boolean; +} + +async function listSearchEvents(api: CleanuparrApi, instanceId: string): Promise { + const res = await api.seeker.getSearchEvents({ page: '1', pageSize: '50', instanceId }); + const body = await res.json(); + return body.items ?? body.records ?? body; +} + +/** Filters by instance, because all the tests use the same mock arr and item titles are not unique. */ +async function findSearchEvent( + api: CleanuparrApi, + instanceId: string, + title: string, +): Promise { + const events = await listSearchEvents(api, instanceId); + return events.find((event) => event.itemTitle === title); +} + +/** Creates a Radarr instance with one searchable movie, so one Seeker run sends one command. */ +async function arrangeSearchableInstance( + api: CleanuparrApi, + mocks: MockServers, + movieTitle: string, + commandStubs: Parameters[0], +): Promise { + await applyArrDefaults(mocks.arr); + await mocks.arr.stubMany([ + arrMoviesStub([{ id: 1, title: movieTitle }]), + arrQualityProfilesStub(), + arrCommandTriggerStub(COMMAND_ID), + ...commandStubs, + ]); + + const instance = await ( + await api.arr.createInstance('radarr', { + name: 'E2E Radarr search flow', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'e2e-test-key-radarr', + version: 5, + }) + ).json(); + + createdInstanceIds.push(instance.id); + + const config = await (await api.seeker.getConfig()).json(); + savedSearchSettings ??= { + searchEnabled: config.searchEnabled, + proactiveSearchEnabled: config.proactiveSearchEnabled, + searchInterval: config.searchInterval, + }; + + const instances = config.instances.map((i: { arrInstanceId: string }) => + i.arrInstanceId === instance.id ? { ...i, enabled: true, monitoredOnly: true } : i, + ); + + await api.seeker.updateConfig({ + ...config, + instances, + searchEnabled: true, + proactiveSearchEnabled: true, + searchInterval: 2, + }); + + return instance.id; +} + +/** Waits for the status, then gives the event back so that the test can assert on it. */ +async function waitForSearchStatus( + api: CleanuparrApi, + instanceId: string, + title: string, + status: string | null, +): Promise { + await expect + .poll(async () => (await findSearchEvent(api, instanceId, title))?.searchStatus, { + timeout: TRANSITION_TIMEOUT, + }) + .toBe(status); + + return findSearchEvent(api, instanceId, title); +} + +test.describe('Seeker: search command status flow', () => { + // The app keeps its data between the tests, thus each test must remove what it made. + test.afterEach(async ({ api }) => { + for (const id of createdInstanceIds.splice(0)) { + await api.arr.deleteInstance('radarr', id); + } + + if (savedSearchSettings) { + const config = await (await api.seeker.getConfig()).json(); + await api.seeker.updateConfig({ ...config, ...savedSearchSettings }); + } + }); + + test('reaches completed when the arr reports the command completed', async ({ api, mocks }) => { + test.setTimeout(TRANSITION_TIMEOUT + 60_000); + + const title = 'Command Completes'; + const instanceId = await arrangeSearchableInstance(api, mocks, title, [ + arrCommandListStub([{ id: COMMAND_ID, status: 'completed' }]), + ]); + + await api.jobs.trigger('Seeker'); + + const event = await waitForSearchStatus(api, instanceId, title, 'Completed'); + expect(event?.searchStatus).toBe('Completed'); + }); + + test('reaches failed when the arr aborts the command', async ({ api, mocks }) => { + test.setTimeout(TRANSITION_TIMEOUT + 60_000); + + const title = 'Command Aborts'; + const instanceId = await arrangeSearchableInstance(api, mocks, title, [ + arrCommandListStub([{ id: COMMAND_ID, status: 'aborted' }]), + ]); + + await api.jobs.trigger('Seeker'); + + const event = await waitForSearchStatus(api, instanceId, title, 'Failed'); + expect(event?.searchStatus).toBe('Failed'); + }); + + test('reaches completed when the arr has forgotten the command', async ({ api, mocks }) => { + test.setTimeout(TRANSITION_TIMEOUT + 60_000); + + const title = 'Command Forgotten'; + const instanceId = await arrangeSearchableInstance(api, mocks, title, [ + arrCommandListStub([]), + arrCommandNotFoundStub(COMMAND_ID), + ]); + + await api.jobs.trigger('Seeker'); + + const event = await waitForSearchStatus(api, instanceId, title, 'Completed'); + expect(event?.searchStatus).toBe('Completed'); + }); + + test('fails the in flight event when the arr instance is deleted', async ({ api, mocks }) => { + test.setTimeout(120_000); + + const title = 'Instance Deleted'; + const instanceId = await arrangeSearchableInstance(api, mocks, title, [ + arrCommandListStub([{ id: COMMAND_ID, status: 'started' }]), + ]); + + await api.jobs.trigger('Seeker'); + + await expect + .poll(async () => (await findSearchEvent(api, instanceId, title))?.searchStatus, { timeout: 90_000 }) + .not.toBe(undefined); + + await api.arr.deleteInstance('radarr', instanceId); + + const event = await waitForSearchStatus(api, instanceId, title, 'Failed'); + expect(event?.searchStatus).toBe('Failed'); + }); + + test('leaves dry run search events without a status', async ({ api, mocks }) => { + test.setTimeout(120_000); + + const title = 'Dry Run Search'; + const instanceId = await arrangeSearchableInstance(api, mocks, title, [ + arrCommandListStub([{ id: COMMAND_ID, status: 'completed' }]), + ]); + + const general = await (await api.general.getConfig()).json(); + await api.general.updateConfig({ ...general, dryRun: true }); + + try { + await api.jobs.trigger('Seeker'); + + await expect + .poll(async () => (await findSearchEvent(api, instanceId, title))?.isDryRun, { timeout: 90_000 }) + .toBe(true); + + const event = await findSearchEvent(api, instanceId, title); + expect(event?.searchStatus ?? null).toBeNull(); + } finally { + await api.general.updateConfig(general); + } + }); +});