Files
decluttarr/tests/jobs/test_remove_completed.py
2025-10-15 13:06:18 +02:00

252 lines
8.2 KiB
Python

"""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, client_name="mock_client_name"):
"""Create a mock download client."""
client = MagicMock()
client.get_qbit_items = AsyncMock(return_value=items)
client.name = client_name
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([], client_name="mock_client_name")
job = RemoveCompleted(client, "sabnzbd", settings, "remove_completed")
# We check the log message instead of mocking the super run
with patch("src.jobs.remove_completed.logger.debug") as mock_log:
result = await job.run()
assert result == 0
mock_log.assert_called_with(
"remove_completed.py/run: Skipping job 'remove_completed' for unsupported 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_items",
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"
target_categories = ["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_items",
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"