fix: resolve the seven bugs pinned by the test audit #67
No files matched your search
+11
-14
@@ -11,19 +11,16 @@ def apprise_notify(req_obj, host, port, aurls, title, body):
|
|||||||
class AppriseClient:
|
class AppriseClient:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.config = ''
|
self.config = ''
|
||||||
try:
|
if os.environ.get("DOCKER"):
|
||||||
if os.environ["DOCKER"]:
|
self.host = os.environ["host"]
|
||||||
self.host = os.environ["host"]
|
self.port = os.environ["port"]
|
||||||
self.port = os.environ["port"]
|
self.aurls = os.environ["aurls"]
|
||||||
self.aurls = os.environ["aurls"]
|
self.title = os.environ["title"]
|
||||||
self.title = os.environ["title"]
|
self.body = os.environ["body"]
|
||||||
self.body = os.environ["body"]
|
if os.environ.get("toml_path"):
|
||||||
if os.environ["toml_path"]:
|
config_file_path=os.environ["toml_path"]
|
||||||
config_file_path=os.environ["toml_path"]
|
with open(config_file_path, 'rb') as c:
|
||||||
with open(config_file_path, 'rb') as c:
|
self.config = load(c)
|
||||||
self.config = load(c)
|
|
||||||
except:
|
|
||||||
KeyError
|
|
||||||
if os.path.exists('./config.toml'):
|
if os.path.exists('./config.toml'):
|
||||||
config_file_path = './config.toml'
|
config_file_path = './config.toml'
|
||||||
with open(config_file_path, 'rb') as c:
|
with open(config_file_path, 'rb') as c:
|
||||||
@@ -37,4 +34,4 @@ class AppriseClient:
|
|||||||
self.apprise_response = apprise_notify(r,self.host,self.port,self.aurls,self.title,self.body)
|
self.apprise_response = apprise_notify(r,self.host,self.port,self.aurls,self.title,self.body)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
AppriseClient()
|
AppriseClient()
|
||||||
@@ -50,6 +50,10 @@ aurls = 'mailto://user:pass@gmail.com'
|
|||||||
enable_dragnet = false
|
enable_dragnet = false
|
||||||
dragnet_outfile = "./orphaned.csv"
|
dragnet_outfile = "./orphaned.csv"
|
||||||
|
|
||||||
|
[telemetry]
|
||||||
|
enable_telemetry = false
|
||||||
|
telemetry_outfile = "./telemetry.csv"
|
||||||
|
|
||||||
[ignored_categories]
|
[ignored_categories]
|
||||||
tech = "tech"
|
tech = "tech"
|
||||||
books = "books"
|
books = "books"
|
||||||
|
|||||||
+2
-2
@@ -89,7 +89,7 @@ class Qbt:
|
|||||||
self.tracker_whitelist = self.config["ignored_domains"]
|
self.tracker_whitelist = self.config["ignored_domains"]
|
||||||
|
|
||||||
#ignored_tags
|
#ignored_tags
|
||||||
self.ignored_tags = self.config["ignored_domains"]
|
self.ignored_tags = self.config["ignored_tags"]
|
||||||
|
|
||||||
#healthcheck
|
#healthcheck
|
||||||
self.use_healthcheck = self.config["healthcheck"]["use_healthcheck"]
|
self.use_healthcheck = self.config["healthcheck"]["use_healthcheck"]
|
||||||
@@ -117,7 +117,7 @@ class Qbt:
|
|||||||
self.tl.info('Connected.')
|
self.tl.info('Connected.')
|
||||||
except qbittorrentapi.APIError as e:
|
except qbittorrentapi.APIError as e:
|
||||||
self.tl.exception(e)
|
self.tl.exception(e)
|
||||||
self.po.send_message(e, title="qbit-maid API ERROR")
|
tor_notify_error(self, e)
|
||||||
# Pulling all torrent data
|
# Pulling all torrent data
|
||||||
self.torrent_list = self.qbt_client.torrents_info()
|
self.torrent_list = self.qbt_client.torrents_info()
|
||||||
#Main process block
|
#Main process block
|
||||||
|
|||||||
@@ -22,24 +22,22 @@ def build_tor_list(self):
|
|||||||
self.ignored_counter += 1
|
self.ignored_counter += 1
|
||||||
continue
|
continue
|
||||||
if is_tag_blank(torrent['tags']):
|
if is_tag_blank(torrent['tags']):
|
||||||
self.tl.debug(f'Tagging new torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
|
tag = torrent_tag_for(
|
||||||
if is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
|
torrent['tracker'],
|
||||||
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
|
self.tracker_protected_tag,
|
||||||
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
|
self.tracker_non_protected_tag,
|
||||||
self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash'])
|
self.tracker_whitelist.values(),
|
||||||
|
)
|
||||||
|
if tag:
|
||||||
|
self.tl.debug(f'Tagging new torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
|
||||||
|
self.qbt_client.torrents_add_tags(tag, torrent['hash'])
|
||||||
if is_preme(torrent['seeding_time'], self.min_age):
|
if is_preme(torrent['seeding_time'], self.min_age):
|
||||||
self.preme_tor_counter += 1
|
self.preme_tor_counter += 1
|
||||||
self.tl.debug(f'Premature torrent: ["{torrent["name"][0:20]}..."] Seconds Seeded: [{torrent["seeding_time"]}] hash: {torrent["hash"]}')
|
self.tl.debug(f'Premature torrent: ["{torrent["name"][0:20]}..."] Seconds Seeded: [{torrent["seeding_time"]}] hash: {torrent["hash"]}')
|
||||||
continue
|
continue
|
||||||
elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
|
elif is_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
|
||||||
if is_tag_blank(torrent['tags']):
|
|
||||||
self.qbt_client.torrents_add_tags(self.tracker_protected_tag,torrent['hash'])
|
|
||||||
self.tl.debug(f'Tagging Protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
|
|
||||||
self.tracker_list.append(torrent)
|
self.tracker_list.append(torrent)
|
||||||
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
|
elif is_not_protected_tracker(torrent['tracker'], self.tracker_whitelist.values()):
|
||||||
if is_tag_blank(torrent['tags']):
|
|
||||||
self.qbt_client.torrents_add_tags(self.tracker_non_protected_tag,torrent['hash'])
|
|
||||||
self.tl.debug(f'Tagging Non-protected torrent: ["{torrent["name"][0:20]}..."] {torrent["tracker"]}hash: {torrent["hash"]}')
|
|
||||||
self.tracker_list.append(torrent)
|
self.tracker_list.append(torrent)
|
||||||
|
|
||||||
|
|
||||||
@@ -67,11 +65,26 @@ def is_not_protected_tracker(tracker, trackerlist):
|
|||||||
if tracker.split('/')[2] not in trackerlist:
|
if tracker.split('/')[2] not in trackerlist:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def torrent_tag_for(tracker, protected_tag, non_protected_tag, trackerlist):
|
||||||
|
"""Return the tag a torrent should carry, or None for an unknown tracker."""
|
||||||
|
if is_protected_tracker(tracker, trackerlist):
|
||||||
|
return protected_tag
|
||||||
|
if is_not_protected_tracker(tracker, trackerlist):
|
||||||
|
return non_protected_tag
|
||||||
|
return None
|
||||||
|
|
||||||
def is_tag_blank(tag):
|
def is_tag_blank(tag):
|
||||||
if tag == '':
|
if tag == '':
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_ignored_tag(igtags, tortags):
|
def is_ignored_tag(igtags, tortags):
|
||||||
for igt in igtags:
|
"""True when the torrent carries one of the configured ignored tags.
|
||||||
if igt in tortags:
|
|
||||||
return True
|
qBittorrent returns tags as a comma-separated string, so the comparison is
|
||||||
|
per tag: a configured ``ipt`` must not match a torrent tagged
|
||||||
|
``iptorrents``.
|
||||||
|
"""
|
||||||
|
if not tortags:
|
||||||
|
return False
|
||||||
|
torrent_tags = {tag.strip() for tag in str(tortags).split(',') if tag.strip()}
|
||||||
|
return any(igt in torrent_tags for igt in igtags)
|
||||||
+22
@@ -77,9 +77,30 @@ def tor_notify_summary(self):
|
|||||||
except Exception as e: # never let a notification failure abort a run
|
except Exception as e: # never let a notification failure abort a run
|
||||||
logger.error(f"Failed to send pushover notification: {e}")
|
logger.error(f"Failed to send pushover notification: {e}")
|
||||||
|
|
||||||
|
def tor_notify_error(self, error, title="qbit-maid API ERROR"):
|
||||||
|
"""Report an error through pushover when it is configured.
|
||||||
|
|
||||||
|
The vendored ``pushover`` module only exposes the ``Pushover`` class, so the
|
||||||
|
message has to go through the client built by :func:`tor_notify`.
|
||||||
|
"""
|
||||||
|
logger = _logger(self)
|
||||||
|
logger.error(f"{title}: {error}")
|
||||||
|
if not getattr(self, "use_pushover", False):
|
||||||
|
return
|
||||||
|
client = getattr(self, "poc", None)
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
client.message(self.po_key, str(error), title=title)
|
||||||
|
except Exception as exc: # never let a notification failure abort a run
|
||||||
|
logger.error(f"Failed to send pushover error notification: {exc}")
|
||||||
|
|
||||||
def list_first_tor(self, index=0):
|
def list_first_tor(self, index=0):
|
||||||
"""Only lists the first torrent"""
|
"""Only lists the first torrent"""
|
||||||
self.tl.debug('First torrent in the list:')
|
self.tl.debug('First torrent in the list:')
|
||||||
|
if not self.torrent_list:
|
||||||
|
self.tl.debug('No torrents found.')
|
||||||
|
return
|
||||||
torrent = self.torrent_list[index]
|
torrent = self.torrent_list[index]
|
||||||
for k,v in torrent.items():
|
for k,v in torrent.items():
|
||||||
self.tl.debug(f'{k}: {v}')
|
self.tl.debug(f'{k}: {v}')
|
||||||
@@ -337,6 +358,7 @@ def setup_logging(use_log: bool, log_path: str, log_level: str,
|
|||||||
# Make setup idempotent: clear any handlers from a previous call
|
# Make setup idempotent: clear any handlers from a previous call
|
||||||
for handler in list(logger.handlers):
|
for handler in list(logger.handlers):
|
||||||
logger.removeHandler(handler)
|
logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
if not use_log:
|
if not use_log:
|
||||||
logger.addHandler(logging.NullHandler())
|
logger.addHandler(logging.NullHandler())
|
||||||
|
|||||||
+5
-10
@@ -143,8 +143,6 @@ class TestAppriseClientConfig(unittest.TestCase):
|
|||||||
return config_path
|
return config_path
|
||||||
|
|
||||||
def test_toml_path_is_read_when_docker_is_set(self):
|
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({
|
os.environ.update({
|
||||||
"DOCKER": "1",
|
"DOCKER": "1",
|
||||||
"host": "env-host",
|
"host": "env-host",
|
||||||
@@ -158,15 +156,12 @@ class TestAppriseClientConfig(unittest.TestCase):
|
|||||||
self.assertEqual(self.captured["host"], "toml-host")
|
self.assertEqual(self.captured["host"], "toml-host")
|
||||||
self.assertEqual(self.captured["port"], 4321)
|
self.assertEqual(self.captured["port"], 4321)
|
||||||
|
|
||||||
def test_toml_path_is_ignored_without_docker(self):
|
def test_toml_path_is_read_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()
|
os.environ["toml_path"] = self._write_elsewhere_config()
|
||||||
with self.assertRaises(AttributeError):
|
AppriseClient()
|
||||||
AppriseClient()
|
self.assertEqual(self.captured["host"], "toml-host")
|
||||||
|
self.assertEqual(self.captured["port"], 4321)
|
||||||
|
self.assertEqual(self.captured["title"], "Toml Title")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+9
-6
@@ -113,13 +113,16 @@ class TestPredicates(unittest.TestCase):
|
|||||||
self.assertTrue(is_ignored_tag(["save"], "save,public,ipt"))
|
self.assertTrue(is_ignored_tag(["save"], "save,public,ipt"))
|
||||||
self.assertFalse(is_ignored_tag(["save"], "public,ipt"))
|
self.assertFalse(is_ignored_tag(["save"], "public,ipt"))
|
||||||
|
|
||||||
def test_is_ignored_tag_matches_substrings_not_tags(self):
|
def test_is_ignored_tag_matches_whole_tags_only(self):
|
||||||
"""KNOWN BUG: matching is substring-based, so a short tag matches any
|
"""A configured tag must match a whole tag, not a substring of one.
|
||||||
longer tag that contains it. 'ipt' matches 'iptorrents' and 'save'
|
|
||||||
matches 'unsaved', which silently ignores unrelated torrents.
|
'ipt' must not match 'iptorrents' and 'save' must not match 'unsaved'.
|
||||||
"""
|
"""
|
||||||
self.assertTrue(is_ignored_tag(["ipt"], "iptorrents"))
|
self.assertFalse(is_ignored_tag(["ipt"], "iptorrents"))
|
||||||
self.assertTrue(is_ignored_tag(["save"], "unsaved"))
|
self.assertFalse(is_ignored_tag(["save"], "unsaved"))
|
||||||
|
self.assertTrue(is_ignored_tag(["ipt"], "save,ipt"))
|
||||||
|
self.assertTrue(is_ignored_tag(["save"], " save , public"))
|
||||||
|
self.assertFalse(is_ignored_tag(["save"], ""))
|
||||||
|
|
||||||
|
|
||||||
class TestSendPing(unittest.TestCase):
|
class TestSendPing(unittest.TestCase):
|
||||||
|
|||||||
+51
-27
@@ -13,7 +13,13 @@ import types
|
|||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from qbt_fakes import FakeAPIError, FakeQbtClient, StubPushover, torrent
|
from qbt_fakes import (
|
||||||
|
FailingPushover,
|
||||||
|
FakeAPIError,
|
||||||
|
FakeQbtClient,
|
||||||
|
StubPushover,
|
||||||
|
torrent,
|
||||||
|
)
|
||||||
|
|
||||||
REPO = os.path.dirname(os.path.abspath(__file__))
|
REPO = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
@@ -200,13 +206,21 @@ class TestQbtPipeline(QbtTestCase):
|
|||||||
self.assertIsNotNone(qbt.et)
|
self.assertIsNotNone(qbt.et)
|
||||||
self.assertGreaterEqual(qbt.et, qbt.st)
|
self.assertGreaterEqual(qbt.et, qbt.st)
|
||||||
|
|
||||||
def test_empty_torrent_list_crashes(self):
|
def test_empty_torrent_list_is_handled(self):
|
||||||
"""KNOWN BUG: list_first_tor() indexes torrent_list[0] unconditionally,
|
"""A qBittorrent instance with zero torrents must not abort the run."""
|
||||||
so a qBittorrent instance with zero torrents raises IndexError and the
|
qbt, client = self.build([])
|
||||||
whole run aborts before any summary is sent.
|
self.assertEqual(qbt.total_torrents, 0)
|
||||||
"""
|
self.assertEqual(qbt.torrent_hash_delete_list, [])
|
||||||
with self.assertRaises(IndexError):
|
self.assertEqual(client.deleted, [])
|
||||||
self.build([])
|
|
||||||
|
def test_empty_torrent_list_still_sends_a_summary(self):
|
||||||
|
stub = StubPushover()
|
||||||
|
with mock.patch.object(self.module.pushover, "Pushover",
|
||||||
|
return_value=stub):
|
||||||
|
self.build([], use_pushover="true")
|
||||||
|
self.assertEqual(len(stub.calls), 1)
|
||||||
|
self.assertEqual(stub.calls[0]["kwargs"]["title"],
|
||||||
|
"qbit-maid: nothing removed")
|
||||||
|
|
||||||
|
|
||||||
class TestQbtConfigSources(QbtTestCase):
|
class TestQbtConfigSources(QbtTestCase):
|
||||||
@@ -281,23 +295,32 @@ class TestQbtLoginFailure(QbtTestCase):
|
|||||||
def test_login_error_notifies_and_continues(self):
|
def test_login_error_notifies_and_continues(self):
|
||||||
client = FakeQbtClient(torrents=[torrent(tags="ipt")],
|
client = FakeQbtClient(torrents=[torrent(tags="ipt")],
|
||||||
login_error=FakeAPIError("bad credentials"))
|
login_error=FakeAPIError("bad credentials"))
|
||||||
with mock.patch.object(self.module.pushover, "send_message",
|
stub = StubPushover()
|
||||||
create=True) as send_message:
|
with mock.patch.object(self.module.pushover, "Pushover",
|
||||||
qbt, _ = self.build([torrent(tags="ipt")], client=client)
|
return_value=stub):
|
||||||
send_message.assert_called_once()
|
qbt, _ = self.build([torrent(tags="ipt")], client=client,
|
||||||
self.assertEqual(send_message.call_args.kwargs["title"],
|
use_pushover="true")
|
||||||
|
# The error notification goes out first, then the run summary.
|
||||||
|
self.assertEqual(len(stub.calls), 2)
|
||||||
|
self.assertEqual(stub.calls[0]["kwargs"]["title"],
|
||||||
"qbit-maid API ERROR")
|
"qbit-maid API ERROR")
|
||||||
|
self.assertIn("bad credentials", stub.calls[0]["body"])
|
||||||
self.assertEqual(qbt.total_torrents, 1)
|
self.assertEqual(qbt.total_torrents, 1)
|
||||||
|
|
||||||
def test_login_error_crashes_without_a_send_message_helper(self):
|
def test_login_error_without_pushover_does_not_crash(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")],
|
client = FakeQbtClient(torrents=[torrent(tags="ipt")],
|
||||||
login_error=FakeAPIError("bad credentials"))
|
login_error=FakeAPIError("bad credentials"))
|
||||||
with self.assertRaises(AttributeError):
|
qbt, _ = self.build([torrent(tags="ipt")], client=client)
|
||||||
self.build([torrent(tags="ipt")], client=client)
|
self.assertEqual(qbt.total_torrents, 1)
|
||||||
|
|
||||||
|
def test_login_error_survives_a_failing_pushover(self):
|
||||||
|
client = FakeQbtClient(torrents=[torrent(tags="ipt")],
|
||||||
|
login_error=FakeAPIError("bad credentials"))
|
||||||
|
with mock.patch.object(self.module.pushover, "Pushover",
|
||||||
|
return_value=FailingPushover()):
|
||||||
|
qbt, _ = self.build([torrent(tags="ipt")], client=client,
|
||||||
|
use_pushover="true")
|
||||||
|
self.assertEqual(qbt.total_torrents, 1)
|
||||||
|
|
||||||
|
|
||||||
class TestMainGuard(QbtTestCase):
|
class TestMainGuard(QbtTestCase):
|
||||||
@@ -312,14 +335,15 @@ class TestMainGuard(QbtTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestIgnoredTagsConfig(QbtTestCase):
|
class TestIgnoredTagsConfig(QbtTestCase):
|
||||||
def test_ignored_tags_reads_the_ignored_domains_section(self):
|
def test_ignored_tags_reads_the_ignored_tags_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")])
|
qbt, _ = self.build([torrent(tags="ipt")])
|
||||||
self.assertEqual(qbt.ignored_tags, {"tracker-example": "tracker.example"})
|
self.assertEqual(qbt.ignored_tags, {"save": "save"})
|
||||||
self.assertNotIn("save", qbt.ignored_tags)
|
self.assertNotIn("tracker-example", qbt.ignored_tags)
|
||||||
|
|
||||||
|
def test_ignored_tag_from_config_is_skipped(self):
|
||||||
|
qbt, _ = self.build([torrent(tags="save", infohash="ignored-tag")])
|
||||||
|
self.assertEqual(qbt.ignored_counter, 1)
|
||||||
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+33
-10
@@ -2,7 +2,28 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from qbt_fakes import FakeQbt, torrent
|
from qbt_fakes import FakeQbt, torrent
|
||||||
from qlist import build_tor_list
|
from qlist import build_tor_list, torrent_tag_for
|
||||||
|
|
||||||
|
|
||||||
|
class TestTorrentTagFor(unittest.TestCase):
|
||||||
|
def test_protected_tracker_gets_the_protected_tag(self):
|
||||||
|
self.assertEqual(
|
||||||
|
torrent_tag_for("https://tracker.example/a", "ipt", "public",
|
||||||
|
["tracker.example"]),
|
||||||
|
"ipt",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_tracker_gets_the_public_tag(self):
|
||||||
|
self.assertEqual(
|
||||||
|
torrent_tag_for("https://other.example/a", "ipt", "public",
|
||||||
|
["tracker.example"]),
|
||||||
|
"public",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_blank_tracker_gets_no_tag(self):
|
||||||
|
self.assertIsNone(
|
||||||
|
torrent_tag_for("", "ipt", "public", ["tracker.example"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBuildTorList(unittest.TestCase):
|
class TestBuildTorList(unittest.TestCase):
|
||||||
@@ -36,6 +57,14 @@ class TestBuildTorList(unittest.TestCase):
|
|||||||
self.assertEqual(qbt.ignored_counter, 1)
|
self.assertEqual(qbt.ignored_counter, 1)
|
||||||
self.assertEqual(qbt.tracker_list, [])
|
self.assertEqual(qbt.tracker_list, [])
|
||||||
|
|
||||||
|
def test_ignored_tag_does_not_match_a_longer_tag(self):
|
||||||
|
qbt = self.run_build(
|
||||||
|
[torrent(tags="iptorrents", tracker="https://other.example/a")],
|
||||||
|
ignored_tags={"ipt": "ipt"},
|
||||||
|
)
|
||||||
|
self.assertEqual(qbt.ignored_counter, 0)
|
||||||
|
self.assertEqual(len(qbt.tracker_list), 1)
|
||||||
|
|
||||||
def test_blank_tag_on_protected_tracker_gets_protected_tag(self):
|
def test_blank_tag_on_protected_tracker_gets_protected_tag(self):
|
||||||
qbt = self.run_build([torrent(tags="", infohash="h1")])
|
qbt = self.run_build([torrent(tags="", infohash="h1")])
|
||||||
self.assertIn(("ipt", "h1"), qbt.qbt_client.added_tags)
|
self.assertIn(("ipt", "h1"), qbt.qbt_client.added_tags)
|
||||||
@@ -48,16 +77,10 @@ class TestBuildTorList(unittest.TestCase):
|
|||||||
self.assertIn(("public", "h2"), qbt.qbt_client.added_tags)
|
self.assertIn(("public", "h2"), qbt.qbt_client.added_tags)
|
||||||
self.assertEqual(len(qbt.tracker_list), 1)
|
self.assertEqual(len(qbt.tracker_list), 1)
|
||||||
|
|
||||||
def test_blank_tag_is_applied_twice_for_non_premature_torrents(self):
|
def test_blank_tag_is_applied_once_for_non_premature_torrents(self):
|
||||||
"""KNOWN REDUNDANCY: the tagging block runs twice for the same torrent.
|
"""A blank-tag torrent is tagged once, not once per code path."""
|
||||||
|
|
||||||
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")])
|
qbt = self.run_build([torrent(tags="", infohash="h1")])
|
||||||
self.assertEqual(qbt.qbt_client.added_tags, [("ipt", "h1"), ("ipt", "h1")])
|
self.assertEqual(qbt.qbt_client.added_tags, [("ipt", "h1")])
|
||||||
|
|
||||||
def test_premature_blank_tag_torrent_is_tagged_once(self):
|
def test_premature_blank_tag_torrent_is_tagged_once(self):
|
||||||
"""Premature torrents are tagged by the first block, then skipped."""
|
"""Premature torrents are tagged by the first block, then skipped."""
|
||||||
|
|||||||
+43
-9
@@ -5,10 +5,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
import warnings
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
|
||||||
from qbt_fakes import FakeQbt, StubPushover, torrent
|
from qbt_fakes import FailingPushover, FakeQbt, StubPushover, torrent
|
||||||
from qlogging import (
|
from qlogging import (
|
||||||
LOGGER_NAME,
|
LOGGER_NAME,
|
||||||
debug_torrent_list,
|
debug_torrent_list,
|
||||||
@@ -24,6 +23,7 @@ from qlogging import (
|
|||||||
summary_runtime,
|
summary_runtime,
|
||||||
summary_stats,
|
summary_stats,
|
||||||
tor_notify,
|
tor_notify,
|
||||||
|
tor_notify_error,
|
||||||
tor_notify_summary,
|
tor_notify_summary,
|
||||||
tornotifytest,
|
tornotifytest,
|
||||||
torrent_count,
|
torrent_count,
|
||||||
@@ -76,6 +76,35 @@ class TestNotifySetup(unittest.TestCase):
|
|||||||
self.assertEqual(qbt.poc.calls[0]["kwargs"]["title"], "qbit-maid")
|
self.assertEqual(qbt.poc.calls[0]["kwargs"]["title"], "qbit-maid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTorNotifyError(unittest.TestCase):
|
||||||
|
def test_sends_through_the_pushover_client(self):
|
||||||
|
qbt = FakeQbt(use_pushover=True)
|
||||||
|
tor_notify_error(qbt, RuntimeError("boom"))
|
||||||
|
self.assertEqual(len(qbt.poc.calls), 1)
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["user"], "USERKEY")
|
||||||
|
self.assertIn("boom", qbt.poc.calls[0]["body"])
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["kwargs"]["title"],
|
||||||
|
"qbit-maid API ERROR")
|
||||||
|
|
||||||
|
def test_skips_pushover_when_disabled(self):
|
||||||
|
qbt = FakeQbt(use_pushover=False)
|
||||||
|
tor_notify_error(qbt, RuntimeError("boom"))
|
||||||
|
self.assertEqual(qbt.poc.calls, [])
|
||||||
|
|
||||||
|
def test_skips_pushover_when_no_client_was_built(self):
|
||||||
|
qbt = FakeQbt(use_pushover=True, poc=None)
|
||||||
|
tor_notify_error(qbt, RuntimeError("boom")) # must not raise
|
||||||
|
|
||||||
|
def test_survives_a_failing_pushover(self):
|
||||||
|
qbt = FakeQbt(use_pushover=True, poc=FailingPushover())
|
||||||
|
tor_notify_error(qbt, RuntimeError("boom")) # must not raise
|
||||||
|
|
||||||
|
def test_accepts_a_custom_title(self):
|
||||||
|
qbt = FakeQbt(use_pushover=True)
|
||||||
|
tor_notify_error(qbt, RuntimeError("boom"), title="Custom")
|
||||||
|
self.assertEqual(qbt.poc.calls[0]["kwargs"]["title"], "Custom")
|
||||||
|
|
||||||
|
|
||||||
class TestCounters(unittest.TestCase):
|
class TestCounters(unittest.TestCase):
|
||||||
def test_process_counts_buckets_by_tag(self):
|
def test_process_counts_buckets_by_tag(self):
|
||||||
qbt = FakeQbt(
|
qbt = FakeQbt(
|
||||||
@@ -117,6 +146,12 @@ class TestDebugHelpers(unittest.TestCase):
|
|||||||
list_first_tor(qbt, index=1)
|
list_first_tor(qbt, index=1)
|
||||||
self.assertIn("One", "\n".join(captured.output))
|
self.assertIn("One", "\n".join(captured.output))
|
||||||
|
|
||||||
|
def test_list_first_tor_handles_an_empty_list(self):
|
||||||
|
qbt = FakeQbt(torrent_list=[])
|
||||||
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
|
list_first_tor(qbt)
|
||||||
|
self.assertIn("No torrents found", "\n".join(captured.output))
|
||||||
|
|
||||||
def test_list_qbit_api_info_logs_versions(self):
|
def test_list_qbit_api_info_logs_versions(self):
|
||||||
qbt = FakeQbt()
|
qbt = FakeQbt()
|
||||||
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
with self.assertLogs("qbit-maid-test", level="DEBUG") as captured:
|
||||||
@@ -233,14 +268,13 @@ class TestSetupLogging(unittest.TestCase):
|
|||||||
self.assertEqual(logger.level, logging.INFO)
|
self.assertEqual(logger.level, logging.INFO)
|
||||||
|
|
||||||
def test_setup_is_idempotent(self):
|
def test_setup_is_idempotent(self):
|
||||||
setup_logging(True, self.log_path, "INFO")
|
first = setup_logging(True, self.log_path, "INFO")
|
||||||
# setup_logging drops the previous handlers without closing them, so a
|
previous_file_handler = first.handlers[0]
|
||||||
# second call leaks the old file descriptor. Known issue; silenced here
|
self.assertIsNotNone(previous_file_handler.stream)
|
||||||
# so the suite output stays readable.
|
logger = setup_logging(True, self.log_path, "INFO")
|
||||||
with warnings.catch_warnings():
|
|
||||||
warnings.simplefilter("ignore", ResourceWarning)
|
|
||||||
logger = setup_logging(True, self.log_path, "INFO")
|
|
||||||
self.assertEqual(len(logger.handlers), 2)
|
self.assertEqual(len(logger.handlers), 2)
|
||||||
|
# The replaced handler is closed, so its file descriptor is released.
|
||||||
|
self.assertIsNone(previous_file_handler.stream)
|
||||||
|
|
||||||
def test_rotation_parameters_are_applied(self):
|
def test_rotation_parameters_are_applied(self):
|
||||||
logger = setup_logging(True, self.log_path, "INFO",
|
logger = setup_logging(True, self.log_path, "INFO",
|
||||||
|
|||||||
Reference in new issue
Block a user