added e2e tests

This commit is contained in:
Flaminel committed 2026-10-15 19:06:43 +03:00
1 parent 1c7996c4f8
commit 994dce0937
10 files changed
+525 -11

No files matched your search

Binary file not shown.
Binary file not shown.
+144
View File
@@ -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 <<EOF
{
"name": "ManualImport",
"files": [{
"path": "$series_path/Season 01/$filename",
"seriesId": 1,
"episodeIds": [$SONARR_UPGRADE_EPISODE_ID],
"quality": { "quality": { "id": 3 }, "revision": { "version": 1, "real": 0, "isRepack": false } },
"languages": [{ "id": 1, "name": "English" }],
"indexerFlags": 0,
"releaseType": "singleEpisode"
}],
"importMode": "move"
}
EOF
)" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
wait_for_command "$SONARR_URL" "$SONARR_API_KEY" "$command_id" 'sonarr upgrade episode import'
local has_file
has_file=$(arr_get "$SONARR_URL" "$SONARR_API_KEY" "/api/v3/episode/$SONARR_UPGRADE_EPISODE_ID" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["hasFile"])')
if [[ "$has_file" != "True" ]]; then
echo "[seed-arr] sonarr upgrade episode never got a file" >&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 <<EOF
{
"name": "ManualImport",
"files": [{
"path": "$movie_path/$filename",
"movieId": $movie_id,
"quality": { "quality": { "id": 8 }, "revision": { "version": 1, "real": 0, "isRepack": false } },
"languages": [{ "id": 1, "name": "English" }],
"indexerFlags": 0
}],
"importMode": "move"
}
EOF
)" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
wait_for_command "$RADARR_URL" "$RADARR_API_KEY" "$command_id" 'radarr upgrade movie import'
local has_file
has_file=$(arr_get "$RADARR_URL" "$RADARR_API_KEY" "/api/v3/movie/$movie_id" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["hasFile"])')
if [[ "$has_file" != "True" ]]; then
echo "[seed-arr] radarr upgrade movie never got a file" >&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() {
+18 -3
View File
@@ -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"
@@ -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<StrikeGroup | undefined> {
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<number> {
return (await strikeGroup(api, downloadId))?.totalStrikes ?? 0;
}
async function isQueued(arr: LiveArr, downloadId: string): Promise<boolean> {
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<void> {
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<void> {
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<void> {
const releases = await arr.get<Array<Record<string, unknown>>>(`/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<void> {
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);
});
}
});
@@ -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<Candidate[]> {
const movies = await RADARR.arr.get<Array<Record<string, unknown>>>('/api/v3/movie');
const movies = (await RADARR.arr.get<Array<Record<string, unknown>>>('/api/v3/movie')).filter(
(movie) => !movie.hasFile,
);
for (const movie of movies) {
if (!movie.monitored) {
@@ -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<void> {
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');
@@ -30,22 +30,22 @@ function buildPayload(kind: QueueRuleKind, name: string, overrides: Record<strin
const RULE_KEYS: Record<QueueRuleKind, string[]> = {
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);
});
}
});