Files
trex Bot 925f9619be
test / unit-tests (push) Failing after 4s
test / komodo-deploy-script (push) Successful in 16s
test / unit-tests (pull_request) Failing after 3s
test / komodo-deploy-script (pull_request) Successful in 16s
ci / docker (push) Successful in 1m37s
test: audit suite for meaningfulness, reach 100% coverage
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.
2026-11-03 15:34:57 -06:00

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