diff --git a/e2e/arr-seed/radarr/movies/Weapons (2025)/Weapons.2025.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv b/e2e/arr-seed/radarr/movies/Weapons (2025)/Weapons.2025.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv new file mode 100644 index 00000000..1a8aa746 Binary files /dev/null and b/e2e/arr-seed/radarr/movies/Weapons (2025)/Weapons.2025.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv differ diff --git a/e2e/arr-seed/radarr/radarr.db b/e2e/arr-seed/radarr/radarr.db index 3143958c..181fbd99 100644 Binary files a/e2e/arr-seed/radarr/radarr.db and b/e2e/arr-seed/radarr/radarr.db differ diff --git a/e2e/arr-seed/sonarr/sonarr.db b/e2e/arr-seed/sonarr/sonarr.db index cd7387a5..c69bef69 100644 Binary files a/e2e/arr-seed/sonarr/sonarr.db and b/e2e/arr-seed/sonarr/sonarr.db differ diff --git a/e2e/arr-seed/sonarr/tv/Agatha All Along/Season 01/Agatha.All.Along.S01E05.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv b/e2e/arr-seed/sonarr/tv/Agatha All Along/Season 01/Agatha.All.Along.S01E05.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv new file mode 100644 index 00000000..0f7da3d0 Binary files /dev/null and b/e2e/arr-seed/sonarr/tv/Agatha All Along/Season 01/Agatha.All.Along.S01E05.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv differ diff --git a/e2e/scripts/seed-arr.sh b/e2e/scripts/seed-arr.sh index 70eed5b8..67efac61 100644 --- a/e2e/scripts/seed-arr.sh +++ b/e2e/scripts/seed-arr.sh @@ -7,6 +7,7 @@ # CI therefore needs no network and every run sees the same library. # # Needs internet: Sonarr reads Skyhook and Radarr reads TMDB. +# Needs ffmpeg: builds the two tiny media files the upgrade-detection spec imports. # Stops the sonarr and radarr containers. # # Re-run it when the pinned image tags change, or when the library has to change. @@ -36,6 +37,13 @@ RADARR_TMDB_ID=911430 # The Wild Robot, added second and left unmonitored. # The specs that need two candidates monitor it themselves and restore it after. RADARR_SECOND_TMDB_ID=1184918 +# Weapons: a third movie seeded with a file, for #381's upgrade-detection specs. +# F1 and Wild Robot stay fileless for the specs that depend on them. +RADARR_UPGRADE_TMDB_ID=1078605 + +# S01E01's episode id equals its series id, and seeker-replacement-grab.api.spec.ts owns S01E02. +# #381's specs use S01E04 and S01E05. +SONARR_UPGRADE_EPISODE_ID=5 log() { echo "[seed-arr] $*" @@ -119,6 +127,35 @@ wait_for_arr() { return 1 } +build_media_file() { + local path="$1" + + ffmpeg -y -loglevel error -f lavfi -i color=c=black:s=320x240:d=1 -c:v libx264 -pix_fmt yuv420p "$path" +} + +wait_for_command() { + local url="$1" api_key="$2" command_id="$3" name="$4" + + log "waiting for $name" + for _ in $(seq 1 60); do + local status + status=$(arr_get "$url" "$api_key" "/api/v3/command/$command_id" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["status"])') + + if [[ "$status" == "completed" ]]; then + return 0 + fi + if [[ "$status" == "failed" ]]; then + echo "[seed-arr] $name failed" >&2 + return 1 + fi + sleep 1 + done + + echo "[seed-arr] $name timed out" >&2 + return 1 +} + # Waits until the arr reports at least one item of the given kind. # The add call returns before the metadata refresh finishes. wait_for_items() { @@ -218,6 +255,51 @@ seed_sonarr() { arr_post "$SONARR_URL" "$SONARR_API_KEY" '/api/v3/series' "$series" > /dev/null wait_for_items "$SONARR_URL" "$SONARR_API_KEY" '/api/v3/episode?seriesId=1' 'sonarr episodes' + + seed_sonarr_upgrade_file +} + +# Gives S01E05 a file, for #381's upgrade-detection specs. +seed_sonarr_upgrade_file() { + log "importing an episode file for the upgrade spec" + + local series_path + series_path=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" '/api/v3/series/1' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["path"])') + + local filename="Agatha.All.Along.S01E05.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv" + local host_dir="$TEST_DATA/sonarr-tv/$(basename "$series_path")/Season 01" + mkdir -p "$host_dir" + build_media_file "$host_dir/$filename" + + local command_id + command_id=$(arr_post "$SONARR_URL" "$SONARR_API_KEY" '/api/v3/command' "$(cat <&2 + return 1 + fi } seed_radarr() { @@ -238,7 +320,62 @@ seed_radarr() { | merge_add_options '/movies' '{"searchForMovie":false}' 'false') arr_post "$RADARR_URL" "$RADARR_API_KEY" '/api/v3/movie' "$second" > /dev/null + log "adding the upgrade movie" + local upgrade + upgrade=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie/lookup?term=tmdb%3A$RADARR_UPGRADE_TMDB_ID" \ + | merge_add_options '/movies' '{"searchForMovie":false}') + arr_post "$RADARR_URL" "$RADARR_API_KEY" '/api/v3/movie' "$upgrade" > /dev/null + wait_for_items "$RADARR_URL" "$RADARR_API_KEY" '/api/v3/movie' 'radarr movies' + + seed_radarr_upgrade_file +} + +# Gives the third movie a file, for #381's upgrade-detection specs. +seed_radarr_upgrade_file() { + log "importing a movie file for the upgrade spec" + + local movie + movie=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" '/api/v3/movie' | python3 -c " +import json, sys +movie = next(m for m in json.load(sys.stdin) if m['tmdbId'] == $RADARR_UPGRADE_TMDB_ID) +print(json.dumps({'id': movie['id'], 'path': movie['path']})) +") + local movie_id movie_path + movie_id=$(echo "$movie" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') + movie_path=$(echo "$movie" | python3 -c 'import json,sys; print(json.load(sys.stdin)["path"])') + + local filename="Weapons.2025.1080p.WEB-DL.DDP5.1.H.264-SEED.mkv" + local host_dir="$TEST_DATA/radarr-movies/$(basename "$movie_path")" + mkdir -p "$host_dir" + build_media_file "$host_dir/$filename" + + local command_id + command_id=$(arr_post "$RADARR_URL" "$RADARR_API_KEY" '/api/v3/command' "$(cat <&2 + return 1 + fi } # Keeps the database and config.xml, drops everything the arr rebuilds on start. @@ -263,6 +400,13 @@ snapshot() { sqlite3 "$dest/$arr.db" 'PRAGMA journal_mode=DELETE; VACUUM;' > /dev/null rm -f "$dest/$arr.db-wal" "$dest/$arr.db-shm" + + # Carries the on-disk file the upgrade spec's moviefile/episodefile row points at. + # setup-test-data.sh restores it before every run, or the arr unlinks the row on its next disk scan. + case "$arr" in + radarr) cp -R "$TEST_DATA/radarr-movies" "$dest/movies" ;; + sonarr) cp -R "$TEST_DATA/sonarr-tv" "$dest/tv" ;; + esac } main() { diff --git a/e2e/scripts/setup-test-data.sh b/e2e/scripts/setup-test-data.sh index 94de5241..4bbb804b 100644 --- a/e2e/scripts/setup-test-data.sh +++ b/e2e/scripts/setup-test-data.sh @@ -16,12 +16,11 @@ ARR_SEED="$HERE/arr-seed" rm -rf "$TEST_DATA/torznab-src" # Media an arr imports during a run, wiped so its library always starts empty. -rm -rf "$TEST_DATA/sonarr-tv" "$TEST_DATA/radarr-movies" "$TEST_DATA/lazylibrarian-books" +# sonarr-tv and radarr-movies are handled below, alongside the arr config restore. +rm -rf "$TEST_DATA/lazylibrarian-books" mkdir -p \ "$TEST_DATA/torznab-src" \ - "$TEST_DATA/sonarr-tv" \ - "$TEST_DATA/radarr-movies" \ "$TEST_DATA/lazylibrarian-books" \ "$TEST_DATA/downloads/qbittorrent" \ "$TEST_DATA/downloads/transmission" \ @@ -112,6 +111,22 @@ for arr in sonarr radarr; do fi done +# Restore the seeded media files. +# Without them, the arr unlinks the seeded moviefile/episodefile rows on its next disk scan. +# Anything a run imports gets wiped. +rm -rf "$TEST_DATA/sonarr-tv" "$TEST_DATA/radarr-movies" +if [[ -d "$ARR_SEED/radarr/movies" ]]; then + cp -R "$ARR_SEED/radarr/movies" "$TEST_DATA/radarr-movies" +else + mkdir -p "$TEST_DATA/radarr-movies" +fi +if [[ -d "$ARR_SEED/sonarr/tv" ]]; then + cp -R "$ARR_SEED/sonarr/tv" "$TEST_DATA/sonarr-tv" +else + mkdir -p "$TEST_DATA/sonarr-tv" +fi +chmod -R a+rwX "$TEST_DATA/sonarr-tv" "$TEST_DATA/radarr-movies" + # Restore LazyLibrarian from its committed seed. # LazyLibrarian rewrites config.ini when it shuts down, so the run gets a copy. LAZYLIBRARIAN_SEED="$HERE/lazylibrarian-seed" diff --git a/e2e/tests/live-arr-fast/queue-rule-content-type.api.spec.ts b/e2e/tests/live-arr-fast/queue-rule-content-type.api.spec.ts new file mode 100644 index 00000000..7fcf4e7e --- /dev/null +++ b/e2e/tests/live-arr-fast/queue-rule-content-type.api.spec.ts @@ -0,0 +1,239 @@ +import { test, expect } from '../fixtures/base'; +import type { CleanuparrApi } from '../helpers/api'; +import type { ArrType } from '../helpers/api/arr'; +import { indexerMock } from '../helpers/live-arr'; +import type { LiveArr } from '../helpers/live-arr'; +import { + RADARR, + RUN_TAG, + SONARR, + arrangeQueueCleaner, + resetLiveArrState, + teardownQueueCleaner, + waitForArrQueue, +} from '../helpers/seeker-live'; +import { grabbableReleases } from '../helpers/mocks/torznab-stubs'; + +/** + * #381: stall rules scoped to new downloads vs upgrades. + * + * Grabs two stalled downloads against the real Sonarr and Radarr containers. + * One targets an item with a file (upgrade), the other an item without one (new). + * Checks that a New-scoped rule and an Upgrade-scoped rule strike each independently. + * + * The grab bypasses the Seeker: both default quality profiles have upgradeAllowed off. + * The arrs never offer a cutoff-unmet candidate on their own. + * A manual release search and grab, like the arr's "interactive search" button, returns the release with downloadAllowed=true. + * That puts it in the queue. + */ + +const STRIKE_TIMEOUT = 60_000; +const NEW_MAX_STRIKES = 3; +// Each run strikes both downloads together. +// A smaller gap races the delay between a trigger and its strike showing up. +const UPGRADE_MAX_STRIKES = 7; + +interface ContentTypeCase { + label: string; + arrType: ArrType; + arr: LiveArr; + url: string; + apiKey: string; + version: number; + searchMode: 'tvsearch' | 'movie'; + category: number; + queryParam: 'episodeId' | 'movieId'; + /** Seeded id with no file. */ + newItemId: number; + /** Id the seed gave a file. */ + upgradeItemId: number; + newTitle: string; + upgradeTitle: string; +} + +const SONARR_CASE: ContentTypeCase = { + label: 'sonarr', + arrType: 'sonarr', + arr: SONARR.arr, + url: SONARR.url, + apiKey: SONARR.apiKey, + version: SONARR.version, + searchMode: SONARR.searchMode, + category: SONARR.category, + queryParam: 'episodeId', + newItemId: 4, + upgradeItemId: 5, + newTitle: `Agatha.All.Along.S01E04.1080p.WEB-DL.DDP5.1.H.264-E2ECT${RUN_TAG}`, + upgradeTitle: `Agatha.All.Along.S01E05.1080p.WEB-DL.DDP5.1.H.264-E2ECT${RUN_TAG}`, +}; + +const RADARR_CASE: ContentTypeCase = { + label: 'radarr', + arrType: 'radarr', + arr: RADARR.arr, + url: RADARR.url, + apiKey: RADARR.apiKey, + version: RADARR.version, + searchMode: RADARR.searchMode, + category: RADARR.category, + queryParam: 'movieId', + newItemId: 1, + upgradeItemId: 3, + newTitle: `F1.2025.1080p.WEB-DL.DDP5.1.H.264-E2ECT${RUN_TAG}`, + upgradeTitle: `Weapons.2025.1080p.WEB-DL.DDP5.1.H.264-E2ECT${RUN_TAG}`, +}; + +interface StrikeGroup { + downloadId: string; + totalStrikes: number; +} + +async function strikeGroup(api: CleanuparrApi, downloadId: string): Promise { + const body = await (await api.strikes.list({ pageSize: 200 })).json(); + return (body.items ?? []).find( + (item: StrikeGroup) => item.downloadId.toLowerCase() === downloadId.toLowerCase(), + ); +} + +async function totalStrikes(api: CleanuparrApi, downloadId: string): Promise { + return (await strikeGroup(api, downloadId))?.totalStrikes ?? 0; +} + +async function isQueued(arr: LiveArr, downloadId: string): Promise { + await arr.refreshMonitoredDownloads(); + return (await arr.queue()).some((record) => record.downloadId === downloadId); +} + +/** Triggers the queue cleaner until the download's strike count reaches the target. */ +async function strikeUntil(api: CleanuparrApi, downloadId: string, target: number): Promise { + await expect + .poll( + async () => { + await api.jobs.trigger('QueueCleaner'); + return totalStrikes(api, downloadId); + }, + { timeout: STRIKE_TIMEOUT, intervals: [2_000] }, + ) + .toBeGreaterThanOrEqual(target); +} + +async function waitForRemoval(arr: LiveArr, downloadId: string): Promise { + await expect.poll(() => isQueued(arr, downloadId), { timeout: STRIKE_TIMEOUT, intervals: [2_000] }).toBe(false); +} + +/** + * Grabs a release from the arr's interactive search. + * Same as a user clicking "grab" on a release the automatic search skipped. + */ +async function manualGrab(arr: LiveArr, queryParam: 'episodeId' | 'movieId', itemId: number, guid: string): Promise { + const releases = await arr.get>>(`/api/v3/release?${queryParam}=${itemId}`); + const release = releases.find((candidate) => candidate.guid === guid); + + expect(release, `release ${guid} missing from the ${queryParam}=${itemId} search`).toBeTruthy(); + await arr.post('/api/v3/release', release); +} + +const createdArrInstances: Array<{ type: ArrType; id: string }> = []; + +/** This spec's own rules would conflict with a sibling spec's completion-range stall rule. */ +async function clearStallRules(api: CleanuparrApi): Promise { + const stallRules = await (await api.queueCleaner.listRules('stall')).json(); + for (const rule of stallRules) { + await api.queueCleaner.deleteRule('stall', rule.id); + } +} + +test.describe('QueueCleaner: content type scoping (#381)', () => { + test.beforeEach(async ({ api }) => { + await resetLiveArrState(); + await clearStallRules(api); + }); + + test.afterEach(async ({ api }) => { + for (const instance of createdArrInstances.splice(0)) { + await api.arr.deleteInstance(instance.type, instance.id); + } + + await clearStallRules(api); + await teardownQueueCleaner(api); + await resetLiveArrState(); + }); + + for (const testCase of [SONARR_CASE, RADARR_CASE]) { + test(`${testCase.label}: a New rule and an Upgrade rule strike their own download`, async ({ api }) => { + test.setTimeout(300_000); + + const { downloadIds, mappings } = grabbableReleases( + testCase.searchMode, + [testCase.newTitle, testCase.upgradeTitle], + testCase.category, + ); + const [newDownloadId, upgradeDownloadId] = downloadIds; + await indexerMock.stubMany(mappings); + + const instance = await ( + await api.arr.createInstance(testCase.arrType, { + name: `e2e-content-type-${testCase.label}`, + url: testCase.url, + apiKey: testCase.apiKey, + version: testCase.version, + enabled: true, + }) + ).json(); + createdArrInstances.push({ type: testCase.arrType, id: instance.id }); + + await arrangeQueueCleaner(api); + + const newRule = await ( + await api.queueCleaner.createRule('stall', { + name: `e2e-new-${testCase.label}`, + enabled: true, + maxStrikes: NEW_MAX_STRIKES, + privacyType: 'Both', + contentType: 'New', + minCompletionPercentage: 0, + maxCompletionPercentage: 100, + deletePrivateTorrentsFromClient: true, + changeCategory: false, + resetStrikesOnProgress: false, + minimumProgress: null, + }) + ).json(); + expect(newRule.id, 'the New rule should have been created').toBeTruthy(); + + const upgradeRule = await ( + await api.queueCleaner.createRule('stall', { + name: `e2e-upgrade-${testCase.label}`, + enabled: true, + maxStrikes: UPGRADE_MAX_STRIKES, + privacyType: 'Both', + contentType: 'Upgrade', + minCompletionPercentage: 0, + maxCompletionPercentage: 100, + deletePrivateTorrentsFromClient: true, + changeCategory: false, + resetStrikesOnProgress: false, + minimumProgress: null, + }) + ).json(); + expect(upgradeRule.id, 'the Upgrade rule should have been created').toBeTruthy(); + + await manualGrab(testCase.arr, testCase.queryParam, testCase.newItemId, testCase.newTitle); + await manualGrab(testCase.arr, testCase.queryParam, testCase.upgradeItemId, testCase.upgradeTitle); + + await waitForArrQueue(testCase.arr, newDownloadId, 90_000); + await waitForArrQueue(testCase.arr, upgradeDownloadId, 90_000); + + await strikeUntil(api, newDownloadId, NEW_MAX_STRIKES); + await waitForRemoval(testCase.arr, newDownloadId); + + // The Upgrade rule's ceiling is higher. + // The runs that removed the new download must leave the upgrade queued. + expect(await totalStrikes(api, upgradeDownloadId)).toBeLessThan(UPGRADE_MAX_STRIKES); + expect(await isQueued(testCase.arr, upgradeDownloadId)).toBe(true); + + await strikeUntil(api, upgradeDownloadId, UPGRADE_MAX_STRIKES); + await waitForRemoval(testCase.arr, upgradeDownloadId); + }); + } +}); diff --git a/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts b/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts index aa42f4f9..484a17d8 100644 --- a/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts +++ b/e2e/tests/live-arr-fast/seeker-selection.api.spec.ts @@ -21,6 +21,10 @@ import { torznabSearchStub } from '../helpers/mocks/torznab-stubs'; * * The seed leaves the second movie unmonitored, so every other spec sees one * candidate. These tests monitor it and the teardown puts the library back. + * + * The seed also gives a third movie a file, for the #381 upgrade-detection specs. + * Its profile never allows upgrades, so the Seeker never selects it. + * monitorEveryMovie() leaves it out of the candidate pool. */ const SETTLE_MS = 20_000; @@ -32,7 +36,9 @@ interface Candidate { } async function monitorEveryMovie(): Promise { - const movies = await RADARR.arr.get>>('/api/v3/movie'); + const movies = (await RADARR.arr.get>>('/api/v3/movie')).filter( + (movie) => !movie.hasFile, + ); for (const movie of movies) { if (!movie.monitored) { diff --git a/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts b/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts index 229bd580..6603f186 100644 --- a/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts +++ b/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from '@playwright/test'; +import { test, expect, type Page, type Locator } from '@playwright/test'; import { loginAndGotoSettings, toggle, @@ -11,6 +11,18 @@ import { // Behavior-parity spec for the Queue Cleaner settings form + stall-rule modal. test.describe('Queue Cleaner UI', () => { + // Privacy Type's hint contains "applies to", so hasText alone matches both fields. + // Excluding the Privacy Type label picks the right one. + function appliesToSelect(modal: Locator): Locator { + return modal.locator('app-select').filter({ hasText: 'Applies to' }).filter({ hasNotText: 'Privacy Type' }); + } + + async function selectAppliesTo(modal: Locator, optionText: string): Promise { + const el = appliesToSelect(modal); + await el.getByRole('combobox').click(); + await el.getByRole('option', { name: optionText, exact: true }).click(); + } + async function openStallModal(page: Page) { await ensureToggle(toggle(page, 'Enabled'), true); await ensureAccordionExpanded( @@ -224,6 +236,83 @@ test.describe('Queue Cleaner UI', () => { await expect(card).toHaveCount(0); }); + test('stall modal: Applies to persists after save and reload', async ({ page }) => { + const name = `ui-content-type-stall-${Date.now()}`; + await loginAndGotoSettings(page, 'queue-cleaner'); + const modal = await openStallModal(page); + + await modal.locator('app-input').filter({ hasText: 'Name' }).locator('input').fill(name); + await selectAppliesTo(modal, 'New'); + + const created = page.waitForResponse( + (r) => r.url().endsWith('/api/queue-rules/stall') && r.request().method() === 'POST', + ); + await modal.getByRole('button', { name: 'Create' }).click(); + expect((await created).status()).toBeLessThan(300); + await expect(modal).toBeHidden(); + + const card = page.locator('.rule-card').filter({ hasText: name }); + await expect(card).toBeVisible(); + + await page.reload(); + await ensureToggle(toggle(page, 'Enabled'), true); + await ensureAccordionExpanded( + page, + 'Stalled Download Rules', + page.getByRole('button', { name: 'Add Stall Rule' }), + ); + await expect(card).toBeVisible(); + + await card.getByRole('button', { name: 'Edit rule' }).click(); + const editModal = page.getByRole('dialog', { name: 'Edit Stall Rule' }); + await expect(appliesToSelect(editModal).getByRole('combobox')).toHaveText('New'); + + await editModal.getByRole('button', { name: 'Cancel' }).click(); + await card.getByRole('button', { name: 'Delete rule' }).click(); + await page.getByRole('alertdialog', { name: 'Delete Stall Rule' }) + .getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(card).toHaveCount(0); + }); + + test('slow modal: Applies to persists after save and reload', async ({ page }) => { + const name = `ui-content-type-slow-${Date.now()}`; + await loginAndGotoSettings(page, 'queue-cleaner'); + const modal = await openSlowModal(page); + + await modal.locator('app-input').filter({ hasText: 'Name' }).locator('input').fill(name); + await modal.locator('app-size-input').filter({ hasText: 'Minimum Speed' }).locator('input').first().fill('50'); + await selectAppliesTo(modal, 'Upgrade'); + + const created = page.waitForResponse( + (r) => r.url().endsWith('/api/queue-rules/slow') && r.request().method() === 'POST', + ); + await modal.getByRole('button', { name: 'Create' }).click(); + expect((await created).status()).toBeLessThan(300); + await expect(modal).toBeHidden(); + + const card = page.locator('.rule-card').filter({ hasText: name }); + await expect(card).toBeVisible(); + + await page.reload(); + await ensureToggle(toggle(page, 'Enabled'), true); + await ensureAccordionExpanded( + page, + 'Slow Download Rules', + page.getByRole('button', { name: 'Add Slow Rule' }), + ); + await expect(card).toBeVisible(); + + await card.getByRole('button', { name: 'Edit rule' }).click(); + const editModal = page.getByRole('dialog', { name: 'Edit Slow Rule' }); + await expect(appliesToSelect(editModal).getByRole('combobox')).toHaveText('Upgrade'); + + await editModal.getByRole('button', { name: 'Cancel' }).click(); + await card.getByRole('button', { name: 'Delete rule' }).click(); + await page.getByRole('alertdialog', { name: 'Delete Slow Rule' }) + .getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(card).toHaveCount(0); + }); + test('unsaved-changes guard fires when dirty and clears after revert', async ({ page }) => { await loginAndGotoSettings(page, 'queue-cleaner'); const enabled = toggle(page, 'Enabled'); diff --git a/e2e/tests/queue-cleaner/queue-rules.api.spec.ts b/e2e/tests/queue-cleaner/queue-rules.api.spec.ts index 09d7d7bd..5a3bdfe8 100644 --- a/e2e/tests/queue-cleaner/queue-rules.api.spec.ts +++ b/e2e/tests/queue-cleaner/queue-rules.api.spec.ts @@ -30,22 +30,22 @@ function buildPayload(kind: QueueRuleKind, name: string, overrides: Record = { stall: [ - 'changeCategory', 'deletePrivateTorrentsFromClient', 'enabled', 'id', + 'changeCategory', 'contentType', 'deletePrivateTorrentsFromClient', 'enabled', 'id', 'maxCompletionPercentage', 'maxStrikes', 'minCompletionPercentage', 'minimumProgress', 'name', 'privacyType', 'resetStrikesOnProgress', ], slow: [ - 'changeCategory', 'deletePrivateTorrentsFromClient', 'enabled', 'id', 'ignoreAboveSize', + 'changeCategory', 'contentType', 'deletePrivateTorrentsFromClient', 'enabled', 'id', 'ignoreAboveSize', 'ignoreWhileAltSpeedActive', 'maxCompletionPercentage', 'maxStrikes', 'maxTimeHours', 'minCompletionPercentage', 'minSpeed', 'name', 'privacyType', 'resetStrikesOnProgress', ], }; -test.describe('QueueCleaner — rules CRUD', () => { +test.describe('QueueCleaner: rules CRUD', () => { // Belt-and-braces: SQLite-direct autoReset clears the rule tables, but EF - // Core's pooled connection can occasionally hold a pre-DELETE snapshot. - // Explicitly delete any lingering rules through the API as well so the - // backend's own view matches our reset. + // Core's pooled connection can hold a pre-DELETE snapshot. + // Delete any lingering rules through the API too, so the + // backend's own view matches the reset. test.beforeEach(async ({ api }) => { for (const kind of KINDS) { const list = await (await api.queueCleaner.listRules(kind)).json(); @@ -107,5 +107,26 @@ test.describe('QueueCleaner — rules CRUD', () => { expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); }); + + test(`${kind}: New and Upgrade rules coexist, but All overlapping either is rejected`, async ({ api }) => { + const newRule = await api.queueCleaner.createRule( + kind, + buildPayload(kind, `${kind}-content-new`, { contentType: 'New' }), + ); + expect(newRule.status).toBeLessThan(300); + + const upgradeRule = await api.queueCleaner.createRule( + kind, + buildPayload(kind, `${kind}-content-upgrade`, { contentType: 'Upgrade' }), + ); + expect(upgradeRule.status).toBeLessThan(300); + + const allRule = await api.queueCleaner.createRule( + kind, + buildPayload(kind, `${kind}-content-all`, { contentType: 'All' }), + ); + expect(allRule.status).toBeGreaterThanOrEqual(400); + expect(allRule.status).toBeLessThan(500); + }); } });