Adds app/ingest/qbittorrent.py — auto-detected by the pipeline on the (YYYY/MM/DD HH:MM:SS) timestamp fingerprint. Handles both slash and dash date separators, optional [Warning|Critical] bracket levels, and multi-line continuations (Qt stack traces). patterns/default.yaml: 8 new qbit_ patterns covering tracker errors, port bind failures, disk errors, hash check failures, peer bans, download completion, ratio limits, and session errors. manage.sh: ingest-qbit [HOST] command mirrors ingest-plex — probes known default log paths locally or via SSH, ingests, restarts server. 14 tests covering format detection, severity mapping, multiline handling, and timestamp normalization.
90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""Tests for the qBittorrent log ingestor."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.ingest.qbittorrent import is_qbit_log, parse
|
|
|
|
SAMPLE_LOG = """\
|
|
(2026/05/09 14:10:01) qBittorrent v5.0.3 started
|
|
(2026/05/09 14:10:02) [Warning] Tracker 'http://tracker.example.com/announce' is not working. Reason: Connection timed out
|
|
(2026/05/09 14:10:03) [Critical] Couldn't listen on any of the network interfaces. Aborting!
|
|
(2026/05/09 14:10:04) Download of 'ubuntu-24.04.iso' has finished.
|
|
(2026/05/09 14:10:05) [Warning] Hash check failed for piece 42 of 'ubuntu-24.04.iso'
|
|
(2026/05/09 14:10:06) Some long message
|
|
that continues on the next line
|
|
and a third line
|
|
(2026/05/09 14:10:07) Normal message without bracket level
|
|
"""
|
|
|
|
DASH_FORMAT = "(2026-05-09 14:10:01) qBittorrent v4.6.2 started\n"
|
|
|
|
|
|
class TestDetector:
|
|
def test_detects_slash_format(self):
|
|
assert is_qbit_log("(2026/05/09 14:10:01) qBittorrent started")
|
|
|
|
def test_detects_dash_format(self):
|
|
assert is_qbit_log(DASH_FORMAT.strip())
|
|
|
|
def test_rejects_plex_format(self):
|
|
assert not is_qbit_log("Jan 01, 2026 12:00:00.000 [12345] DEBUG - message")
|
|
|
|
def test_rejects_journald_json(self):
|
|
assert not is_qbit_log('{"__REALTIME_TIMESTAMP": "12345", "MESSAGE": "hi"}')
|
|
|
|
def test_rejects_plaintext(self):
|
|
assert not is_qbit_log("2026-05-09 14:10:01 some syslog line")
|
|
|
|
|
|
class TestParser:
|
|
def _parse(self, text: str) -> list:
|
|
return list(parse(iter(text.splitlines(keepends=True)), "qbit_test", []))
|
|
|
|
def test_entry_count(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
assert len(entries) == 7
|
|
|
|
def test_startup_entry(self):
|
|
e = self._parse(SAMPLE_LOG)[0]
|
|
assert "qBittorrent v5.0.3 started" in e.text
|
|
# No bracket level + no severity keyword in text → None (consistent with other ingestors)
|
|
assert e.severity is None
|
|
assert e.timestamp_iso == "2026-05-09T14:10:01+00:00"
|
|
|
|
def test_warning_severity(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
tracker_entry = entries[1]
|
|
assert tracker_entry.severity == "WARN"
|
|
assert "not working" in tracker_entry.text
|
|
|
|
def test_critical_severity(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
port_entry = entries[2]
|
|
assert port_entry.severity == "CRITICAL"
|
|
|
|
def test_multiline_continuation(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
multiline = entries[5]
|
|
assert "continues on the next line" in multiline.text
|
|
assert "third line" in multiline.text
|
|
|
|
def test_no_level_bracket_falls_back_to_detect(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
last = entries[6]
|
|
assert last.text == "Normal message without bracket level"
|
|
|
|
def test_source_id_propagated(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
assert all(e.source_id == "qbit_test" for e in entries)
|
|
|
|
def test_sequence_is_monotonic(self):
|
|
entries = self._parse(SAMPLE_LOG)
|
|
sequences = [e.sequence for e in entries]
|
|
assert sequences == sorted(sequences)
|
|
assert len(set(sequences)) == len(sequences)
|
|
|
|
def test_dash_format_timestamp(self):
|
|
entries = list(parse(iter(DASH_FORMAT.splitlines(keepends=True)), "qbit", []))
|
|
assert len(entries) == 1
|
|
assert entries[0].timestamp_iso == "2026-05-09T14:10:01+00:00"
|