feat: add remove_completed job and new download client job type

This commit is contained in:
Jakub Buzuk
2025-09-11 02:08:56 +02:00
parent b2cb1ebf86
commit b433e06338
7 changed files with 580 additions and 15 deletions

View File

@@ -0,0 +1,249 @@
"""Tests for the remove_completed job."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.jobs.remove_completed import COMPLETED_STATES, RemoveCompleted
def create_mock_settings(target_tags=None, target_categories=None):
"""Create mock settings for testing."""
settings = MagicMock()
settings.jobs = MagicMock()
settings.jobs.remove_completed.enabled = True
settings.jobs.remove_completed.target_tags = target_tags or []
settings.jobs.remove_completed.target_categories = target_categories or []
settings.general = MagicMock()
settings.general.protected_tag = "protected"
return settings
def create_mock_download_client(items: list):
"""Create a mock download client."""
client = MagicMock()
client.get_qbit_items = AsyncMock(return_value=items)
return client
# Default item properties for tests
ITEM_DEFAULTS = {
"progress": 1,
"ratio": 0,
"ratio_limit": -1,
"seeding_time": 0,
"seeding_time_limit": -1,
"tags": "",
"category": "movies",
"state": "stoppedUP",
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("item_properties", "target_tags", "target_categories", "should_be_removed"),
[
# Ratio limit met, matching tag and category
(
{"ratio": 2, "ratio_limit": 2, "tags": "tag1"},
["tag1"],
["movies"],
True,
),
# Seeding time limit met, matching tag and category
(
{"seeding_time": 100, "seeding_time_limit": 100, "tags": "tag1"},
["tag1"],
["movies"],
True,
),
# Neither limit met
({"ratio": 1, "ratio_limit": 2}, ["tag1"], ["movies"], False),
# Progress less than 1 (should not be considered completed)
(
{"progress": 0.5, "state": "downloading"},
["tag1"],
["movies"],
False,
),
# No matching tags or categories
(
{"ratio": 2, "ratio_limit": 2, "tags": "other", "category": "tv"},
["tag1"],
["movies"],
False,
),
# Matching category, but not completed
({"category": "tv", "state": "downloading"}, [], ["tv"], False),
# Matching tag, but not completed
({"tags": "tag2", "state": "downloading"}, ["tag2"], [], False),
# Matching category and completed (ratio)
(
{"ratio": 2, "ratio_limit": 2, "category": "tv"},
[],
["tv"],
True,
),
# Matching tag and completed (seeding time)
(
{"seeding_time": 100, "seeding_time_limit": 100, "tags": "tag2"},
["tag2"],
[],
True,
),
# No targets specified
({"ratio": 2, "ratio_limit": 2}, [], [], False),
# Item with multiple tags, one is a target
(
{"tags": "tag1,tag2", "ratio": 2, "ratio_limit": 2},
["tag2"],
[],
True,
),
# Item with a tag that is a substring of a target tag (should not match)
({"tags": "tag", "ratio": 2, "ratio_limit": 2}, ["tag1"], [], False),
# Item with a category that is a substring of a target (should not match)
(
{"category": "movie", "ratio": 2, "ratio_limit": 2},
[],
["movies"],
False,
),
# Test with another completed state
(
{"ratio": 2, "ratio_limit": 2, "state": "pausedUP"},
["tag1"],
["movies"],
True,
),
],
)
async def test_remove_completed_logic(
item_properties: dict,
target_tags: list,
target_categories: list,
should_be_removed: bool,
):
"""Test the logic of the remove_completed job with various scenarios."""
item = {**ITEM_DEFAULTS, **item_properties, "name": "test_item"}
settings = create_mock_settings(target_tags, target_categories)
client = create_mock_download_client([item])
job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed")
items_to_remove = await job._get_items_to_remove(await client.get_qbit_items())
if should_be_removed:
assert len(items_to_remove) == 1
assert items_to_remove[0]["name"] == "test_item"
else:
assert len(items_to_remove) == 0
@pytest.mark.asyncio
async def test_remove_completed_skipped_for_sabnzbd():
"""Test that the remove_completed job is skipped for SABnzbd clients."""
settings = create_mock_settings()
client = create_mock_download_client([])
job = RemoveCompleted(client, "sabnzbd", settings, "remove_completed")
# We check the log message instead of mocking the super run
with patch.object(job.logger, "debug") as mock_log:
result = await job.run()
assert result == 0
mock_log.assert_called_with(
"Skipping job 'remove_completed' for Usenet client mock_client_name.",
)
@pytest.mark.asyncio
async def test_remove_completed_test_run_enabled():
"""Test that no items are removed when test_run is enabled."""
item = {
**ITEM_DEFAULTS,
"ratio": 2,
"ratio_limit": 2,
"name": "test_item",
"tags": "tag1",
}
settings = create_mock_settings(target_tags=["tag1"])
settings.general.test_run = True
client = create_mock_download_client([item])
job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed")
with patch.object(
job,
"_remove_qbittorrent_item",
new_callable=AsyncMock,
) as mock_remove:
result = await job.run()
assert (
result == 1
) # The job should still report the number of items it would have removed
mock_remove.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("protected_on", ["tag", "category"])
async def test_remove_completed_with_protected_item(protected_on):
"""Test that items with a protected tag or category are not removed."""
item_properties = {"ratio": 2, "ratio_limit": 2, "name": "protected_item"}
target_tags = ["tag1"]
target_categories = ["movies"]
if protected_on == "tag":
item_properties["tags"] = "protected"
# Also add a targetable tag to ensure it's the protection that stops it
item_properties["tags"] += ",tag1"
else:
item_properties["category"] = "protected"
item = {**ITEM_DEFAULTS, **item_properties}
settings = create_mock_settings(
target_tags=target_tags,
target_categories=target_categories,
)
client = create_mock_download_client([item])
job = RemoveCompleted(client, "qbittorrent", settings, "remove_completed")
with patch.object(
job,
"_remove_qbittorrent_item",
new_callable=AsyncMock,
) as mock_remove:
result = await job.run()
assert result == 0 # No items should be removed
mock_remove.assert_not_called()
@pytest.mark.asyncio
async def test_is_completed_logic():
"""Test the internal _is_completed logic with different states and limits."""
job = RemoveCompleted(MagicMock(), "qbittorrent", MagicMock(), "remove_completed")
# Completed states
for state in COMPLETED_STATES:
# Ratio met
assert job._is_completed(
{"state": state, "ratio": 2, "ratio_limit": 2},
), f"Failed for state {state} with ratio met"
# Seeding time met
assert job._is_completed(
{"state": state, "seeding_time": 100, "seeding_time_limit": 100},
), f"Failed for state {state} with seeding time met"
# Neither met
assert not job._is_completed(
{"state": state, "ratio": 1, "ratio_limit": 2},
), f"Failed for state {state} with neither limit met"
# Limits not set
assert not job._is_completed(
{"state": state, "ratio": 1, "ratio_limit": -1},
), f"Failed for state {state} with no ratio limit"
# Non-completed states
assert not job._is_completed(
{"state": "downloading", "ratio": 2, "ratio_limit": 1},
), "Failed for non-completed state"