Files
alfred/tests/infrastructure/api/test_qbittorrent_client.py

416 lines
15 KiB
Python

"""Tests for ``alfred.infrastructure.api.qbittorrent.client.QBittorrentClient``.
Exercises every public method against a ``MagicMock`` ``requests.Session``
attached to the client. Auth state (``self._authenticated``) is asserted
explicitly so the implicit auto-login behavior of mutation methods is
covered.
Scope:
- ``TestInit`` — host/credentials wiring + Session attached.
- ``TestMakeRequest`` — verb dispatch (GET/POST), JSON vs text fallback,
error translation for timeout/403/5xx/RequestException, invalid verb.
- ``TestLogin`` — happy path, non-"Ok." rejection, propagation from
underlying API error.
- ``TestGetTorrents`` — auto-login, non-list payload safety, per-item parse
failures.
- ``TestAddTorrent`` — magnet payload wiring, optional category/save_path,
paused flag, unexpected response.
- ``TestMutations`` — pause/resume/delete/recheck/set_location all wire the
hash and propagate errors.
- ``TestFindByName`` — exact match, case-insensitive match, save_path fallback,
no match.
- ``TestParseTorrent`` — progress percentage conversion, defaults.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from requests.exceptions import HTTPError, RequestException, Timeout
from alfred.infrastructure.api.qbittorrent.client import QBittorrentClient
from alfred.infrastructure.api.qbittorrent.dto import TorrentInfo
from alfred.infrastructure.api.qbittorrent.exceptions import (
QBittorrentAPIError,
QBittorrentAuthError,
)
def _resp(body, *, status=200, json_decodable=True):
r = MagicMock()
r.status_code = status
r.raise_for_status.return_value = None
if json_decodable:
r.json.return_value = body
else:
r.json.side_effect = ValueError("not json")
r.text = body
return r
def _http_error(status_code):
r = MagicMock()
r.status_code = status_code
err = HTTPError(f"{status_code}")
err.response = r
r.raise_for_status.side_effect = err
return r
@pytest.fixture
def client():
c = QBittorrentClient(
host="http://qbit.test:8080",
username="admin",
password="secret",
timeout=5,
)
# Replace requests.Session with a MagicMock so we control responses
c.session = MagicMock()
return c
# --------------------------------------------------------------------------- #
# Init #
# --------------------------------------------------------------------------- #
class TestInit:
def test_explicit_args(self):
c = QBittorrentClient(host="http://x:1", username="u", password="p", timeout=99)
assert c.host == "http://x:1"
assert c.username == "u"
assert c.password == "p"
assert c.timeout == 99
assert c._authenticated is False
# --------------------------------------------------------------------------- #
# _make_request #
# --------------------------------------------------------------------------- #
class TestMakeRequest:
def test_invalid_verb(self, client):
with pytest.raises(ValueError, match="HTTP"):
client._make_request("PATCH", "/api/v2/foo")
def test_get_returns_json(self, client):
client.session.get.return_value = _resp({"k": "v"})
out = client._make_request("GET", "/x", data={"a": 1})
assert out == {"k": "v"}
client.session.get.assert_called_once()
def test_post_returns_text_when_not_json(self, client):
client.session.post.return_value = _resp("Ok.", json_decodable=False)
out = client._make_request("POST", "/x", data={"a": 1})
assert out == "Ok."
def test_timeout(self, client):
client.session.get.side_effect = Timeout("slow")
with pytest.raises(QBittorrentAPIError, match="timeout"):
client._make_request("GET", "/x")
def test_http_403_auth_error(self, client):
client.session.post.return_value = _http_error(403)
with pytest.raises(QBittorrentAuthError):
client._make_request("POST", "/x")
def test_http_500_generic(self, client):
client.session.get.return_value = _http_error(500)
with pytest.raises(QBittorrentAPIError, match="500"):
client._make_request("GET", "/x")
def test_request_exception(self, client):
client.session.get.side_effect = RequestException("net down")
with pytest.raises(QBittorrentAPIError, match="connect"):
client._make_request("GET", "/x")
# --------------------------------------------------------------------------- #
# Login #
# --------------------------------------------------------------------------- #
class TestLogin:
def test_login_success(self, client):
client.session.post.return_value = _resp("Ok.", json_decodable=False)
assert client.login() is True
assert client._authenticated is True
def test_login_wrong_credentials(self, client):
client.session.post.return_value = _resp("Fails.", json_decodable=False)
with pytest.raises(QBittorrentAuthError):
client.login()
assert client._authenticated is False
def test_login_api_error_translated_to_auth_error(self, client):
client.session.post.return_value = _http_error(403)
with pytest.raises(QBittorrentAuthError):
client.login()
# --------------------------------------------------------------------------- #
# get_torrents (auto-login behavior) #
# --------------------------------------------------------------------------- #
class TestGetTorrents:
def test_auto_logs_in_then_fetches(self, client):
# Order: 1) login POST, 2) torrents/info GET
client.session.post.return_value = _resp("Ok.", json_decodable=False)
client.session.get.return_value = _resp(
[
{
"hash": "h1",
"name": "Foo",
"size": 100,
"progress": 0.5,
"state": "downloading",
"dlspeed": 1024,
"upspeed": 512,
"eta": 60,
"num_seeds": 5,
"num_leechs": 1,
"ratio": 0.1,
"category": "movies",
"save_path": "/dl",
}
]
)
torrents = client.get_torrents()
assert len(torrents) == 1
assert torrents[0].name == "Foo"
assert torrents[0].progress == 50.0 # 0.5 → 50%
assert client._authenticated is True
def test_non_list_returns_empty(self, client):
client._authenticated = True
client.session.get.return_value = _resp({"oops": "bad"})
assert client.get_torrents() == []
def test_filter_and_category_propagated(self, client):
client._authenticated = True
client.session.get.return_value = _resp([])
client.get_torrents(filter="completed", category="movies")
params = client.session.get.call_args.kwargs["params"]
assert params == {"filter": "completed", "category": "movies"}
def test_skips_unparseable_torrents(self, client):
client._authenticated = True
# _parse_torrent uses .get on every field with sensible defaults, so
# malformed dicts almost never raise — patch the parser to force it.
client.session.get.return_value = _resp([{"good": True}])
with patch.object(client, "_parse_torrent", side_effect=Exception("nope")):
assert client.get_torrents() == []
# --------------------------------------------------------------------------- #
# add_torrent #
# --------------------------------------------------------------------------- #
class TestAddTorrent:
def test_add_success(self, client):
client._authenticated = True
client.session.post.return_value = _resp("Ok.", json_decodable=False)
assert client.add_torrent("magnet:?xt=foo") is True
def test_add_unexpected_response(self, client):
client._authenticated = True
client.session.post.return_value = _resp("Fails.", json_decodable=False)
assert client.add_torrent("magnet:?xt=foo") is False
def test_add_payload(self, client):
client._authenticated = True
client.session.post.return_value = _resp("Ok.", json_decodable=False)
client.add_torrent(
"magnet:?xt=foo", category="movies", save_path="/dl", paused=True
)
payload = client.session.post.call_args.kwargs["data"]
assert payload["urls"] == "magnet:?xt=foo"
assert payload["paused"] == "true"
assert payload["category"] == "movies"
assert payload["savepath"] == "/dl"
def test_paused_false_serialized(self, client):
client._authenticated = True
client.session.post.return_value = _resp("Ok.", json_decodable=False)
client.add_torrent("magnet:?xt=foo")
payload = client.session.post.call_args.kwargs["data"]
assert payload["paused"] == "false"
# --------------------------------------------------------------------------- #
# Mutations (delete, pause, resume, recheck, set_location) #
# --------------------------------------------------------------------------- #
class TestMutations:
def _ok(self, client):
client._authenticated = True
client.session.post.return_value = _resp("Ok.", json_decodable=False)
def test_delete_success(self, client):
self._ok(client)
assert client.delete_torrent("hash1", delete_files=True) is True
payload = client.session.post.call_args.kwargs["data"]
assert payload["hashes"] == "hash1"
assert payload["deleteFiles"] == "true"
def test_delete_no_files_default(self, client):
self._ok(client)
client.delete_torrent("hash1")
assert client.session.post.call_args.kwargs["data"]["deleteFiles"] == "false"
def test_pause(self, client):
self._ok(client)
assert client.pause_torrent("hash1") is True
def test_resume(self, client):
self._ok(client)
assert client.resume_torrent("hash1") is True
def test_recheck(self, client):
self._ok(client)
assert client.recheck("hash1") is True
def test_set_location(self, client):
self._ok(client)
assert client.set_location("hash1", "/new/path") is True
payload = client.session.post.call_args.kwargs["data"]
assert payload == {"hashes": "hash1", "location": "/new/path"}
def test_mutation_propagates_api_error(self, client):
client._authenticated = True
client.session.post.return_value = _http_error(500)
with pytest.raises(QBittorrentAPIError):
client.delete_torrent("hash1")
# --------------------------------------------------------------------------- #
# find_by_name #
# --------------------------------------------------------------------------- #
def _torrent_dict(name, save_path=None):
return {
"hash": "h",
"name": name,
"size": 1,
"progress": 0.0,
"state": "x",
"dlspeed": 0,
"upspeed": 0,
"eta": 0,
"num_seeds": 0,
"num_leechs": 0,
"ratio": 0.0,
"save_path": save_path,
}
class TestFindByName:
def test_exact_match(self, client):
client._authenticated = True
client.session.get.return_value = _resp(
[_torrent_dict("Foundation.S01"), _torrent_dict("Other")]
)
result = client.find_by_name("Foundation.S01")
assert isinstance(result, TorrentInfo)
assert result.name == "Foundation.S01"
def test_case_insensitive_match(self, client):
client._authenticated = True
client.session.get.return_value = _resp([_torrent_dict("foundation.s01")])
result = client.find_by_name("Foundation.S01")
assert result is not None
assert result.name == "foundation.s01"
def test_save_path_fallback(self, client):
client._authenticated = True
client.session.get.return_value = _resp(
[_torrent_dict("Different", save_path="/dl/Foundation.S01")]
)
result = client.find_by_name("Foundation.S01")
assert result is not None
assert result.save_path.endswith("Foundation.S01")
def test_no_match_returns_none(self, client):
client._authenticated = True
client.session.get.return_value = _resp([_torrent_dict("nope")])
assert client.find_by_name("Foundation.S01") is None
# --------------------------------------------------------------------------- #
# _parse_torrent #
# --------------------------------------------------------------------------- #
class TestParseTorrent:
def test_defaults(self, client):
t = client._parse_torrent({})
assert t.hash == ""
assert t.name == "Unknown"
assert t.progress == 0.0
assert t.state == "unknown"
def test_progress_converted_to_percentage(self, client):
t = client._parse_torrent({"progress": 0.75})
assert t.progress == 75.0
def test_full_payload(self, client):
t = client._parse_torrent(
{
"hash": "h",
"name": "n",
"size": 1024,
"progress": 1.0,
"state": "uploading",
"dlspeed": 100,
"upspeed": 50,
"eta": 0,
"num_seeds": 10,
"num_leechs": 2,
"ratio": 2.5,
"category": "movies",
"save_path": "/dl",
}
)
assert t.progress == 100.0
assert t.ratio == 2.5
assert t.category == "movies"
# --------------------------------------------------------------------------- #
# logout #
# --------------------------------------------------------------------------- #
class TestLogout:
def test_logout_success(self, client):
client._authenticated = True
client.session.post.return_value = _resp("", json_decodable=False)
assert client.logout() is True
assert client._authenticated is False
def test_logout_swallows_errors(self, client):
client._authenticated = True
client.session.post.side_effect = RuntimeError("boom")
# Per implementation, logout returns False instead of raising.
assert client.logout() is False
# --------------------------------------------------------------------------- #
# get_torrent_properties #
# --------------------------------------------------------------------------- #
class TestGetTorrentProperties:
def test_properties_returned(self, client):
client._authenticated = True
client.session.get.return_value = _resp({"piece_size": 16384})
assert client.get_torrent_properties("h")["piece_size"] == 16384