mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
added e2e tests
This commit is contained in:
1 parent
1c7996c4f8
commit
994dce0937
10 files changed
+525
-11
No files matched your search
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user