mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-04-22 14:58:15 -04:00
- Added new book output option **upload to Booklore**, available in download settings - Got annoyed at my messy processing code while implementing Booklore so refactored the whole thing - Full black box file processing testing with randomised configuration - Deluge: Connect via WebUI auth for simplified setup - Added env vars documentation, auto generated via script, and unlocked most settings to be used as env vars
491 lines
17 KiB
Python
491 lines
17 KiB
Python
"""
|
|
Integration failure tests for download clients.
|
|
|
|
These tests verify error handling behavior against REAL running clients.
|
|
They require the Docker test stack to be running.
|
|
|
|
Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_integration_failures.py -v -m integration
|
|
|
|
Key scenarios tested:
|
|
- Invalid magnet links / bad torrents
|
|
- Non-existent download IDs
|
|
- Duplicate downloads
|
|
- Client disconnection recovery
|
|
"""
|
|
|
|
import time
|
|
import pytest
|
|
|
|
from shelfmark.core.config import config
|
|
from shelfmark.core.settings_registry import save_config_file
|
|
from shelfmark.release_sources.prowlarr.clients import DownloadStatus, DownloadState
|
|
|
|
|
|
# Invalid magnet - valid format but non-existent torrent
|
|
INVALID_MAGNET = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000&dn=nonexistent"
|
|
|
|
# Valid Ubuntu magnet for comparison tests
|
|
VALID_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso"
|
|
|
|
|
|
# =============================================================================
|
|
# Configuration Setup Functions (copied from test_integration_clients.py)
|
|
# =============================================================================
|
|
|
|
|
|
def _setup_transmission_config():
|
|
save_config_file("prowlarr_clients", {
|
|
"PROWLARR_TORRENT_CLIENT": "transmission",
|
|
"TRANSMISSION_URL": "http://transmission:9091",
|
|
"TRANSMISSION_USERNAME": "admin",
|
|
"TRANSMISSION_PASSWORD": "admin",
|
|
"TRANSMISSION_CATEGORY": "test",
|
|
})
|
|
config.refresh()
|
|
|
|
|
|
def _setup_qbittorrent_config():
|
|
save_config_file("prowlarr_clients", {
|
|
"PROWLARR_TORRENT_CLIENT": "qbittorrent",
|
|
"QBITTORRENT_URL": "http://qbittorrent:8080",
|
|
"QBITTORRENT_USERNAME": "admin",
|
|
"QBITTORRENT_PASSWORD": "admin123",
|
|
"QBITTORRENT_CATEGORY": "test",
|
|
})
|
|
config.refresh()
|
|
|
|
|
|
def _setup_deluge_config():
|
|
save_config_file("prowlarr_clients", {
|
|
"PROWLARR_TORRENT_CLIENT": "deluge",
|
|
"DELUGE_HOST": "deluge",
|
|
"DELUGE_PORT": "8112",
|
|
"DELUGE_PASSWORD": "deluge",
|
|
"DELUGE_CATEGORY": "test",
|
|
})
|
|
config.refresh()
|
|
|
|
|
|
# =============================================================================
|
|
# Client Factory Functions
|
|
# =============================================================================
|
|
|
|
|
|
def _try_get_transmission_client():
|
|
_setup_transmission_config()
|
|
try:
|
|
from shelfmark.release_sources.prowlarr.clients.transmission import TransmissionClient
|
|
client = TransmissionClient()
|
|
client.test_connection()
|
|
return client
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _try_get_qbittorrent_client():
|
|
_setup_qbittorrent_config()
|
|
try:
|
|
from shelfmark.release_sources.prowlarr.clients.qbittorrent import QBittorrentClient
|
|
client = QBittorrentClient()
|
|
success, _ = client.test_connection()
|
|
if success:
|
|
return client
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _try_get_deluge_client():
|
|
_setup_deluge_config()
|
|
try:
|
|
from shelfmark.release_sources.prowlarr.clients.deluge import DelugeClient
|
|
client = DelugeClient()
|
|
success, _ = client.test_connection()
|
|
if success:
|
|
return client
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
# =============================================================================
|
|
# Fixtures
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def transmission_client():
|
|
client = _try_get_transmission_client()
|
|
if client is None:
|
|
pytest.skip("Transmission not available")
|
|
return client
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def qbittorrent_client():
|
|
client = _try_get_qbittorrent_client()
|
|
if client is None:
|
|
pytest.skip("qBittorrent not available")
|
|
return client
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def deluge_client():
|
|
client = _try_get_deluge_client()
|
|
if client is None:
|
|
pytest.skip("Deluge not available")
|
|
return client
|
|
|
|
|
|
# =============================================================================
|
|
# Non-Existent Download ID Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestNonExistentDownloads:
|
|
"""Tests for handling non-existent download IDs."""
|
|
|
|
def test_transmission_get_status_nonexistent(self, transmission_client):
|
|
"""Transmission should handle non-existent download ID gracefully."""
|
|
status = transmission_client.get_status("nonexistent-hash-12345")
|
|
|
|
# Should return an error status, not crash
|
|
assert isinstance(status, DownloadStatus)
|
|
assert status.state == DownloadState.ERROR or status.complete is False
|
|
|
|
def test_transmission_remove_nonexistent(self, transmission_client):
|
|
"""Transmission should handle removing non-existent download."""
|
|
# Should not raise, should return False or True depending on implementation
|
|
result = transmission_client.remove("nonexistent-hash-12345", delete_files=True)
|
|
# Just verify it doesn't crash - implementations vary on return value
|
|
assert isinstance(result, bool)
|
|
|
|
def test_transmission_get_path_nonexistent(self, transmission_client):
|
|
"""Transmission should return None for non-existent download path."""
|
|
path = transmission_client.get_download_path("nonexistent-hash-12345")
|
|
assert path is None
|
|
|
|
def test_qbittorrent_get_status_nonexistent(self, qbittorrent_client):
|
|
"""qBittorrent should handle non-existent download ID gracefully."""
|
|
status = qbittorrent_client.get_status("0" * 40) # Valid hash format but nonexistent
|
|
|
|
assert isinstance(status, DownloadStatus)
|
|
assert status.state == DownloadState.ERROR or status.complete is False
|
|
|
|
def test_qbittorrent_remove_nonexistent(self, qbittorrent_client):
|
|
"""qBittorrent should handle removing non-existent download."""
|
|
result = qbittorrent_client.remove("0" * 40, delete_files=True)
|
|
assert isinstance(result, bool)
|
|
|
|
def test_deluge_get_status_nonexistent(self, deluge_client):
|
|
"""Deluge should handle non-existent download ID gracefully."""
|
|
status = deluge_client.get_status("0" * 40)
|
|
|
|
assert isinstance(status, DownloadStatus)
|
|
assert status.state == DownloadState.ERROR or status.complete is False
|
|
|
|
def test_deluge_remove_nonexistent(self, deluge_client):
|
|
"""Deluge should handle removing non-existent download."""
|
|
result = deluge_client.remove("0" * 40, delete_files=True)
|
|
assert isinstance(result, bool)
|
|
|
|
|
|
# =============================================================================
|
|
# Duplicate Download Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestDuplicateDownloads:
|
|
"""Tests for handling duplicate download requests."""
|
|
|
|
def test_transmission_add_duplicate(self, transmission_client):
|
|
"""Transmission should handle adding same torrent twice."""
|
|
client = transmission_client
|
|
|
|
# Add first time
|
|
download_id_1 = client.add_download(
|
|
url=VALID_MAGNET,
|
|
name="Duplicate Test 1",
|
|
)
|
|
time.sleep(2)
|
|
|
|
try:
|
|
# Add same magnet again - should either:
|
|
# 1. Return the same ID (deduplicated)
|
|
# 2. Raise an exception
|
|
# 3. Return a new ID (some clients allow this)
|
|
try:
|
|
download_id_2 = client.add_download(
|
|
url=VALID_MAGNET,
|
|
name="Duplicate Test 2",
|
|
)
|
|
# If we get here, client allowed it
|
|
# Clean up the duplicate if different
|
|
if download_id_2 != download_id_1:
|
|
client.remove(download_id_2, delete_files=True)
|
|
except Exception as e:
|
|
# Expected - client rejected duplicate
|
|
assert "duplicate" in str(e).lower() or "exists" in str(e).lower()
|
|
finally:
|
|
client.remove(download_id_1, delete_files=True)
|
|
|
|
def test_qbittorrent_add_duplicate(self, qbittorrent_client):
|
|
"""qBittorrent should handle adding same torrent twice."""
|
|
client = qbittorrent_client
|
|
|
|
download_id_1 = client.add_download(
|
|
url=VALID_MAGNET,
|
|
name="Duplicate Test qBit 1",
|
|
)
|
|
time.sleep(3)
|
|
|
|
try:
|
|
try:
|
|
download_id_2 = client.add_download(
|
|
url=VALID_MAGNET,
|
|
name="Duplicate Test qBit 2",
|
|
)
|
|
if download_id_2 != download_id_1:
|
|
client.remove(download_id_2, delete_files=True)
|
|
except Exception:
|
|
# Expected behavior
|
|
pass
|
|
finally:
|
|
client.remove(download_id_1, delete_files=True)
|
|
|
|
|
|
# =============================================================================
|
|
# Invalid URL/Magnet Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestInvalidUrls:
|
|
"""Tests for handling invalid URLs and magnets."""
|
|
|
|
def test_transmission_completely_invalid_url(self, transmission_client):
|
|
"""Transmission should reject completely invalid URL."""
|
|
with pytest.raises(Exception):
|
|
transmission_client.add_download(
|
|
url="not-a-valid-url-at-all",
|
|
name="Invalid URL Test",
|
|
)
|
|
|
|
def test_transmission_malformed_magnet(self, transmission_client):
|
|
"""Transmission should reject malformed magnet link."""
|
|
with pytest.raises(Exception):
|
|
transmission_client.add_download(
|
|
url="magnet:?xt=invalid",
|
|
name="Malformed Magnet Test",
|
|
)
|
|
|
|
def test_qbittorrent_completely_invalid_url(self, qbittorrent_client):
|
|
"""qBittorrent should reject completely invalid URL."""
|
|
with pytest.raises(Exception):
|
|
qbittorrent_client.add_download(
|
|
url="not-a-valid-url-at-all",
|
|
name="Invalid URL Test",
|
|
)
|
|
|
|
def test_deluge_completely_invalid_url(self, deluge_client):
|
|
"""Deluge should reject completely invalid URL."""
|
|
with pytest.raises(Exception):
|
|
deluge_client.add_download(
|
|
url="not-a-valid-url-at-all",
|
|
name="Invalid URL Test",
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# find_existing Edge Cases
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestFindExistingEdgeCases:
|
|
"""Tests for find_existing behavior in edge cases."""
|
|
|
|
def test_transmission_find_nonexistent(self, transmission_client):
|
|
"""find_existing should return None for non-existent torrent."""
|
|
result = transmission_client.find_existing(INVALID_MAGNET)
|
|
assert result is None
|
|
|
|
def test_qbittorrent_find_nonexistent(self, qbittorrent_client):
|
|
"""find_existing should return None for non-existent torrent."""
|
|
result = qbittorrent_client.find_existing(INVALID_MAGNET)
|
|
assert result is None
|
|
|
|
def test_deluge_find_nonexistent(self, deluge_client):
|
|
"""find_existing should return None for non-existent torrent."""
|
|
result = deluge_client.find_existing(INVALID_MAGNET)
|
|
assert result is None
|
|
|
|
def test_transmission_find_with_invalid_url(self, transmission_client):
|
|
"""find_existing should handle invalid URL gracefully."""
|
|
# Should return None, not crash
|
|
result = transmission_client.find_existing("not-a-magnet-link")
|
|
assert result is None
|
|
|
|
def test_qbittorrent_find_with_invalid_url(self, qbittorrent_client):
|
|
"""find_existing should handle invalid URL gracefully."""
|
|
result = qbittorrent_client.find_existing("not-a-magnet-link")
|
|
assert result is None
|
|
|
|
|
|
# =============================================================================
|
|
# Connection Loss Simulation (where possible)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestConnectionResilience:
|
|
"""Tests for connection resilience."""
|
|
|
|
def test_transmission_recovers_after_session_id_change(self, transmission_client):
|
|
"""Transmission should handle session ID invalidation."""
|
|
# First, make a successful call to establish session
|
|
transmission_client.test_connection()
|
|
|
|
# Manually invalidate the session ID if accessible
|
|
if hasattr(transmission_client, '_session_id'):
|
|
old_session = transmission_client._session_id
|
|
transmission_client._session_id = "invalid-session-id"
|
|
|
|
# Should auto-recover with a new session
|
|
success, msg = transmission_client.test_connection()
|
|
|
|
# Restore or verify it got a new one
|
|
assert success or transmission_client._session_id != "invalid-session-id"
|
|
|
|
def test_qbittorrent_handles_expired_cookie(self, qbittorrent_client):
|
|
"""qBittorrent should handle expired session cookie."""
|
|
# First, make a successful call
|
|
qbittorrent_client.test_connection()
|
|
|
|
# Clear the session if accessible
|
|
if hasattr(qbittorrent_client, '_session'):
|
|
qbittorrent_client._session.cookies.clear()
|
|
|
|
# Should re-authenticate automatically
|
|
success, msg = qbittorrent_client.test_connection()
|
|
assert success
|
|
|
|
|
|
# =============================================================================
|
|
# State Transition Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestStateTransitions:
|
|
"""Tests verifying correct state reporting during download lifecycle."""
|
|
|
|
def test_transmission_initial_state_is_valid(self, transmission_client):
|
|
"""Newly added torrent should have valid initial state."""
|
|
client = transmission_client
|
|
|
|
download_id = client.add_download(
|
|
url=VALID_MAGNET,
|
|
name="State Test",
|
|
)
|
|
|
|
try:
|
|
# Check immediately
|
|
status = client.get_status(download_id)
|
|
|
|
assert isinstance(status, DownloadStatus)
|
|
assert 0 <= status.progress <= 100
|
|
assert isinstance(status.complete, bool)
|
|
|
|
# State should be one of the expected values
|
|
valid_states = {
|
|
DownloadState.DOWNLOADING,
|
|
DownloadState.QUEUED,
|
|
DownloadState.CHECKING,
|
|
DownloadState.PAUSED,
|
|
"downloading",
|
|
"queued",
|
|
"checking",
|
|
"fetching_metadata",
|
|
}
|
|
assert status.state in valid_states or isinstance(status.state, DownloadState)
|
|
finally:
|
|
client.remove(download_id, delete_files=True)
|
|
|
|
def test_qbittorrent_initial_state_is_valid(self, qbittorrent_client):
|
|
"""Newly added torrent should have valid initial state."""
|
|
client = qbittorrent_client
|
|
|
|
download_id = client.add_download(
|
|
url=VALID_MAGNET,
|
|
name="State Test qBit",
|
|
)
|
|
|
|
try:
|
|
time.sleep(2) # qBittorrent needs a moment
|
|
status = client.get_status(download_id)
|
|
|
|
assert isinstance(status, DownloadStatus)
|
|
assert 0 <= status.progress <= 100
|
|
assert isinstance(status.complete, bool)
|
|
finally:
|
|
client.remove(download_id, delete_files=True)
|
|
|
|
def test_transmission_reports_metadata_fetching(self, transmission_client):
|
|
"""Transmission should report metadata fetching for magnet links."""
|
|
client = transmission_client
|
|
|
|
# Use the invalid magnet - it will stay in metadata fetching state
|
|
download_id = client.add_download(
|
|
url=INVALID_MAGNET,
|
|
name="Metadata Test",
|
|
)
|
|
|
|
try:
|
|
time.sleep(2)
|
|
status = client.get_status(download_id)
|
|
|
|
# For an invalid magnet, it should either:
|
|
# 1. Be stuck in metadata fetching
|
|
# 2. Have an error
|
|
# 3. Show 0% progress
|
|
assert status.progress == 0 or status.state == DownloadState.ERROR
|
|
finally:
|
|
client.remove(download_id, delete_files=True)
|
|
|
|
|
|
# =============================================================================
|
|
# Timeout Behavior Tests
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.slow
|
|
class TestTimeoutBehavior:
|
|
"""Tests for timeout and stall detection."""
|
|
|
|
def test_transmission_stalled_torrent_state(self, transmission_client):
|
|
"""Verify stalled torrent is reported correctly."""
|
|
client = transmission_client
|
|
|
|
# Add invalid magnet - will never find peers
|
|
download_id = client.add_download(
|
|
url=INVALID_MAGNET,
|
|
name="Stall Test",
|
|
)
|
|
|
|
try:
|
|
# Wait a bit for it to "stall"
|
|
time.sleep(5)
|
|
|
|
status = client.get_status(download_id)
|
|
|
|
# Should still be at 0% or have stalled status
|
|
assert status.progress == 0
|
|
# State could be downloading (but no progress) or specific stall state
|
|
assert not status.complete
|
|
finally:
|
|
client.remove(download_id, delete_files=True)
|