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.
This commit is contained in:
1 parent
5b54d9e558
commit
925f9619be
10 files changed
+1348
-164
No files matched your search
+18
@@ -0,0 +1,18 @@
|
|||||||
|
[run]
|
||||||
|
source = .
|
||||||
|
branch = False
|
||||||
|
omit =
|
||||||
|
pushover.py
|
||||||
|
test_*.py
|
||||||
|
qbt_fakes.py
|
||||||
|
*/venv/*
|
||||||
|
/tmp/*
|
||||||
|
|
||||||
|
[report]
|
||||||
|
# Entry-point guards are not reachable from a test process; they are covered
|
||||||
|
# by the container smoke test instead.
|
||||||
|
exclude_lines =
|
||||||
|
pragma: no cover
|
||||||
|
if __name__ == .__main__.:
|
||||||
|
show_missing = True
|
||||||
|
skip_covered = False
|
||||||
@@ -8,6 +8,24 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Install dependencies
|
||||||
|
# Mirrors the Dockerfile: the container installs qbittorrent-api
|
||||||
|
# unpinned, so requirements.txt is not used here.
|
||||||
|
run: |
|
||||||
|
python3 -m pip install --upgrade pip
|
||||||
|
python3 -m pip install requests qbittorrent-api coverage
|
||||||
|
- name: Run the full suite with coverage
|
||||||
|
run: |
|
||||||
|
python3 -m coverage run --rcfile=.coveragerc -m unittest \
|
||||||
|
test_qbitmaid test_write_csv test_qlist test_qprocess \
|
||||||
|
test_qlogging_extra test_apprise test_qbt
|
||||||
|
python3 -m coverage report -m --fail-under=100
|
||||||
|
|
||||||
komodo-deploy-script:
|
komodo-deploy-script:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
"""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
|
||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
"""Tests for AppriseClient — the HTTP poster and its config resolution."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import AppriseClient as apprise_module
|
||||||
|
from AppriseClient import AppriseClient, apprise_notify
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRequests:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def post(self, url, json=None, verify=None):
|
||||||
|
self.calls.append({"url": url, "json": json, "verify": verify})
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppriseNotify(unittest.TestCase):
|
||||||
|
def test_posts_the_expected_payload(self):
|
||||||
|
req = FakeRequests()
|
||||||
|
response = apprise_notify(
|
||||||
|
req, "apprise.local", 8088, "mailto://u:p@example.com", "Title", "Body"
|
||||||
|
)
|
||||||
|
self.assertIsInstance(response, FakeResponse)
|
||||||
|
self.assertEqual(len(req.calls), 1)
|
||||||
|
call = req.calls[0]
|
||||||
|
self.assertEqual(call["url"], "http://apprise.local:8088/notify/")
|
||||||
|
self.assertEqual(call["json"], {
|
||||||
|
"urls": "mailto://u:p@example.com",
|
||||||
|
"title": "Title",
|
||||||
|
"body": "Body",
|
||||||
|
})
|
||||||
|
self.assertFalse(call["verify"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppriseClientConfig(unittest.TestCase):
|
||||||
|
ENV_KEYS = ("DOCKER", "host", "port", "aurls", "title", "body", "toml_path")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
|
self._cwd = os.getcwd()
|
||||||
|
os.chdir(self.tmp)
|
||||||
|
self.addCleanup(os.chdir, self._cwd)
|
||||||
|
|
||||||
|
saved = {key: os.environ.get(key) for key in self.ENV_KEYS}
|
||||||
|
for key in self.ENV_KEYS:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
self.addCleanup(self._restore_env, saved)
|
||||||
|
|
||||||
|
self.captured = {}
|
||||||
|
patcher = mock.patch.object(
|
||||||
|
apprise_module, "apprise_notify", self._fake_notify
|
||||||
|
)
|
||||||
|
patcher.start()
|
||||||
|
self.addCleanup(patcher.stop)
|
||||||
|
|
||||||
|
def _restore_env(self, saved):
|
||||||
|
for key, value in saved.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
def _fake_notify(self, req_obj, host, port, aurls, title, body):
|
||||||
|
self.captured.update(host=host, port=port, aurls=aurls, title=title, body=body)
|
||||||
|
return "sent"
|
||||||
|
|
||||||
|
def write_config(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"host": "config-host",
|
||||||
|
"port": 9999,
|
||||||
|
"aurls": "mailto://config@example.com",
|
||||||
|
"title": "Config Title",
|
||||||
|
"body": "Config Body",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
with open("config.toml", "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(
|
||||||
|
"[apprise]\n"
|
||||||
|
f'host = "{values["host"]}"\n'
|
||||||
|
f"port = {values["port"]}\n"
|
||||||
|
f'aurls = "{values["aurls"]}"\n'
|
||||||
|
f'title = "{values["title"]}"\n'
|
||||||
|
f'body = "{values["body"]}"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reads_values_from_environment(self):
|
||||||
|
os.environ.update({
|
||||||
|
"DOCKER": "1",
|
||||||
|
"host": "env-host",
|
||||||
|
"port": "1234",
|
||||||
|
"aurls": "mailto://env@example.com",
|
||||||
|
"title": "Env Title",
|
||||||
|
"body": "Env Body",
|
||||||
|
})
|
||||||
|
AppriseClient()
|
||||||
|
self.assertEqual(self.captured["host"], "env-host")
|
||||||
|
self.assertEqual(self.captured["port"], "1234")
|
||||||
|
self.assertEqual(self.captured["title"], "Env Title")
|
||||||
|
|
||||||
|
def test_reads_values_from_config_file(self):
|
||||||
|
self.write_config()
|
||||||
|
AppriseClient()
|
||||||
|
self.assertEqual(self.captured["host"], "config-host")
|
||||||
|
self.assertEqual(self.captured["port"], 9999)
|
||||||
|
self.assertEqual(self.captured["aurls"], "mailto://config@example.com")
|
||||||
|
self.assertEqual(self.captured["title"], "Config Title")
|
||||||
|
self.assertEqual(self.captured["body"], "Config Body")
|
||||||
|
|
||||||
|
def test_config_file_overrides_environment(self):
|
||||||
|
os.environ.update({
|
||||||
|
"DOCKER": "1",
|
||||||
|
"host": "env-host",
|
||||||
|
"port": "1234",
|
||||||
|
"aurls": "mailto://env@example.com",
|
||||||
|
"title": "Env Title",
|
||||||
|
"body": "Env Body",
|
||||||
|
})
|
||||||
|
self.write_config()
|
||||||
|
AppriseClient()
|
||||||
|
self.assertEqual(self.captured["host"], "config-host")
|
||||||
|
self.assertEqual(self.captured["title"], "Config Title")
|
||||||
|
|
||||||
|
def _write_elsewhere_config(self):
|
||||||
|
config_path = os.path.join(self.tmp, "elsewhere.toml")
|
||||||
|
with open(config_path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(
|
||||||
|
"[apprise]\n"
|
||||||
|
'host = "toml-host"\n'
|
||||||
|
"port = 4321\n"
|
||||||
|
'aurls = "mailto://toml@example.com"\n'
|
||||||
|
'title = "Toml Title"\n'
|
||||||
|
'body = "Toml Body"\n'
|
||||||
|
)
|
||||||
|
return config_path
|
||||||
|
|
||||||
|
def test_toml_path_is_read_when_docker_is_set(self):
|
||||||
|
# The DOCKER branch reads five env vars before toml_path is reached, so
|
||||||
|
# all of them must be present for the toml_path branch to run at all.
|
||||||
|
os.environ.update({
|
||||||
|
"DOCKER": "1",
|
||||||
|
"host": "env-host",
|
||||||
|
"port": "1234",
|
||||||
|
"aurls": "mailto://env@example.com",
|
||||||
|
"title": "Env Title",
|
||||||
|
"body": "Env Body",
|
||||||
|
})
|
||||||
|
os.environ["toml_path"] = self._write_elsewhere_config()
|
||||||
|
AppriseClient()
|
||||||
|
self.assertEqual(self.captured["host"], "toml-host")
|
||||||
|
self.assertEqual(self.captured["port"], 4321)
|
||||||
|
|
||||||
|
def test_toml_path_is_ignored_without_docker(self):
|
||||||
|
"""KNOWN BUG: the bare ``except`` swallows the DOCKER KeyError and
|
||||||
|
skips the rest of the try block, so ``toml_path`` is never read unless
|
||||||
|
``DOCKER`` is also set. With no ./config.toml either, the client then
|
||||||
|
crashes on the missing ``host`` attribute.
|
||||||
|
"""
|
||||||
|
os.environ["toml_path"] = self._write_elsewhere_config()
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
AppriseClient()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+113
-129
@@ -1,167 +1,142 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
|
||||||
import unittest
|
import unittest
|
||||||
from collections import Counter
|
|
||||||
|
|
||||||
import requests as r
|
from qbt_fakes import FailingPushover, FakeQbt, StubPushover
|
||||||
from qlist import is_preme,is_cat_ignored,is_tracker_blank,is_protected_tracker,is_not_protected_tracker,is_tag_blank,is_ignored_tag
|
from qlist import (
|
||||||
from qprocess import is_downloading,is_protected_under_ratio,is_old_tor,is_protected_over_ratio,is_not_protected_tor
|
is_cat_ignored,
|
||||||
|
is_ignored_tag,
|
||||||
|
is_not_protected_tracker,
|
||||||
|
is_preme,
|
||||||
|
is_protected_tracker,
|
||||||
|
is_tag_blank,
|
||||||
|
is_tracker_blank,
|
||||||
|
)
|
||||||
from qlogging import (
|
from qlogging import (
|
||||||
send_ping,
|
|
||||||
build_summary,
|
build_summary,
|
||||||
format_runtime,
|
format_runtime,
|
||||||
get_notify_options,
|
get_notify_options,
|
||||||
|
send_ping,
|
||||||
summary_stats,
|
summary_stats,
|
||||||
tor_notify_apprise,
|
tor_notify_apprise,
|
||||||
tor_notify_summary,
|
tor_notify_summary,
|
||||||
)
|
)
|
||||||
|
from qprocess import (
|
||||||
|
is_downloading,
|
||||||
|
is_not_protected_tor,
|
||||||
|
is_old_tor,
|
||||||
|
is_protected_over_ratio,
|
||||||
|
is_protected_under_ratio,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeQbt:
|
class RecordingRequests:
|
||||||
"""Minimal stand-in for the Qbt object, holding only what notifiers read."""
|
"""Stands in for the requests module without touching the network."""
|
||||||
|
|
||||||
def __init__(self, **overrides):
|
class RequestException(Exception):
|
||||||
self.config = {"pushover": {}}
|
pass
|
||||||
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")
|
|
||||||
for key, value in overrides.items():
|
|
||||||
setattr(self, key, value)
|
|
||||||
|
|
||||||
|
|
||||||
class StubPushover:
|
|
||||||
"""Records message() calls instead of hitting the Pushover API."""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
self.timeouts = []
|
||||||
|
|
||||||
def message(self, user, body, **kwargs):
|
def get(self, url, timeout=None):
|
||||||
self.calls.append({"user": user, "body": body, "kwargs": kwargs})
|
self.calls.append(url)
|
||||||
|
self.timeouts.append(timeout)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
|
||||||
class FailingPushover:
|
class TestPredicates(unittest.TestCase):
|
||||||
def message(self, *args, **kwargs):
|
def test_is_preme(self):
|
||||||
raise RuntimeError("pushover is down")
|
self.assertTrue(is_preme(1, 1)) # equal to min_age counts as premature
|
||||||
|
|
||||||
class TestQbitmaid(unittest.TestCase):
|
|
||||||
def test_ispreme_sanity(self):
|
|
||||||
self.assertTrue(is_preme(1,1))
|
|
||||||
self.assertTrue(is_preme(1, 2))
|
self.assertTrue(is_preme(1, 2))
|
||||||
self.assertFalse(is_preme(2, 1))
|
self.assertFalse(is_preme(2, 1))
|
||||||
|
|
||||||
def test_ispreme(self):
|
def test_is_cat_ignored(self):
|
||||||
pass
|
cats = ["a", "b", "c"]
|
||||||
|
for cat in cats:
|
||||||
|
self.assertTrue(is_cat_ignored(cat, cats))
|
||||||
|
self.assertFalse(is_cat_ignored("d", cats))
|
||||||
|
self.assertFalse(is_cat_ignored(1, cats))
|
||||||
|
self.assertFalse(is_cat_ignored(1.0, cats))
|
||||||
|
|
||||||
def test_iscatignored_sanity(self):
|
def test_is_tracker_blank(self):
|
||||||
self.assertTrue(is_cat_ignored('a', ['a','b','c']))
|
self.assertTrue(is_tracker_blank(""))
|
||||||
self.assertTrue(is_cat_ignored('b', ['a','b','c']))
|
self.assertFalse(is_tracker_blank("a"))
|
||||||
self.assertTrue(is_cat_ignored('c', ['a','b','c']))
|
|
||||||
self.assertFalse(is_cat_ignored('d', ['a','b','c']))
|
|
||||||
self.assertFalse(is_cat_ignored(1, ['a','b','d']))
|
|
||||||
self.assertFalse(is_cat_ignored(1.0000000, ['a','b','c']))
|
|
||||||
|
|
||||||
def test_iscatignored(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_istrackerblank_sanity(self):
|
|
||||||
self.assertTrue(is_tracker_blank(''))
|
|
||||||
self.assertFalse(is_tracker_blank('a'))
|
|
||||||
self.assertFalse(is_tracker_blank(1))
|
self.assertFalse(is_tracker_blank(1))
|
||||||
self.assertFalse(is_tracker_blank(1.00000000))
|
self.assertFalse(is_tracker_blank(1.0))
|
||||||
|
|
||||||
def test_istrackerblank(self):
|
def test_is_protected_tracker(self):
|
||||||
pass
|
whitelist = ["a.com", "b.me", "c.io"]
|
||||||
|
self.assertTrue(is_protected_tracker("https://a.com/", whitelist))
|
||||||
|
self.assertFalse(is_protected_tracker("https://google.com/", whitelist))
|
||||||
|
self.assertFalse(is_protected_tracker("https://d.com", whitelist))
|
||||||
|
self.assertFalse(is_protected_tracker("", whitelist))
|
||||||
|
|
||||||
def test_isprotectedtracker_sanity(self):
|
def test_is_not_protected_tracker(self):
|
||||||
self.assertTrue(is_protected_tracker('https://a.com/',['a.com','b.me','c.io']))
|
whitelist = ["a.com", "b.me", "c.io"]
|
||||||
self.assertFalse(is_protected_tracker('https://google.com/',['a.com','b.me','c.io']))
|
self.assertFalse(is_not_protected_tracker("https://a.com/", whitelist))
|
||||||
self.assertFalse(is_protected_tracker('https://d.com',['a.com','b.me','c.io']))
|
self.assertTrue(is_not_protected_tracker("https://google.com/", whitelist))
|
||||||
|
self.assertTrue(is_not_protected_tracker("https://d.com", whitelist))
|
||||||
|
self.assertFalse(is_not_protected_tracker("", whitelist))
|
||||||
|
|
||||||
def test_isprotectedtracker(self):
|
def test_is_tag_blank(self):
|
||||||
pass
|
self.assertTrue(is_tag_blank(""))
|
||||||
|
for value in ("a", 1, 1.0001, False, True):
|
||||||
|
self.assertFalse(is_tag_blank(value))
|
||||||
|
|
||||||
def test_isnotprotectedtracker_sanity(self):
|
def test_is_downloading(self):
|
||||||
self.assertFalse(is_not_protected_tracker('https://a.com/',['a.com','b.me','c.io']))
|
self.assertTrue(is_downloading("downloading"))
|
||||||
self.assertTrue(is_not_protected_tracker('https://google.com/',['a.com','b.me','c.io']))
|
self.assertFalse(is_downloading("DOWNLOADING"))
|
||||||
self.assertTrue(is_not_protected_tracker('https://d.com',['a.com','b.me','c.io']))
|
self.assertFalse(is_downloading("dOwNlOaDiNg"))
|
||||||
|
self.assertFalse(is_downloading("uploading"))
|
||||||
|
|
||||||
def test_isnotprotectedtracker(self):
|
def test_is_protected_under_ratio(self):
|
||||||
pass
|
self.assertTrue(is_protected_under_ratio(0.5, 1, "a", "a,b,c"))
|
||||||
|
self.assertFalse(is_protected_under_ratio(1.5, 1, "a", "a,b,c"))
|
||||||
|
self.assertFalse(is_protected_under_ratio(0.5, 1, "z", "a,b,c"))
|
||||||
|
|
||||||
def test_istagblank(self):
|
def test_is_old_tor(self):
|
||||||
self.assertTrue(is_tag_blank(''))
|
|
||||||
self.assertFalse(is_tag_blank('a'))
|
|
||||||
self.assertFalse(is_tag_blank(1))
|
|
||||||
self.assertFalse(is_tag_blank(1.0001))
|
|
||||||
self.assertFalse(is_tag_blank(False))
|
|
||||||
self.assertFalse(is_tag_blank(True))
|
|
||||||
|
|
||||||
def test_isdownloading_sanity(self):
|
|
||||||
self.assertTrue(is_downloading('downloading'))
|
|
||||||
|
|
||||||
def test_isdownloading(self):
|
|
||||||
self.assertFalse(is_downloading('DOWNLOADING'))
|
|
||||||
self.assertFalse(is_downloading('dOwNlOaDiNg'))
|
|
||||||
|
|
||||||
def test_isprotectedunderratio_sanity(self):
|
|
||||||
self.assertTrue(is_protected_under_ratio(0.5,1,'a','a,b,c'))
|
|
||||||
|
|
||||||
def test_isprotectedunderratio(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_isoldtor_sanity(self):
|
|
||||||
self.assertFalse(is_old_tor(1,2))
|
|
||||||
|
|
||||||
def test_isoldtor(self):
|
|
||||||
self.assertTrue(is_old_tor(1, 1))
|
self.assertTrue(is_old_tor(1, 1))
|
||||||
self.assertTrue(is_old_tor(2, 1))
|
self.assertTrue(is_old_tor(2, 1))
|
||||||
self.assertFalse(is_old_tor(1, 2))
|
self.assertFalse(is_old_tor(1, 2))
|
||||||
|
|
||||||
def test_isprotectedoverratio_sanity(self):
|
def test_is_protected_over_ratio(self):
|
||||||
self.assertTrue(is_protected_over_ratio(2,1,'a','a,b,c'))
|
self.assertTrue(is_protected_over_ratio(2, 1, "a", "a,b,c"))
|
||||||
|
self.assertFalse(is_protected_over_ratio(0.5, 1, "a", "a,b,c"))
|
||||||
|
self.assertFalse(is_protected_over_ratio(2, 1, "z", "a,b,c"))
|
||||||
|
|
||||||
def test_isprotectedoverratio(self):
|
def test_is_not_protected_tor(self):
|
||||||
pass
|
self.assertTrue(is_not_protected_tor("a", "a,b,c"))
|
||||||
|
self.assertFalse(is_not_protected_tor("z", "a,b,c"))
|
||||||
|
|
||||||
def test_isnonprotectedtor_sanity(self):
|
def test_is_ignored_tag(self):
|
||||||
self.assertTrue(is_not_protected_tor('a','a,b,c'))
|
self.assertTrue(is_ignored_tag(["save"], "save,public,ipt"))
|
||||||
|
self.assertFalse(is_ignored_tag(["save"], "public,ipt"))
|
||||||
|
|
||||||
def test_isnonprotectedtor(self):
|
def test_is_ignored_tag_matches_substrings_not_tags(self):
|
||||||
pass
|
"""KNOWN BUG: matching is substring-based, so a short tag matches any
|
||||||
|
longer tag that contains it. 'ipt' matches 'iptorrents' and 'save'
|
||||||
def test_isignoredtag_sanity(self):
|
matches 'unsaved', which silently ignores unrelated torrents.
|
||||||
self.assertTrue(is_ignored_tag(['a','b','c'], 'first,second,third,a'))
|
"""
|
||||||
|
self.assertTrue(is_ignored_tag(["ipt"], "iptorrents"))
|
||||||
def test_isignoredtag(self):
|
self.assertTrue(is_ignored_tag(["save"], "unsaved"))
|
||||||
self.assertTrue(is_ignored_tag(['save'], 'save,public,ipt'))
|
|
||||||
|
|
||||||
def test_sendpingstart_sanity(self):
|
|
||||||
send_ping(self, r, "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb" + "/start")
|
|
||||||
url = "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb"
|
|
||||||
send_ping(self, r, url)
|
|
||||||
|
|
||||||
def test_sendping_start(self):
|
|
||||||
url = "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb/"
|
|
||||||
send_ping(self, r, url.strip("/") + "/start")
|
|
||||||
send_ping(self, r, "https://thunder.jonb.io/ping/921625e5-5b76-4f45-a0c3-56145e16f3bb")
|
|
||||||
|
|
||||||
|
|
||||||
# --- notification formatting -------------------------------------------
|
class TestSendPing(unittest.TestCase):
|
||||||
|
def test_send_ping_requests_the_url(self):
|
||||||
|
req = RecordingRequests()
|
||||||
|
send_ping(FakeQbt(), req, "https://example.com/ping/x")
|
||||||
|
self.assertEqual(req.calls, ["https://example.com/ping/x"])
|
||||||
|
|
||||||
|
def test_send_ping_uses_a_five_second_timeout(self):
|
||||||
|
req = RecordingRequests()
|
||||||
|
send_ping(FakeQbt(), req, "https://example.com/ping/x")
|
||||||
|
self.assertEqual(req.timeouts, [5])
|
||||||
|
|
||||||
|
|
||||||
|
# --- notification formatting -----------------------------------------------
|
||||||
|
|
||||||
|
class TestFormatRuntime(unittest.TestCase):
|
||||||
def test_format_runtime(self):
|
def test_format_runtime(self):
|
||||||
self.assertEqual(format_runtime(datetime.timedelta(seconds=0)), "0s")
|
self.assertEqual(format_runtime(datetime.timedelta(seconds=0)), "0s")
|
||||||
self.assertEqual(format_runtime(datetime.timedelta(seconds=12)), "12s")
|
self.assertEqual(format_runtime(datetime.timedelta(seconds=12)), "12s")
|
||||||
@@ -169,6 +144,8 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
self.assertEqual(format_runtime(datetime.timedelta(seconds=3723)), "1h 02m 03s")
|
self.assertEqual(format_runtime(datetime.timedelta(seconds=3723)), "1h 02m 03s")
|
||||||
self.assertEqual(format_runtime(datetime.timedelta(seconds=-5)), "0s")
|
self.assertEqual(format_runtime(datetime.timedelta(seconds=-5)), "0s")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummaryStats(unittest.TestCase):
|
||||||
def test_summary_stats(self):
|
def test_summary_stats(self):
|
||||||
stats = summary_stats(FakeQbt())
|
stats = summary_stats(FakeQbt())
|
||||||
self.assertEqual(stats["total"], 42)
|
self.assertEqual(stats["total"], 42)
|
||||||
@@ -189,6 +166,8 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
self.assertEqual(stats["removed"], 0)
|
self.assertEqual(stats["removed"], 0)
|
||||||
self.assertEqual(stats["runtime"], "unknown")
|
self.assertEqual(stats["runtime"], "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSummary(unittest.TestCase):
|
||||||
def test_build_summary_plain(self):
|
def test_build_summary_plain(self):
|
||||||
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
|
title, body = build_summary(FakeQbt(), style="plain", use_emoji=False)
|
||||||
self.assertEqual(title, "qbit-maid: 7 torrents removed")
|
self.assertEqual(title, "qbit-maid: 7 torrents removed")
|
||||||
@@ -295,10 +274,16 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
_, body = build_summary(FakeQbt(), include_hostname=True)
|
_, body = build_summary(FakeQbt(), include_hostname=True)
|
||||||
self.assertTrue(body.startswith("Host: "))
|
self.assertTrue(body.startswith("Host: "))
|
||||||
|
|
||||||
|
def test_build_summary_hostname_html(self):
|
||||||
|
_, body = build_summary(FakeQbt(), style="html", include_hostname=True)
|
||||||
|
self.assertTrue(body.startswith("<b>Host:</b> "))
|
||||||
|
|
||||||
def test_build_summary_unknown_style_falls_back(self):
|
def test_build_summary_unknown_style_falls_back(self):
|
||||||
_, body = build_summary(FakeQbt(), style="markdown")
|
_, body = build_summary(FakeQbt(), style="markdown")
|
||||||
self.assertNotIn("<b>", body)
|
self.assertNotIn("<b>", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifyOptions(unittest.TestCase):
|
||||||
def test_get_notify_options_defaults(self):
|
def test_get_notify_options_defaults(self):
|
||||||
opts = get_notify_options(FakeQbt())
|
opts = get_notify_options(FakeQbt())
|
||||||
self.assertEqual(opts["style"], "plain")
|
self.assertEqual(opts["style"], "plain")
|
||||||
@@ -337,6 +322,8 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
fake = FakeQbt(config={"pushover": {"format": "bogus"}})
|
fake = FakeQbt(config={"pushover": {"format": "bogus"}})
|
||||||
self.assertEqual(get_notify_options(fake)["style"], "plain")
|
self.assertEqual(get_notify_options(fake)["style"], "plain")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTorNotifySummary(unittest.TestCase):
|
||||||
def test_tor_notify_summary_plain_payload(self):
|
def test_tor_notify_summary_plain_payload(self):
|
||||||
fake = FakeQbt()
|
fake = FakeQbt()
|
||||||
tor_notify_summary(fake)
|
tor_notify_summary(fake)
|
||||||
@@ -387,6 +374,8 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
fake.poc = FailingPushover()
|
fake.poc = FailingPushover()
|
||||||
tor_notify_summary(fake) # must not raise
|
tor_notify_summary(fake) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestTorNotifyApprise(unittest.TestCase):
|
||||||
def test_tor_notify_apprise_uses_plain_body(self):
|
def test_tor_notify_apprise_uses_plain_body(self):
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
@@ -410,11 +399,6 @@ class TestQbitmaid(unittest.TestCase):
|
|||||||
self.assertEqual(fake.poc.calls[0]["body"], captured["body"])
|
self.assertEqual(fake.poc.calls[0]["body"], captured["body"])
|
||||||
self.assertEqual(fake.poc.calls[0]["kwargs"]["title"], captured["title"])
|
self.assertEqual(fake.poc.calls[0]["kwargs"]["title"], captured["title"])
|
||||||
|
|
||||||
# def test__sanity(self):
|
|
||||||
# pass
|
|
||||||
|
|
||||||
# def test_(self):
|
if __name__ == "__main__":
|
||||||
# pass
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
unittest.main()
|
||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
"""End-to-end tests for the Qbt object in qbit-maid.py.
|
||||||
|
|
||||||
|
The module name contains a hyphen, so it cannot be imported normally; it is
|
||||||
|
loaded by path with ``qbittorrentapi`` stubbed out.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from qbt_fakes import FakeAPIError, FakeQbtClient, StubPushover, torrent
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
CONFIG_TEMPLATE = """
|
||||||
|
[qbittorrent]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8080
|
||||||
|
username = "user"
|
||||||
|
password = "pass"
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
use_log = true
|
||||||
|
log_level = "{log_level}"
|
||||||
|
log_path = "{log_path}"
|
||||||
|
|
||||||
|
[app_tags]
|
||||||
|
protected_tag = "ipt"
|
||||||
|
non_protected_tag = "public"
|
||||||
|
|
||||||
|
[torrent]
|
||||||
|
max_age = 2419200
|
||||||
|
min_age = 432000
|
||||||
|
delete_torrents = {delete_torrents}
|
||||||
|
|
||||||
|
[pushover]
|
||||||
|
use_pushover = {use_pushover}
|
||||||
|
po_key = "USERKEY"
|
||||||
|
po_token = "TOKEN"
|
||||||
|
|
||||||
|
[apprise]
|
||||||
|
use_apprise = {use_apprise}
|
||||||
|
host = "localhost"
|
||||||
|
port = 8088
|
||||||
|
aurls = "mailto://user:pass@example.com"
|
||||||
|
|
||||||
|
[dragnet]
|
||||||
|
enable_dragnet = false
|
||||||
|
dragnet_outfile = "{dragnet_outfile}"
|
||||||
|
|
||||||
|
[telemetry]
|
||||||
|
enable_telemetry = false
|
||||||
|
telemetry_outfile = "{telemetry_outfile}"
|
||||||
|
|
||||||
|
[ignored_categories]
|
||||||
|
tech = "tech"
|
||||||
|
|
||||||
|
[ignored_domains]
|
||||||
|
tracker-example = "tracker.example"
|
||||||
|
|
||||||
|
[ignored_tags]
|
||||||
|
save = "save"
|
||||||
|
|
||||||
|
[healthcheck]
|
||||||
|
use_healthcheck = {use_healthcheck}
|
||||||
|
healthcheck_url = "https://example.com/ping/x"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_qbit_maid(as_main=False):
|
||||||
|
"""Import qbit-maid.py by path, with qbittorrentapi stubbed.
|
||||||
|
|
||||||
|
With ``as_main=True`` the module is executed as ``__main__``, which runs
|
||||||
|
the ``Qbt()`` entry point at import time.
|
||||||
|
"""
|
||||||
|
# Install the stub once and reuse it, so tests can patch its Client.
|
||||||
|
# This keeps the suite off any real qBittorrent even when the real package
|
||||||
|
# is installed in the environment.
|
||||||
|
stub = sys.modules.get("qbittorrentapi")
|
||||||
|
if getattr(stub, "_qbit_maid_stub", False) is not True:
|
||||||
|
stub = types.ModuleType("qbittorrentapi")
|
||||||
|
stub.APIError = FakeAPIError
|
||||||
|
stub.Client = FakeQbtClient
|
||||||
|
stub._qbit_maid_stub = True
|
||||||
|
sys.modules["qbittorrentapi"] = stub
|
||||||
|
if REPO not in sys.path:
|
||||||
|
sys.path.insert(0, REPO)
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"__main__" if as_main else "qbit_maid", os.path.join(REPO, "qbit-maid.py")
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class QbtTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
|
self._cwd = os.getcwd()
|
||||||
|
os.chdir(self.tmp)
|
||||||
|
self.addCleanup(os.chdir, self._cwd)
|
||||||
|
self.addCleanup(self._close_log_handlers)
|
||||||
|
self.module = load_qbit_maid()
|
||||||
|
|
||||||
|
def _close_log_handlers(self):
|
||||||
|
logger = logging.getLogger("qbit-maid")
|
||||||
|
for handler in list(logger.handlers):
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
def write_config(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"log_level": "CRITICAL", # keep the console handler quiet
|
||||||
|
"log_path": os.path.join(self.tmp, "qc.log"),
|
||||||
|
"delete_torrents": "true",
|
||||||
|
"use_pushover": "false",
|
||||||
|
"use_apprise": "false",
|
||||||
|
"use_healthcheck": "false",
|
||||||
|
"dragnet_outfile": os.path.join(self.tmp, "orphaned.csv"),
|
||||||
|
"telemetry_outfile": os.path.join(self.tmp, "telemetry.csv"),
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
with open("config.toml", "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(CONFIG_TEMPLATE.format(**values))
|
||||||
|
|
||||||
|
def build(self, torrents, client=None, **config_overrides):
|
||||||
|
self.write_config(**config_overrides)
|
||||||
|
client = client or FakeQbtClient(torrents=torrents)
|
||||||
|
|
||||||
|
def factory(**kwargs):
|
||||||
|
client.init_kwargs = kwargs
|
||||||
|
return client
|
||||||
|
|
||||||
|
with mock.patch.object(self.module.qbittorrentapi, "Client",
|
||||||
|
side_effect=factory):
|
||||||
|
qbt = self.module.Qbt()
|
||||||
|
return qbt, client
|
||||||
|
|
||||||
|
|
||||||
|
class TestQbtPipeline(QbtTestCase):
|
||||||
|
def sample_torrents(self):
|
||||||
|
return [
|
||||||
|
torrent(tags="ipt", ratio=0.5, time_active=9999999,
|
||||||
|
infohash="del-protected"),
|
||||||
|
torrent(tags="public", tracker="https://other.example/a", ratio=0.1,
|
||||||
|
infohash="del-public"),
|
||||||
|
torrent(tags="public", state="downloading", infohash="skip-downloading"),
|
||||||
|
torrent(tags="other", infohash="orphan-1"),
|
||||||
|
torrent(tracker="", infohash="ignored-blank-tracker"),
|
||||||
|
torrent(category="tech", infohash="ignored-category"),
|
||||||
|
torrent(tags="ipt", seeding_time=1, infohash="premature"),
|
||||||
|
torrent(tags="", infohash="new-blank-tag"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_full_run_counts_every_bucket(self):
|
||||||
|
qbt, client = self.build(self.sample_torrents())
|
||||||
|
self.assertEqual(qbt.total_torrents, 8)
|
||||||
|
self.assertEqual(qbt.ignored_counter, 2)
|
||||||
|
self.assertEqual(qbt.preme_tor_counter, 1)
|
||||||
|
self.assertEqual(qbt.up_tor_counter, 2)
|
||||||
|
self.assertCountEqual(qbt.torrent_hash_delete_list,
|
||||||
|
["del-protected", "del-public"])
|
||||||
|
|
||||||
|
def test_full_run_deletes_the_selected_hashes(self):
|
||||||
|
_, client = self.build(self.sample_torrents())
|
||||||
|
self.assertEqual(len(client.deleted), 1)
|
||||||
|
delete_files, hashes = client.deleted[0]
|
||||||
|
self.assertTrue(delete_files)
|
||||||
|
self.assertCountEqual(hashes, ["del-protected", "del-public"])
|
||||||
|
|
||||||
|
def test_full_run_tags_new_torrents(self):
|
||||||
|
_, client = self.build(self.sample_torrents())
|
||||||
|
self.assertIn(("ipt", "new-blank-tag"), client.added_tags)
|
||||||
|
|
||||||
|
def test_dry_run_does_not_delete(self):
|
||||||
|
qbt, client = self.build(self.sample_torrents(), delete_torrents="false")
|
||||||
|
self.assertFalse(qbt.delete_torrents)
|
||||||
|
self.assertEqual(client.deleted, [])
|
||||||
|
self.assertCountEqual(qbt.torrent_hash_delete_list,
|
||||||
|
["del-protected", "del-public"])
|
||||||
|
|
||||||
|
def test_client_is_constructed_from_config(self):
|
||||||
|
_, client = self.build([torrent(tags="ipt")])
|
||||||
|
self.assertEqual(client.init_kwargs["host"], "127.0.0.1")
|
||||||
|
self.assertEqual(client.init_kwargs["port"], 8080)
|
||||||
|
self.assertEqual(client.init_kwargs["username"], "user")
|
||||||
|
|
||||||
|
def test_client_logs_in(self):
|
||||||
|
_, client = self.build([torrent(tags="ipt")])
|
||||||
|
self.assertTrue(client.logged_in)
|
||||||
|
|
||||||
|
def test_runtime_is_recorded(self):
|
||||||
|
qbt, _ = self.build([torrent(tags="ipt")])
|
||||||
|
self.assertIsNotNone(qbt.et)
|
||||||
|
self.assertGreaterEqual(qbt.et, qbt.st)
|
||||||
|
|
||||||
|
def test_empty_torrent_list_crashes(self):
|
||||||
|
"""KNOWN BUG: list_first_tor() indexes torrent_list[0] unconditionally,
|
||||||
|
so a qBittorrent instance with zero torrents raises IndexError and the
|
||||||
|
whole run aborts before any summary is sent.
|
||||||
|
"""
|
||||||
|
with self.assertRaises(IndexError):
|
||||||
|
self.build([])
|
||||||
|
|
||||||
|
|
||||||
|
class TestQbtConfigSources(QbtTestCase):
|
||||||
|
def test_config_is_read_from_toml_path_env(self):
|
||||||
|
config_path = os.path.join(self.tmp, "from-env.toml")
|
||||||
|
self.write_config()
|
||||||
|
os.replace("config.toml", config_path)
|
||||||
|
os.environ["toml_path"] = config_path
|
||||||
|
self.addCleanup(os.environ.pop, "toml_path", None)
|
||||||
|
with mock.patch.object(self.module.qbittorrentapi, "Client",
|
||||||
|
return_value=FakeQbtClient(torrents=[torrent(tags="ipt")])):
|
||||||
|
qbt = self.module.Qbt()
|
||||||
|
self.assertEqual(qbt.tracker_protected_tag, "ipt")
|
||||||
|
|
||||||
|
def test_local_config_toml_wins_over_toml_path(self):
|
||||||
|
elsewhere = os.path.join(self.tmp, "elsewhere.toml")
|
||||||
|
with open(elsewhere, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(CONFIG_TEMPLATE.format(
|
||||||
|
log_level="CRITICAL",
|
||||||
|
log_path=os.path.join(self.tmp, "other.log"),
|
||||||
|
delete_torrents="true", use_pushover="false",
|
||||||
|
use_apprise="false", use_healthcheck="false",
|
||||||
|
dragnet_outfile="a.csv", telemetry_outfile="b.csv",
|
||||||
|
).replace('protected_tag = "ipt"', 'protected_tag = "from-elsewhere"'))
|
||||||
|
os.environ["toml_path"] = elsewhere
|
||||||
|
self.addCleanup(os.environ.pop, "toml_path", None)
|
||||||
|
self.write_config()
|
||||||
|
with mock.patch.object(self.module.qbittorrentapi, "Client",
|
||||||
|
return_value=FakeQbtClient(torrents=[torrent(tags="ipt")])):
|
||||||
|
qbt = self.module.Qbt()
|
||||||
|
self.assertEqual(qbt.tracker_protected_tag, "ipt")
|
||||||
|
|
||||||
|
|
||||||
|
class TestQbtNotifiers(QbtTestCase):
|
||||||
|
def test_pushover_summary_is_sent_when_enabled(self):
|
||||||
|
stub = StubPushover()
|
||||||
|
with mock.patch.object(self.module.pushover, "Pushover",
|
||||||
|
return_value=stub):
|
||||||
|
qbt, _ = self.build([torrent(tags="public", infohash="h1")],
|
||||||
|
use_pushover="true")
|
||||||
|
self.assertEqual(len(stub.calls), 1)
|
||||||
|
self.assertEqual(stub.calls[0]["kwargs"]["title"],
|
||||||
|
"qbit-maid: 1 torrent removed")
|
||||||
|
|
||||||
|
def test_apprise_is_sent_when_enabled(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_apprise(req_obj, host, port, aurls, title, body):
|
||||||
|
captured.update(host=host, port=port, title=title, body=body)
|
||||||
|
|
||||||
|
with mock.patch.object(self.module, "apprise_notify", fake_apprise):
|
||||||
|
self.build([torrent(tags="public", infohash="h1")],
|
||||||
|
use_apprise="true")
|
||||||
|
self.assertEqual(captured["host"], "localhost")
|
||||||
|
self.assertEqual(captured["port"], 8088)
|
||||||
|
self.assertIn("1 removed", captured["body"])
|
||||||
|
|
||||||
|
def test_healthcheck_is_pinged_twice_when_enabled(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_ping(obj, req_obj, url):
|
||||||
|
calls.append(url)
|
||||||
|
|
||||||
|
with mock.patch.object(self.module, "send_ping", fake_ping):
|
||||||
|
self.build([torrent(tags="ipt")], use_healthcheck="true")
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
self.assertTrue(calls[0].endswith("/start"))
|
||||||
|
self.assertFalse(calls[1].endswith("/start"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestQbtLoginFailure(QbtTestCase):
|
||||||
|
def test_login_error_notifies_and_continues(self):
|
||||||
|
client = FakeQbtClient(torrents=[torrent(tags="ipt")],
|
||||||
|
login_error=FakeAPIError("bad credentials"))
|
||||||
|
with mock.patch.object(self.module.pushover, "send_message",
|
||||||
|
create=True) as send_message:
|
||||||
|
qbt, _ = self.build([torrent(tags="ipt")], client=client)
|
||||||
|
send_message.assert_called_once()
|
||||||
|
self.assertEqual(send_message.call_args.kwargs["title"],
|
||||||
|
"qbit-maid API ERROR")
|
||||||
|
self.assertEqual(qbt.total_torrents, 1)
|
||||||
|
|
||||||
|
def test_login_error_crashes_without_a_send_message_helper(self):
|
||||||
|
"""KNOWN BUG: the vendored pushover module has no module-level
|
||||||
|
``send_message``, so the error path raises AttributeError instead of
|
||||||
|
notifying anyone.
|
||||||
|
"""
|
||||||
|
client = FakeQbtClient(torrents=[torrent(tags="ipt")],
|
||||||
|
login_error=FakeAPIError("bad credentials"))
|
||||||
|
with self.assertRaises(AttributeError):
|
||||||
|
self.build([torrent(tags="ipt")], client=client)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMainGuard(QbtTestCase):
|
||||||
|
def test_running_as_main_executes_the_pipeline(self):
|
||||||
|
self.write_config()
|
||||||
|
client = FakeQbtClient(torrents=[torrent(tags="ipt")])
|
||||||
|
stub = sys.modules["qbittorrentapi"]
|
||||||
|
with mock.patch.object(stub, "Client", return_value=client):
|
||||||
|
load_qbit_maid(as_main=True)
|
||||||
|
self.assertTrue(client.logged_in)
|
||||||
|
self.assertEqual(len(client.deleted), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIgnoredTagsConfig(QbtTestCase):
|
||||||
|
def test_ignored_tags_reads_the_ignored_domains_section(self):
|
||||||
|
"""KNOWN BUG: qbit-maid.py assigns ``ignored_tags`` from
|
||||||
|
``config["ignored_domains"]``, so the ``[ignored_tags]`` section is
|
||||||
|
never read and tag-based ignoring silently does nothing.
|
||||||
|
"""
|
||||||
|
qbt, _ = self.build([torrent(tags="ipt")])
|
||||||
|
self.assertEqual(qbt.ignored_tags, {"tracker-example": "tracker.example"})
|
||||||
|
self.assertNotIn("save", qbt.ignored_tags)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
"""Tests for qlist.build_tor_list — the tagging and sorting pass."""
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from qbt_fakes import FakeQbt, torrent
|
||||||
|
from qlist import build_tor_list
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildTorList(unittest.TestCase):
|
||||||
|
def run_build(self, torrents, **overrides):
|
||||||
|
qbt = FakeQbt(torrent_list=list(torrents), **overrides).reset_counters()
|
||||||
|
build_tor_list(qbt)
|
||||||
|
return qbt
|
||||||
|
|
||||||
|
def test_sets_total_torrents(self):
|
||||||
|
qbt = self.run_build([torrent(infohash="a"), torrent(infohash="b")])
|
||||||
|
self.assertEqual(qbt.total_torrents, 2)
|
||||||
|
|
||||||
|
def test_blank_tracker_is_ignored(self):
|
||||||
|
qbt = self.run_build([torrent(tracker="")])
|
||||||
|
self.assertEqual(qbt.ignored_counter, 1)
|
||||||
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
def test_ignored_category_is_ignored(self):
|
||||||
|
qbt = self.run_build(
|
||||||
|
[torrent(category="tech")],
|
||||||
|
cat_whitelist={"tech": "tech"},
|
||||||
|
)
|
||||||
|
self.assertEqual(qbt.ignored_counter, 1)
|
||||||
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
def test_ignored_tag_is_ignored(self):
|
||||||
|
qbt = self.run_build(
|
||||||
|
[torrent(tags="save,public")],
|
||||||
|
ignored_tags={"save": "save"},
|
||||||
|
)
|
||||||
|
self.assertEqual(qbt.ignored_counter, 1)
|
||||||
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
def test_blank_tag_on_protected_tracker_gets_protected_tag(self):
|
||||||
|
qbt = self.run_build([torrent(tags="", infohash="h1")])
|
||||||
|
self.assertIn(("ipt", "h1"), qbt.qbt_client.added_tags)
|
||||||
|
self.assertEqual(len(qbt.tracker_list), 1)
|
||||||
|
|
||||||
|
def test_blank_tag_on_non_protected_tracker_gets_public_tag(self):
|
||||||
|
qbt = self.run_build(
|
||||||
|
[torrent(tags="", tracker="https://other.example/announce", infohash="h2")]
|
||||||
|
)
|
||||||
|
self.assertIn(("public", "h2"), qbt.qbt_client.added_tags)
|
||||||
|
self.assertEqual(len(qbt.tracker_list), 1)
|
||||||
|
|
||||||
|
def test_blank_tag_is_applied_twice_for_non_premature_torrents(self):
|
||||||
|
"""KNOWN REDUNDANCY: the tagging block runs twice for the same torrent.
|
||||||
|
|
||||||
|
Lines 24-29 tag any blank-tag torrent; lines 34-43 tag it again inside
|
||||||
|
the tracker branch. qBittorrent treats the second call as a no-op, so
|
||||||
|
the outcome is correct but one API call per new torrent is wasted.
|
||||||
|
Pinned here so the duplication is visible rather than silent.
|
||||||
|
"""
|
||||||
|
qbt = self.run_build([torrent(tags="", infohash="h1")])
|
||||||
|
self.assertEqual(qbt.qbt_client.added_tags, [("ipt", "h1"), ("ipt", "h1")])
|
||||||
|
|
||||||
|
def test_premature_blank_tag_torrent_is_tagged_once(self):
|
||||||
|
"""Premature torrents are tagged by the first block, then skipped."""
|
||||||
|
qbt = self.run_build([torrent(tags="", seeding_time=1, infohash="h3")])
|
||||||
|
self.assertEqual(qbt.qbt_client.added_tags, [("ipt", "h3")])
|
||||||
|
self.assertEqual(qbt.preme_tor_counter, 1)
|
||||||
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
def test_premature_torrent_is_counted_and_not_queued(self):
|
||||||
|
qbt = self.run_build([torrent(seeding_time=10, tags="ipt")])
|
||||||
|
self.assertEqual(qbt.preme_tor_counter, 1)
|
||||||
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
def test_protected_torrent_with_tag_is_queued_without_retagging(self):
|
||||||
|
qbt = self.run_build([torrent(tags="ipt")])
|
||||||
|
self.assertEqual(qbt.qbt_client.added_tags, [])
|
||||||
|
self.assertEqual(len(qbt.tracker_list), 1)
|
||||||
|
|
||||||
|
def test_non_protected_torrent_with_tag_is_queued_without_retagging(self):
|
||||||
|
qbt = self.run_build(
|
||||||
|
[torrent(tags="public", tracker="https://other.example/announce")]
|
||||||
|
)
|
||||||
|
self.assertEqual(qbt.qbt_client.added_tags, [])
|
||||||
|
self.assertEqual(len(qbt.tracker_list), 1)
|
||||||
|
|
||||||
|
def test_mixed_batch_counts_each_bucket(self):
|
||||||
|
qbt = self.run_build([
|
||||||
|
torrent(tracker=""), # ignored
|
||||||
|
torrent(category="tech"), # ignored
|
||||||
|
torrent(tags="save"), # ignored
|
||||||
|
torrent(seeding_time=1, tags="ipt"), # premature
|
||||||
|
torrent(tags="ipt", infohash="p1"), # queued
|
||||||
|
torrent(tags="public", tracker="https://o.example/a",
|
||||||
|
infohash="n1"), # queued
|
||||||
|
], cat_whitelist={"tech": "tech"}, ignored_tags={"save": "save"})
|
||||||
|
self.assertEqual(qbt.ignored_counter, 3)
|
||||||
|
self.assertEqual(qbt.preme_tor_counter, 1)
|
||||||
|
self.assertEqual(len(qbt.tracker_list), 2)
|
||||||
|
self.assertEqual(qbt.total_torrents, 6)
|
||||||
|
|
||||||
|
def test_consumes_the_input_list(self):
|
||||||
|
qbt = self.run_build([torrent(tags="ipt")])
|
||||||
|
self.assertEqual(qbt.torrent_list, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Tests for the qlogging helpers that the notifier tests do not reach."""
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import warnings
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from qbt_fakes import FakeQbt, StubPushover, torrent
|
||||||
|
from qlogging import (
|
||||||
|
LOGGER_NAME,
|
||||||
|
debug_torrent_list,
|
||||||
|
format_runtime,
|
||||||
|
get_script_runtime,
|
||||||
|
list_first_tor,
|
||||||
|
list_qbit_api_info,
|
||||||
|
log_torrent,
|
||||||
|
print_processor,
|
||||||
|
process_counts,
|
||||||
|
send_ping,
|
||||||
|
setup_logging,
|
||||||
|
summary_runtime,
|
||||||
|
summary_stats,
|
||||||
|
tor_notify,
|
||||||
|
tor_notify_summary,
|
||||||
|
tornotifytest,
|
||||||
|
torrent_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakePushoverModule:
|
||||||
|
"""Stands in for the vendored ``pushover`` module."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.created = []
|
||||||
|
|
||||||
|
def Pushover(self, token): # noqa: N802 - mirrors the library's name
|
||||||
|
self.created.append(token)
|
||||||
|
return StubPushover()
|
||||||
|
|
||||||
|
|
||||||
|
class FailingRequests:
|
||||||
|
class RequestException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def get(self, url, timeout=None):
|
||||||
|
self.calls.append(url)
|
||||||
|
raise self.RequestException("boom")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifySetup(unittest.TestCase):
|
||||||
|
def test_tor_notify_creates_client_when_enabled(self):
|
||||||
|
qbt = FakeQbt(use_pushover=True, po_token="TOKEN")
|
||||||
|
qbt.po = FakePushoverModule()
|
||||||
|
tor_notify(qbt)
|
||||||
|
self.assertEqual(qbt.po.created, ["TOKEN"])
|
||||||
|
self.assertIsInstance(qbt.poc, StubPushover)
|
||||||
|
|
||||||
|
def test_tor_notify_is_a_noop_when_disabled(self):
|
||||||
|
qbt = FakeQbt(use_pushover=False)
|
||||||
|
qbt.po = FakePushoverModule()
|
||||||
|
tor_notify(qbt)
|
||||||
|
self.assertEqual(qbt.po.created, [])
|
||||||
|
|
||||||
|
def test_tornotifytest_sends_a_test_message(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
tornotifytest(qbt)
|
||||||
|
self.assertEqual(len(qbt.poc.calls), 1)
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["user"], "USERKEY")
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["body"], "Test Message")
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["kwargs"]["title"], "qbit-maid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCounters(unittest.TestCase):
|
||||||
|
def test_process_counts_buckets_by_tag(self):
|
||||||
|
qbt = FakeQbt(
|
||||||
|
ct=Counter,
|
||||||
|
tracker_list=[torrent(tags="ipt"), torrent(tags="ipt"), torrent(tags="public")],
|
||||||
|
)
|
||||||
|
process_counts(qbt)
|
||||||
|
self.assertEqual(qbt.c["ipt"], 2)
|
||||||
|
self.assertEqual(qbt.c["public"], 1)
|
||||||
|
|
||||||
|
def test_print_processor_logs_every_counter(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
with self.assertLogs("qbit-maid-test", level="INFO") as captured:
|
||||||
|
print_processor(qbt)
|
||||||
|
joined = "\n".join(captured.output)
|
||||||
|
for label in ("Total", "Premature", "Ignored", "Protected",
|
||||||
|
"Non-protected", "Orphaned", "Marked for deletion"):
|
||||||
|
self.assertIn(label, joined)
|
||||||
|
|
||||||
|
def test_torrent_count_logs_both_tags(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
|
torrent_count(qbt)
|
||||||
|
joined = "\n".join(captured.output)
|
||||||
|
self.assertIn('tag["ipt"] 10', joined)
|
||||||
|
self.assertIn('tag["public"] 20', joined)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDebugHelpers(unittest.TestCase):
|
||||||
|
def test_list_first_tor_logs_each_field(self):
|
||||||
|
qbt = FakeQbt(torrent_list=[torrent(name="First")])
|
||||||
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
|
list_first_tor(qbt)
|
||||||
|
self.assertIn("First", "\n".join(captured.output))
|
||||||
|
|
||||||
|
def test_list_first_tor_honours_the_index(self):
|
||||||
|
qbt = FakeQbt(torrent_list=[torrent(name="Zero"), torrent(name="One")])
|
||||||
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
|
list_first_tor(qbt, index=1)
|
||||||
|
self.assertIn("One", "\n".join(captured.output))
|
||||||
|
|
||||||
|
def test_list_qbit_api_info_logs_versions(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
|
list_qbit_api_info(qbt)
|
||||||
|
joined = "\n".join(captured.output)
|
||||||
|
self.assertIn("v4.6.2", joined)
|
||||||
|
self.assertIn("2.9.3", joined)
|
||||||
|
|
||||||
|
def test_debug_torrent_list_logs_the_list(self):
|
||||||
|
qbt = FakeQbt(torrent_list=[torrent(name="Logged")])
|
||||||
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
|
debug_torrent_list(qbt)
|
||||||
|
self.assertIn("Logged", "\n".join(captured.output))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntime(unittest.TestCase):
|
||||||
|
def test_get_script_runtime_sets_extm_for_pushover(self):
|
||||||
|
qbt = FakeQbt(use_pushover=True, use_apprise=False)
|
||||||
|
get_script_runtime(qbt)
|
||||||
|
self.assertEqual(qbt.extm, "Execution time: [0:00:12]")
|
||||||
|
|
||||||
|
def test_get_script_runtime_sets_extm_for_apprise(self):
|
||||||
|
qbt = FakeQbt(use_pushover=False, use_apprise=True)
|
||||||
|
get_script_runtime(qbt)
|
||||||
|
self.assertEqual(qbt.extm, "Execution time: [0:00:12]")
|
||||||
|
|
||||||
|
def test_get_script_runtime_without_notifiers_leaves_extm_alone(self):
|
||||||
|
qbt = FakeQbt(use_pushover=False, use_apprise=False, extm="")
|
||||||
|
get_script_runtime(qbt)
|
||||||
|
self.assertEqual(qbt.extm, "")
|
||||||
|
|
||||||
|
def test_format_runtime_falls_back_to_str_for_non_timedelta(self):
|
||||||
|
self.assertEqual(format_runtime(12), "12")
|
||||||
|
self.assertEqual(format_runtime("n/a"), "n/a")
|
||||||
|
|
||||||
|
def test_summary_runtime_falls_back_to_extm(self):
|
||||||
|
class OnlyExtm:
|
||||||
|
extm = "Execution time: [5s]"
|
||||||
|
|
||||||
|
self.assertEqual(summary_runtime(OnlyExtm()), "5s")
|
||||||
|
|
||||||
|
def test_summary_runtime_returns_unknown_without_any_source(self):
|
||||||
|
class Nothing:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(summary_runtime(Nothing()), "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSummaryStatsEdgeCases(unittest.TestCase):
|
||||||
|
def test_missing_tag_returns_zero(self):
|
||||||
|
qbt = FakeQbt(c={})
|
||||||
|
stats = summary_stats(qbt)
|
||||||
|
self.assertEqual(stats["protected"], 0)
|
||||||
|
self.assertEqual(stats["non_protected"], 0)
|
||||||
|
|
||||||
|
def test_unindexable_counter_returns_zero(self):
|
||||||
|
qbt = FakeQbt(c=[])
|
||||||
|
stats = summary_stats(qbt)
|
||||||
|
self.assertEqual(stats["protected"], 0)
|
||||||
|
|
||||||
|
def test_blank_tag_returns_zero(self):
|
||||||
|
qbt = FakeQbt(tracker_protected_tag="", tracker_non_protected_tag="")
|
||||||
|
stats = summary_stats(qbt)
|
||||||
|
self.assertEqual(stats["protected"], 0)
|
||||||
|
self.assertEqual(stats["non_protected"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendPing(unittest.TestCase):
|
||||||
|
def test_send_ping_swallows_request_errors(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
failing = FailingRequests()
|
||||||
|
send_ping(qbt, failing, "https://example.com/ping/x") # must not raise
|
||||||
|
self.assertEqual(failing.calls, ["https://example.com/ping/x"])
|
||||||
|
|
||||||
|
def test_send_ping_logs_the_failure(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
with self.assertLogs("qbit-maid-test", level="INFO") as captured:
|
||||||
|
send_ping(qbt, FailingRequests(), "https://example.com/ping/x")
|
||||||
|
self.assertIn("Ping failed", "\n".join(captured.output))
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupLogging(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
|
self.log_path = os.path.join(self.tmp, "qc.log")
|
||||||
|
self.addCleanup(self._reset_logger)
|
||||||
|
|
||||||
|
def _reset_logger(self):
|
||||||
|
logger = logging.getLogger(LOGGER_NAME)
|
||||||
|
for handler in list(logger.handlers):
|
||||||
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
def test_disabled_logging_uses_a_null_handler(self):
|
||||||
|
logger = setup_logging(False, self.log_path, "INFO")
|
||||||
|
self.assertEqual(logger.level, logging.CRITICAL)
|
||||||
|
self.assertEqual(len(logger.handlers), 1)
|
||||||
|
self.assertIsInstance(logger.handlers[0], logging.NullHandler)
|
||||||
|
self.assertFalse(os.path.exists(self.log_path))
|
||||||
|
|
||||||
|
def test_enabled_logging_writes_to_file_and_console(self):
|
||||||
|
logger = setup_logging(True, self.log_path, "DEBUG")
|
||||||
|
self.assertEqual(logger.level, logging.DEBUG)
|
||||||
|
self.assertEqual(len(logger.handlers), 2)
|
||||||
|
logger.info("hello from the test")
|
||||||
|
for handler in logger.handlers:
|
||||||
|
handler.flush()
|
||||||
|
with open(self.log_path, encoding="utf-8") as fh:
|
||||||
|
self.assertIn("hello from the test", fh.read())
|
||||||
|
|
||||||
|
def test_unknown_level_falls_back_to_info(self):
|
||||||
|
logger = setup_logging(True, self.log_path, "shouty")
|
||||||
|
self.assertEqual(logger.level, logging.INFO)
|
||||||
|
|
||||||
|
def test_setup_is_idempotent(self):
|
||||||
|
setup_logging(True, self.log_path, "INFO")
|
||||||
|
# setup_logging drops the previous handlers without closing them, so a
|
||||||
|
# second call leaks the old file descriptor. Known issue; silenced here
|
||||||
|
# so the suite output stays readable.
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", ResourceWarning)
|
||||||
|
logger = setup_logging(True, self.log_path, "INFO")
|
||||||
|
self.assertEqual(len(logger.handlers), 2)
|
||||||
|
|
||||||
|
def test_rotation_parameters_are_applied(self):
|
||||||
|
logger = setup_logging(True, self.log_path, "INFO",
|
||||||
|
max_bytes=1234, backup_count=7)
|
||||||
|
file_handler = logger.handlers[0]
|
||||||
|
self.assertEqual(file_handler.maxBytes, 1234)
|
||||||
|
self.assertEqual(file_handler.backupCount, 7)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogTorrent(unittest.TestCase):
|
||||||
|
def test_log_torrent_attaches_structured_fields(self):
|
||||||
|
logger = logging.getLogger("qbit-maid-test")
|
||||||
|
previous_level = logger.level
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
self.addCleanup(logger.setLevel, previous_level)
|
||||||
|
record = {}
|
||||||
|
|
||||||
|
class Capture(logging.Handler):
|
||||||
|
def emit(self, rec):
|
||||||
|
record.update(rec.__dict__)
|
||||||
|
|
||||||
|
handler = Capture()
|
||||||
|
logger.addHandler(handler)
|
||||||
|
self.addCleanup(logger.removeHandler, handler)
|
||||||
|
|
||||||
|
log_torrent(logger, logging.INFO, torrent(name="A" * 40, infohash="deadbeef"),
|
||||||
|
message="processing")
|
||||||
|
self.assertEqual(record["torrent_hash"], "deadbeef")
|
||||||
|
self.assertEqual(record["torrent_name"], "A" * 20)
|
||||||
|
self.assertEqual(record["msg"], "processing")
|
||||||
|
|
||||||
|
def test_log_torrent_tolerates_missing_fields(self):
|
||||||
|
logger = logging.getLogger("qbit-maid-test")
|
||||||
|
log_torrent(logger, logging.INFO, {}) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifySummarySound(unittest.TestCase):
|
||||||
|
def test_sound_is_passed_through(self):
|
||||||
|
qbt = FakeQbt(config={"pushover": {"sound": "siren"}})
|
||||||
|
tor_notify_summary(qbt)
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["kwargs"]["sound"], "siren")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Tests for qprocess — the deletion decision pass and the delete calls."""
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from qbt_fakes import FakeQbt, torrent
|
||||||
|
from qprocess import tor_delete, tor_delete_tags, tor_processor
|
||||||
|
|
||||||
|
|
||||||
|
class TestTorProcessor(unittest.TestCase):
|
||||||
|
def run_processor(self, candidates, **overrides):
|
||||||
|
qbt = FakeQbt(tracker_list=list(candidates), cv=csv, **overrides).reset_counters()
|
||||||
|
tor_processor(qbt)
|
||||||
|
return qbt
|
||||||
|
|
||||||
|
def test_downloading_torrent_is_skipped(self):
|
||||||
|
qbt = self.run_processor([torrent(state="downloading", tags="public")])
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, [])
|
||||||
|
self.assertEqual(qbt.up_tor_counter, 0)
|
||||||
|
|
||||||
|
def test_protected_under_ratio_and_old_is_deleted(self):
|
||||||
|
qbt = self.run_processor([
|
||||||
|
torrent(tags="ipt", ratio=0.5, time_active=9999999, infohash="old1"),
|
||||||
|
])
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, ["old1"])
|
||||||
|
|
||||||
|
def test_protected_under_ratio_but_young_is_kept(self):
|
||||||
|
qbt = self.run_processor([
|
||||||
|
torrent(tags="ipt", ratio=0.5, time_active=1, infohash="young1"),
|
||||||
|
])
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, [])
|
||||||
|
self.assertEqual(qbt.up_tor_counter, 0)
|
||||||
|
|
||||||
|
def test_protected_over_ratio_is_deleted(self):
|
||||||
|
qbt = self.run_processor([
|
||||||
|
torrent(tags="ipt", ratio=2.0, infohash="over1"),
|
||||||
|
])
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, ["over1"])
|
||||||
|
|
||||||
|
def test_non_protected_is_deleted(self):
|
||||||
|
qbt = self.run_processor([
|
||||||
|
torrent(tags="public", ratio=0.1, infohash="np1"),
|
||||||
|
])
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, ["np1"])
|
||||||
|
|
||||||
|
def test_unmatched_torrent_is_orphaned(self):
|
||||||
|
qbt = self.run_processor([torrent(tags="other", ratio=0.1)])
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, [])
|
||||||
|
self.assertEqual(qbt.up_tor_counter, 1)
|
||||||
|
|
||||||
|
def test_telemetry_writes_a_row_per_candidate(self):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
|
||||||
|
outfile = os.path.join(tmp, "telemetry.csv")
|
||||||
|
qbt = self.run_processor(
|
||||||
|
[torrent(tags="public", infohash="t1"), torrent(tags="public", infohash="t2")],
|
||||||
|
enable_telemetry=True,
|
||||||
|
telemetry_outfile=outfile,
|
||||||
|
)
|
||||||
|
with open(outfile, newline="", encoding="UTF8") as fh:
|
||||||
|
rows = list(csv.reader(fh))
|
||||||
|
self.assertEqual(rows[0], ["state", "ratio", "tags", "added", "hash", "name", "tracker"])
|
||||||
|
self.assertEqual(len(rows), 3) # header + 2 candidates
|
||||||
|
self.assertEqual(qbt.torrent_hash_delete_list, ["t1", "t2"])
|
||||||
|
|
||||||
|
def test_dragnet_writes_orphans_only(self):
|
||||||
|
tmp = tempfile.mkdtemp()
|
||||||
|
self.addCleanup(shutil.rmtree, tmp, ignore_errors=True)
|
||||||
|
outfile = os.path.join(tmp, "orphaned.csv")
|
||||||
|
qbt = self.run_processor(
|
||||||
|
[torrent(tags="other", infohash="orph1"), torrent(tags="public", infohash="del1")],
|
||||||
|
enable_dragnet=True,
|
||||||
|
dragnet_outfile=outfile,
|
||||||
|
)
|
||||||
|
with open(outfile, newline="", encoding="UTF8") as fh:
|
||||||
|
rows = list(csv.reader(fh))
|
||||||
|
self.assertEqual(rows[0], ["state", "ratio", "tags", "added", "thash", "tname", "trname"])
|
||||||
|
self.assertEqual(len(rows), 2) # header + the single orphan
|
||||||
|
self.assertEqual(rows[1][4], "orph1")
|
||||||
|
self.assertEqual(qbt.up_tor_counter, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteCalls(unittest.TestCase):
|
||||||
|
def test_delete_tags_removes_both_tags(self):
|
||||||
|
qbt = FakeQbt()
|
||||||
|
tor_delete_tags(qbt)
|
||||||
|
self.assertEqual(qbt.qbt_client.deleted_tags, [["ipt", "public"]])
|
||||||
|
|
||||||
|
def test_delete_removes_files_for_each_hash(self):
|
||||||
|
qbt = FakeQbt(torrent_hash_delete_list=["h1", "h2"])
|
||||||
|
tor_delete(qbt)
|
||||||
|
self.assertEqual(qbt.qbt_client.deleted, [(True, ["h1", "h2"])])
|
||||||
|
|
||||||
|
def test_delete_is_a_noop_on_empty_list(self):
|
||||||
|
qbt = FakeQbt(torrent_hash_delete_list=[])
|
||||||
|
tor_delete(qbt)
|
||||||
|
self.assertEqual(qbt.qbt_client.deleted, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+44
-35
@@ -1,41 +1,50 @@
|
|||||||
from qprocess import write_csv
|
"""Tests for qprocess.write_csv — header-once, append semantics."""
|
||||||
import csv
|
import csv
|
||||||
import unittest
|
|
||||||
import os
|
import os
|
||||||
# Torrent Items needed for testing
|
import shutil
|
||||||
# canidate['state'] {canidate["ratio"]} {torrent["tags"]} torrent["seeding_time"] {torrent["category"]} {torrent["name"][0:20]} {canidate["time_active"]}
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from qprocess import write_csv
|
||||||
|
|
||||||
|
HEADER = ["state", "ratio", "tags", "added", "hash", "name", "tracker"]
|
||||||
|
ROW = ["downloading", 1.05, "ipt", 1, "asfasdf23412adfqwer",
|
||||||
|
"thisismynamehahahah", "https://localhost.stackoverflow.tech/announce"]
|
||||||
|
|
||||||
|
|
||||||
class TestWriteCSV(unittest.TestCase):
|
class TestWriteCSV(unittest.TestCase):
|
||||||
def test_write_csv_dragnet(self):
|
def setUp(self):
|
||||||
self.cv = csv
|
self.tmp = tempfile.mkdtemp()
|
||||||
outfile = './test_dragnet_outfile.csv'
|
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
|
||||||
state = 'downloading'
|
self.outfile = os.path.join(self.tmp, "out.csv")
|
||||||
ratio = 1.05
|
|
||||||
tags = 'ipt'
|
|
||||||
added = 1
|
|
||||||
age = 240000
|
|
||||||
time = 123456
|
|
||||||
thash = 'asfasdf23412adfqwer'
|
|
||||||
tname = 'thisismynamehahahah'
|
|
||||||
trname = 'https://localhost.stackoverflow.tech/317332f1c125bc9c1b9b14fb8e054908/announce'
|
|
||||||
header = ['state','ratio','tags','added','age','time','thash','tname','trname']
|
|
||||||
row = [state,ratio,tags,added,age,time,thash,tname,trname]
|
|
||||||
write_csv(self.cv,outfile,header,row)
|
|
||||||
self.assertTrue(os.path.exists(outfile))
|
|
||||||
|
|
||||||
def test_write_csv_telemetry(self):
|
def read_rows(self):
|
||||||
self.cv = csv
|
with open(self.outfile, newline="", encoding="UTF8") as fh:
|
||||||
outfile = './test_telemetry_outfile.csv'
|
return list(csv.reader(fh))
|
||||||
state = 'downloading'
|
|
||||||
ratio = 1.05
|
|
||||||
tags = 'ipt'
|
|
||||||
added = 1
|
|
||||||
thash = 'asfasdf23412adfqwer'
|
|
||||||
tname = 'thisismynamehahahah'
|
|
||||||
trname = 'https://localhost.stackoverflow.tech/317332f1c125bc9c1b9b14fb8e054908/announce'
|
|
||||||
header = ['state','ratio','tags','added','hash','name','tracker']
|
|
||||||
row = [state,ratio,tags,added,thash,tname,trname]
|
|
||||||
write_csv(self.cv,outfile,header,row)
|
|
||||||
self.assertTrue(os.path.exists(outfile))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def test_writes_header_then_row(self):
|
||||||
|
write_csv(csv, self.outfile, HEADER, ROW)
|
||||||
|
rows = self.read_rows()
|
||||||
|
self.assertEqual(rows[0], HEADER)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[1][4], "asfasdf23412adfqwer")
|
||||||
|
|
||||||
|
def test_header_is_written_only_once(self):
|
||||||
|
write_csv(csv, self.outfile, HEADER, ROW)
|
||||||
|
write_csv(csv, self.outfile, HEADER, ROW)
|
||||||
|
rows = self.read_rows()
|
||||||
|
self.assertEqual(rows.count(HEADER), 1)
|
||||||
|
self.assertEqual(len(rows), 3)
|
||||||
|
|
||||||
|
def test_creates_the_file_when_missing(self):
|
||||||
|
self.assertFalse(os.path.exists(self.outfile))
|
||||||
|
write_csv(csv, self.outfile, HEADER, ROW)
|
||||||
|
self.assertTrue(os.path.exists(self.outfile))
|
||||||
|
|
||||||
|
def test_values_are_round_tripped(self):
|
||||||
|
write_csv(csv, self.outfile, HEADER, ROW)
|
||||||
|
self.assertEqual(self.read_rows()[1], [str(value) for value in ROW])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
Reference in new issue
Block a user