From 031fbaf1bfa0b958e8ddaa0154ea96a9a53b1b42 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 17 Oct 2026 12:27:03 +0300 Subject: [PATCH] added live e2e for seeker recent grabs --- e2e/tests/helpers/seeker-live.ts | 27 +++++++- .../live-arr-fast/seeker-filters.api.spec.ts | 69 +++++++++++++++++++ .../seeker/seeker-instance-config.api.spec.ts | 5 ++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/e2e/tests/helpers/seeker-live.ts b/e2e/tests/helpers/seeker-live.ts index 45a1d92d..f1a5afa7 100644 --- a/e2e/tests/helpers/seeker-live.ts +++ b/e2e/tests/helpers/seeker-live.ts @@ -159,7 +159,15 @@ export async function arrangeInstance( const instances = config.instances.map((i: { arrInstanceId: string }) => i.arrInstanceId === instance.id - ? { ...i, enabled: true, monitoredOnly: true, minCycleTimeDays: 0, ...overrides.instance } + ? { + ...i, + enabled: true, + monitoredOnly: true, + minCycleTimeDays: 0, + // Grabs from earlier specs stay in the arr's history for the whole suite run. + recentGrabWindowHours: 0, + ...overrides.instance, + } : i, ); @@ -194,6 +202,23 @@ export async function arrangeInstance( return instance.id; } +/** Patches one already-arranged instance's settings and asserts the update stuck. */ +export async function updateInstance( + api: CleanuparrApi, + instanceId: string, + patch: Record, +): Promise { + const config = await (await api.seeker.getConfig()).json(); + const instances = config.instances.map((i: { arrInstanceId: string }) => + i.arrInstanceId === instanceId ? { ...i, ...patch } : i, + ); + + const updated = await api.seeker.updateConfig({ ...config, instances }); + if (!updated.ok) { + throw new Error(`Seeker config update failed: ${await updated.text()}`); + } +} + /** Removes the instances and restores the settings the specs changed. */ export async function teardownInstances(api: CleanuparrApi): Promise { for (const instance of createdInstances.splice(0)) { diff --git a/e2e/tests/live-arr-fast/seeker-filters.api.spec.ts b/e2e/tests/live-arr-fast/seeker-filters.api.spec.ts index 25c25551..24e2197d 100644 --- a/e2e/tests/live-arr-fast/seeker-filters.api.spec.ts +++ b/e2e/tests/live-arr-fast/seeker-filters.api.spec.ts @@ -11,14 +11,17 @@ import { expectNoSearch, firstSearchEvent, listSearchEvents, + resetDownloads, resetLiveArrState, restoreLibrary, snapshotLibrary, teardownInstances, teardownQueueCleaner, triggerSeeker, + updateInstance, RUN_TAG, TRIGGERED_SEARCH_TIMEOUT, + type SeededArr, } from '../helpers/seeker-live'; import { grabbableRelease, grabbableReleases } from '../helpers/mocks/torznab-stubs'; @@ -107,6 +110,27 @@ test.describe('Seeker candidate filters', () => { throw new Error('the queue cleaner never struck the grabbed download'); } + /** + * Triggers one search and waits for the arr to record its grab. + * + * History, not the queue: the arr can refuse to track a release an earlier test grabbed. + */ + async function grabOnce(api: CleanuparrApi, instanceId: string, target: SeededArr): Promise { + const since = encodeURIComponent(new Date().toISOString()); + + await triggerSeeker(api); + await expect + .poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }) + .toBe(1); + + await expect + .poll( + async () => (await target.arr.get(`/api/v3/history/since?date=${since}&eventType=1`)).length, + { timeout: TRIGGERED_SEARCH_TIMEOUT }, + ) + .toBeGreaterThan(0); + } + /** Whether a strike landed within the window, without failing the test if none did. */ function pollStrikeCount(api: CleanuparrApi): Promise { return expect @@ -281,4 +305,49 @@ test.describe('Seeker candidate filters', () => { ) .toBe(2); }); + + test('skips a movie grabbed within the recent grab window', async ({ api }) => { + test.setTimeout(180_000); + + const instanceId = await arrangeInstance(api, RADARR, { instance: { monitoredOnly: true } }); + await grabOnce(api, instanceId, RADARR); + await resetDownloads(); + + await updateInstance(api, instanceId, { recentGrabWindowHours: 24 }); + await triggerSeeker(api); + await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); + + // The grab stays in arr history after resetDownloads clears the queue, so it is still skipped. + expect(await listSearchEvents(api, instanceId)).toHaveLength(1); + }); + + test('searches a grabbed movie again with the window off', async ({ api }) => { + test.setTimeout(180_000); + + const instanceId = await arrangeInstance(api, RADARR, { instance: { monitoredOnly: true } }); + await grabOnce(api, instanceId, RADARR); + await resetDownloads(); + + await triggerSeeker(api); + + await expect + .poll(async () => (await listSearchEvents(api, instanceId)).length, { timeout: SETTLE_MS }) + .toBe(2); + }); + + test('skips an episode grabbed within the recent grab window', async ({ api }) => { + test.setTimeout(180_000); + + await indexerMock.stubMany(grabbableRelease(SONARR.searchMode, SONARR.release, SONARR.category).mappings); + + const instanceId = await arrangeInstance(api, SONARR, { instance: { monitoredOnly: true } }); + await grabOnce(api, instanceId, SONARR); + await resetDownloads(); + + await updateInstance(api, instanceId, { recentGrabWindowHours: 24 }); + await triggerSeeker(api); + await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); + + expect(await listSearchEvents(api, instanceId)).toHaveLength(1); + }); }); diff --git a/e2e/tests/seeker/seeker-instance-config.api.spec.ts b/e2e/tests/seeker/seeker-instance-config.api.spec.ts index a0975e4a..c18b15e7 100644 --- a/e2e/tests/seeker/seeker-instance-config.api.spec.ts +++ b/e2e/tests/seeker/seeker-instance-config.api.spec.ts @@ -43,6 +43,7 @@ test.describe('Seeker — per-instance config', () => { expect(instance).toHaveProperty('enabled'); expect(instance).toHaveProperty('skipTags'); expect(instance).toHaveProperty('includeTags'); + expect(instance).toHaveProperty('recentGrabWindowHours'); expect(instance).toHaveProperty('activeDownloadLimit'); expect(instance).toHaveProperty('ignoreStruckDownloads'); expect(instance).toHaveProperty('minCycleTimeDays'); @@ -59,6 +60,7 @@ test.describe('Seeker — per-instance config', () => { expect(radarr.useCustomFormatScore).toBe(false); expect(radarr.ignoreStruckDownloads).toBe(false); expect(radarr.includeTags).toEqual([]); + expect(radarr.recentGrabWindowHours).toBe(24); }); test('updates per-instance settings independently', async ({ api }) => { @@ -75,6 +77,7 @@ test.describe('Seeker — per-instance config', () => { useCustomFormatScore: true, ignoreStruckDownloads: true, includeTags: ['wanted', '4k'], + recentGrabWindowHours: 48, }; } if (i.arrInstanceId === sonarrId) { @@ -95,7 +98,9 @@ test.describe('Seeker — per-instance config', () => { expect(radarr.useCustomFormatScore).toBe(true); expect(radarr.ignoreStruckDownloads).toBe(true); expect(radarr.includeTags).toEqual(['wanted', '4k']); + expect(radarr.recentGrabWindowHours).toBe(48); expect(sonarr.includeTags).toEqual([]); + expect(sonarr.recentGrabWindowHours).toBe(24); expect(sonarr.ignoreStruckDownloads).toBe(false); expect(sonarr.monitoredOnly).toBe(true); expect(sonarr.useCutoff).toBe(true);