turnstone/tests/test_watch_watcher.py
pyr0ball 4151c98f23 feat: add file tail source type; configure example-node watchers
- type: file uses tail -F (handles rotation) with auto-format detection
- _parse_lines dispatches to journald/servarr/qbit/caddy/syslog/plaintext
  based on first-line format detection — same logic as batch ingest
- watch.yaml updated with file type docs and example-node-specific example
- scripts/journal-bridge.sh + .service written directly to example-node

Xander's watch.yaml covers: system-journal-live (via bridge file),
sonarr, radarr, lidarr, prowlarr, bazarr, qbittorrent, nzbget, tautulli
2026-05-11 15:44:10 -07:00

174 lines
5.4 KiB
Python

"""Tests for app/watch/watcher.py — config loading, command building, output parsing."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.watch.watcher import (
WatchConfig,
WatchSource,
Watcher,
_strip_docker_ts,
load_watch_config,
)
# ── Config loading ──────────────────────────────────────────────────────────
def test_load_watch_config_missing_file(tmp_path: Path):
result = load_watch_config(tmp_path / "nonexistent.yaml")
assert result == []
def test_load_watch_config_empty_sources(tmp_path: Path):
cfg = tmp_path / "watch.yaml"
cfg.write_text("sources: []\n")
assert load_watch_config(cfg) == []
def test_load_watch_config_parses_journald(tmp_path: Path):
cfg = tmp_path / "watch.yaml"
cfg.write_text("""
sources:
- type: journald
id: system-journal
args: ["-u", "sshd"]
""")
configs = load_watch_config(cfg)
assert len(configs) == 1
assert configs[0].source_type == "journald"
assert configs[0].source_id == "system-journal"
assert configs[0].args == ["-u", "sshd"]
def test_load_watch_config_parses_podman(tmp_path: Path):
cfg = tmp_path / "watch.yaml"
cfg.write_text("""
sources:
- type: podman
id: podman:turnstone
args: ["turnstone"]
""")
configs = load_watch_config(cfg)
assert configs[0].source_type == "podman"
assert configs[0].args == ["turnstone"]
def test_load_watch_config_no_args_defaults_to_empty(tmp_path: Path):
cfg = tmp_path / "watch.yaml"
cfg.write_text("""
sources:
- type: journald
id: system
""")
configs = load_watch_config(cfg)
assert configs[0].args == []
# ── Command building ─────────────────────────────────────────────────────────
def _make_source(source_type: str, source_id: str, args: list = None, db=None, pattern=None):
if db is None:
db = Path("/tmp/fake.db")
if pattern is None:
pattern = Path("/tmp/fake.yaml")
cfg = WatchConfig(source_type=source_type, source_id=source_id, args=args or [])
return WatchSource(cfg, db, pattern)
def test_build_command_journald():
src = _make_source("journald", "sys")
cmd = src._build_command()
assert cmd[:3] == ["journalctl", "-f", "--output=json"]
def test_build_command_journald_with_unit_filter():
src = _make_source("journald", "sshd", args=["-u", "sshd"])
cmd = src._build_command()
assert "-u" in cmd
assert "sshd" in cmd
def test_build_command_podman_with_container():
src = _make_source("podman", "podman:ts", args=["turnstone"])
cmd = src._build_command()
assert cmd[0] == "podman"
assert "logs" in cmd
assert "-f" in cmd
assert "turnstone" in cmd
def test_build_command_docker_no_args_returns_none():
src = _make_source("docker", "docker:nginx")
cmd = src._build_command()
assert cmd is None
def test_build_command_unknown_type_returns_none():
src = _make_source("kafka", "topic:logs")
cmd = src._build_command()
assert cmd is None
def test_build_command_file_with_path():
src = _make_source("file", "sonarr", args=["/opt/sonarr/config/logs/sonarr.0.txt"])
cmd = src._build_command()
assert cmd[0] == "tail"
assert "-F" in cmd
assert "-n" in cmd and "0" in cmd
assert "/opt/sonarr/config/logs/sonarr.0.txt" in cmd
def test_build_command_file_no_args_returns_none():
src = _make_source("file", "sonarr")
cmd = src._build_command()
assert cmd is None
# ── Docker timestamp stripping ───────────────────────────────────────────────
def test_strip_docker_ts_removes_rfc3339_prefix():
line = "2024-01-15T12:34:56.789012345Z some log line"
assert _strip_docker_ts(line) == "some log line"
def test_strip_docker_ts_passes_plain_lines():
line = "plain log line without timestamp"
assert _strip_docker_ts(line) == line
def test_strip_docker_ts_handles_offset_timezone():
# Docker --timestamps always uses Z (UTC), but be safe
line = "2024-01-15T12:34:56Z message"
assert _strip_docker_ts(line) == "message"
# ── Watcher orchestrator ─────────────────────────────────────────────────────
def test_watcher_status_empty_when_no_sources(tmp_path: Path):
w = Watcher(tmp_path / "fake.db", tmp_path / "fake.yaml")
assert w.status == []
assert not w.is_active()
def test_watcher_configure_creates_sources(tmp_path: Path):
w = Watcher(tmp_path / "fake.db", tmp_path / "fake.yaml")
configs = [
WatchConfig("journald", "sys"),
WatchConfig("podman", "ts", ["turnstone"]),
]
w.configure(configs)
assert len(w.status) == 2
assert w.status[0]["source_id"] == "sys"
assert w.status[1]["source_id"] == "ts"
def test_watcher_status_shows_not_running_before_start(tmp_path: Path):
w = Watcher(tmp_path / "fake.db", tmp_path / "fake.yaml")
w.configure([WatchConfig("journald", "sys")])
assert not w.is_active()
assert w.status[0]["running"] is False