turnstone/tests/test_watch_watcher.py
pyr0ball 0497d0ad60 feat: live watch mode — tail journald/docker/podman sources continuously (#4)
Adds background watcher that tails active log sources and ingests entries
in near-real-time, keeping the DB fresh without manual ingest runs.

- app/watch/watcher.py: Watcher + WatchSource using subprocess + select
  loop; flushes every 10s or 100 lines; syncs FTS index every 3 flushes
- patterns/watch.yaml: declarative source config (journald/docker/podman)
- app/rest.py: lifespan context manager starts/stops watcher on app
  startup/shutdown; GET /api/watch/status + POST /api/watch/reload
- web/src/views/DashboardView.vue: live/manual indicator chip + stale
  banner copy adapts to whether live watching is active
- tests/test_watch_watcher.py: 16 tests covering config load, command
  building, docker timestamp stripping, orchestrator lifecycle

Closes #4
2026-05-11 15:34:13 -07:00

159 lines
4.9 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
# ── 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