The suite had 8 empty `pass` tests, two tests that made live network calls to a production healthcheck endpoint, and a write_csv test that only asserted the file existed. CI ran only test_komodo_deploy, so the main suite never executed at all. - Split tests by module: test_qlist, test_qprocess, test_qlogging_extra, test_apprise, test_qbt; rewrote test_qbitmaid and test_write_csv. - qbt_fakes.py: shared fakes (FakeQbt, FakeQbtClient, FakeAPIError, StubPushover, FailingPushover, torrent factory). - test_qbt.py loads the hyphenated qbit-maid.py via importlib and covers the __main__ entry point. - Replaced the live-network tests with a RecordingRequests stub (suite time 14s -> 0.035s). - .coveragerc + CI unit-tests job running the full suite with --fail-under=100. 127 unit tests, 100% statement coverage on all 5 first-party modules (440 stmts), 15/15 mutations caught. Seven pre-existing bugs are pinned by tests with KNOWN BUG docstrings and left unfixed pending a decision.
159 lines
5.1 KiB
Python
159 lines
5.1 KiB
Python
"""Shared fakes for the qbit-maid test suite.
|
|
|
|
This is test scaffolding, not production code, so it is excluded from the
|
|
coverage report (see ``.coveragerc``).
|
|
"""
|
|
import datetime
|
|
import logging
|
|
from collections import Counter
|
|
|
|
# Keep test output clean: the pipeline logs warnings for ignored torrents.
|
|
_test_logger = logging.getLogger("qbit-maid-test")
|
|
_test_logger.addHandler(logging.NullHandler())
|
|
_test_logger.propagate = False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# qbittorrentapi stand-in
|
|
# ---------------------------------------------------------------------------
|
|
class FakeApp:
|
|
version = "v4.6.2"
|
|
web_api_version = "2.9.3"
|
|
|
|
|
|
class FakeAPIError(Exception):
|
|
"""Stands in for qbittorrentapi.APIError."""
|
|
|
|
|
|
class FakeQbtClient:
|
|
"""Records calls instead of talking to a real qBittorrent instance."""
|
|
|
|
def __init__(self, torrents=None, login_error=None, **kwargs):
|
|
self.app = FakeApp()
|
|
self._torrents = list(torrents or [])
|
|
self._login_error = login_error
|
|
self.init_kwargs = kwargs
|
|
self.added_tags = []
|
|
self.deleted_tags = []
|
|
self.deleted = []
|
|
self.logged_in = False
|
|
|
|
def auth_log_in(self):
|
|
if self._login_error is not None:
|
|
raise self._login_error
|
|
self.logged_in = True
|
|
|
|
def torrents_info(self):
|
|
return list(self._torrents)
|
|
|
|
def torrents_add_tags(self, tags, torrent_hash):
|
|
self.added_tags.append((tags, torrent_hash))
|
|
|
|
def torrents_delete_tags(self, tags):
|
|
self.deleted_tags.append(list(tags))
|
|
|
|
def torrents_delete(self, delete_files, hashes):
|
|
self.deleted.append((delete_files, list(hashes)))
|
|
|
|
|
|
def torrent(name="Example.Torrent.Release", tracker="https://tracker.example/announce",
|
|
category="general", tags="", seeding_time=999999, state="uploading",
|
|
ratio=2.0, time_active=999999, infohash="abc123", added_on=1):
|
|
"""Build a torrent dict shaped like a qbittorrentapi response."""
|
|
return {
|
|
"name": name,
|
|
"tracker": tracker,
|
|
"category": category,
|
|
"tags": tags,
|
|
"seeding_time": seeding_time,
|
|
"state": state,
|
|
"ratio": ratio,
|
|
"time_active": time_active,
|
|
"infohash_v1": infohash,
|
|
"hash": infohash,
|
|
"added_on": added_on,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pushover stand-ins
|
|
# ---------------------------------------------------------------------------
|
|
class StubPushover:
|
|
"""Records message() calls instead of hitting the Pushover API."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def message(self, user, body, **kwargs):
|
|
self.calls.append({"user": user, "body": body, "kwargs": kwargs})
|
|
|
|
|
|
class FailingPushover:
|
|
def message(self, *args, **kwargs):
|
|
raise RuntimeError("pushover is down")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The Qbt object
|
|
# ---------------------------------------------------------------------------
|
|
class FakeQbt:
|
|
"""Stand-in for the Qbt object.
|
|
|
|
Defaults match the notifier tests (42 scanned, 7 removed, 4 orphaned).
|
|
Pipeline tests override ``torrent_list`` and ``qbt_client``.
|
|
"""
|
|
|
|
def __init__(self, **overrides):
|
|
# --- notifier surface ---
|
|
self.config = {"pushover": {}}
|
|
self.total_torrents = 42
|
|
self.torrent_hash_delete_list = ["a"] * 7
|
|
self.up_tor_counter = 4
|
|
self.ignored_counter = 5
|
|
self.preme_tor_counter = 3
|
|
self.tracker_protected_tag = "ipt"
|
|
self.tracker_non_protected_tag = "public"
|
|
self.c = Counter({"ipt": 10, "public": 20})
|
|
self.delete_torrents = True
|
|
self.st = datetime.datetime(2026, 10, 27, 9, 54, 0)
|
|
self.et = datetime.datetime(2026, 10, 27, 9, 54, 12)
|
|
self.po_key = "USERKEY"
|
|
self.poc = StubPushover()
|
|
self.apprise_host = "localhost"
|
|
self.apprise_port = 8088
|
|
self.apprise_aurls = "mailto://user:pass@example.com"
|
|
self.tl = logging.getLogger("qbit-maid-test")
|
|
|
|
# --- pipeline surface ---
|
|
self.torrent_list = []
|
|
self.tracker_list = []
|
|
self.qbt_client = FakeQbtClient()
|
|
self.cat_whitelist = {}
|
|
self.tracker_whitelist = {"tracker.example": "tracker.example"}
|
|
self.ignored_tags = {}
|
|
self.min_age = 432000
|
|
self.max_age = 2419200
|
|
self.enable_telemetry = False
|
|
self.telemetry_outfile = "telemetry.csv"
|
|
self.enable_dragnet = False
|
|
self.dragnet_outfile = "orphaned.csv"
|
|
self.cv = None
|
|
self.use_pushover = False
|
|
self.use_apprise = False
|
|
self.extm = ""
|
|
|
|
for key, value in overrides.items():
|
|
setattr(self, key, value)
|
|
|
|
def reset_counters(self):
|
|
"""Zero the pipeline counters.
|
|
|
|
The constructor defaults are the notifier fixture (42 scanned, 7
|
|
removed, ...), which pipeline tests must not inherit.
|
|
"""
|
|
self.up_tor_counter = 0
|
|
self.preme_tor_counter = 0
|
|
self.ignored_counter = 0
|
|
self.torrent_hash_delete_list = []
|
|
return self
|