from __future__ import annotations import os import tempfile import unittest from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import patch from app.config import ( get_current_match_adminlog_enabled, get_current_match_adminlog_interval_seconds, get_current_match_adminlog_lookback_seconds, ) from app.rcon_current_match_worker import ( list_current_match_trusted_targets, run_current_match_adminlog_refresh_loop, run_current_match_adminlog_refresh_once_unlocked, ) TARGET_01 = SimpleNamespace( external_server_id="comunidad-hispana-01", name="Comunidad Hispana #01", host="203.0.113.10", port=7779, password="secret-01", region="ES", game_port=None, query_port=None, source_name="community-hispana-rcon", ) TARGET_02 = SimpleNamespace( external_server_id="comunidad-hispana-02", name="Comunidad Hispana #02", host="203.0.113.11", port=7879, password="secret-02", region="ES", game_port=None, query_port=None, source_name="community-hispana-rcon", ) TARGET_03 = SimpleNamespace( external_server_id="comunidad-hispana-03", name="Comunidad Hispana #03", host="203.0.113.12", port=7979, password="secret-03", region="ES", game_port=None, query_port=None, source_name="community-hispana-rcon", ) class RconCurrentMatchWorkerTests(unittest.TestCase): def test_list_current_match_trusted_targets_filters_only_01_and_02(self) -> None: with patch( "app.rcon_current_match_worker.load_rcon_targets", return_value=(TARGET_01, TARGET_02, TARGET_03), ): selected = list_current_match_trusted_targets() self.assertEqual( [target.external_server_id for target in selected], ["comunidad-hispana-01", "comunidad-hispana-02"], ) def test_once_unlocked_calls_existing_persistence_path(self) -> None: fetch_calls: list[object] = [] persist_calls: list[dict[str, object]] = [] def fake_fetch(target, *, lookback_seconds, timeout_seconds): fetch_calls.append((target.external_server_id, lookback_seconds, timeout_seconds)) return [{"timestamp": "2026-06-18T18:00:00Z", "message": "[1 (1)] Killer(Allies) -> Victim(Axis) with Rifle"}] def fake_persist(*, target, entries, db_path=None, ensure_storage=True): persist_calls.append({"target": target, "entries": entries, "db_path": db_path}) return { "events_seen": len(entries), "events_inserted": len(entries), "duplicate_events": 0, } result = run_current_match_adminlog_refresh_once_unlocked( lookback_seconds=180, targets=[TARGET_01], fetch_entries_fn=fake_fetch, persist_entries_fn=fake_persist, ) self.assertEqual(fetch_calls, [("comunidad-hispana-01", 180, None)]) self.assertEqual(len(persist_calls), 1) self.assertEqual(persist_calls[0]["target"]["target_key"], "comunidad-hispana-01") self.assertEqual(persist_calls[0]["db_path"], None) self.assertEqual(result["totals"]["events_inserted"], 1) self.assertEqual(result["status"], "ok") def test_once_unlocked_initializes_storage_once_and_persists_without_repeated_ddl(self) -> None: persist_calls: list[dict[str, object]] = [] def fake_fetch(target, *, lookback_seconds, timeout_seconds): return [{"timestamp": "2026-06-18T18:00:00Z", "message": "[1 (1)] Killer(Allies) -> Victim(Axis) with Rifle"}] def fake_persist(*, target, entries, db_path=None, ensure_storage=True): persist_calls.append( { "target": target, "entries": entries, "db_path": db_path, "ensure_storage": ensure_storage, } ) return { "events_seen": len(entries), "events_inserted": len(entries), "duplicate_events": 0, } with patch("app.rcon_current_match_worker.initialize_rcon_admin_log_storage") as initialize: result = run_current_match_adminlog_refresh_once_unlocked( lookback_seconds=180, targets=[TARGET_01], fetch_entries_fn=fake_fetch, persist_entries_fn=fake_persist, ) initialize.assert_called_once_with(db_path=None) self.assertEqual(persist_calls[0]["ensure_storage"], False) self.assertEqual(result["status"], "ok") def test_failing_target_does_not_block_other_target(self) -> None: def fake_fetch(target, *, lookback_seconds, timeout_seconds): if target.external_server_id == "comunidad-hispana-01": raise RuntimeError("boom") return [{"timestamp": "2026-06-18T18:00:00Z", "message": "[1 (1)] Killer(Allies) -> Victim(Axis) with Rifle"}] def fake_persist(*, target, entries, db_path=None, ensure_storage=True): return { "events_seen": len(entries), "events_inserted": len(entries), "duplicate_events": 0, } result = run_current_match_adminlog_refresh_once_unlocked( lookback_seconds=180, targets=[TARGET_01, TARGET_02], fetch_entries_fn=fake_fetch, persist_entries_fn=fake_persist, ) self.assertEqual(result["status"], "partial") self.assertEqual(result["totals"]["failed_targets"], 1) self.assertEqual(result["totals"]["events_inserted"], 1) self.assertEqual(len(result["targets"]), 1) self.assertEqual(result["targets"][0]["target_key"], "comunidad-hispana-02") def test_overlapping_windows_remain_idempotent_via_existing_persistence(self) -> None: entry = { "timestamp": "2026-06-18T18:00:00Z", "message": ( "[5:00 min (321)] KILL: Alpha(Allies/76561198000000001) -> " "Bravo(Axis/76561198000000002) with M1 GARAND" ), } with tempfile.TemporaryDirectory() as temp_dir: db_path = os.path.join(temp_dir, "current_match.sqlite3") def fake_fetch(target, *, lookback_seconds, timeout_seconds): return [entry] first = run_current_match_adminlog_refresh_once_unlocked( lookback_seconds=180, targets=[TARGET_01], fetch_entries_fn=fake_fetch, db_path=db_path, ) second = run_current_match_adminlog_refresh_once_unlocked( lookback_seconds=180, targets=[TARGET_01], fetch_entries_fn=fake_fetch, db_path=db_path, ) self.assertEqual(first["totals"]["events_inserted"], 1) self.assertEqual(first["totals"]["duplicate_events"], 0) self.assertEqual(second["totals"]["events_inserted"], 0) self.assertEqual(second["totals"]["duplicate_events"], 1) def test_loop_honors_max_runs(self) -> None: results = [{"status": "ok"}, {"status": "ok"}] with ( patch("app.rcon_current_match_worker.initialize_rcon_admin_log_storage") as initialize, patch( "app.rcon_current_match_worker.run_current_match_adminlog_refresh_once", side_effect=results, ) as run_once, patch("app.rcon_current_match_worker.time.sleep") as sleep, ): run_current_match_adminlog_refresh_loop( interval_seconds=5, lookback_seconds=900, max_runs=2, ) initialize.assert_called_once_with() self.assertEqual(run_once.call_count, 2) self.assertEqual(sleep.call_count, 1) def test_compose_nas_runs_split_live_worker_and_safe_historical_interval(self) -> None: compose_path = os.path.join( os.path.dirname(os.path.dirname(__file__)), "..", "deploy", "portainer", "docker-compose.nas.yml", ) with open(compose_path, encoding="utf-8") as handle: compose_text = handle.read() self.assertIn("rcon-live-adminlog-worker:", compose_text) self.assertIn("app.rcon_current_match_worker", compose_text) self.assertIn('--lookback-minutes\n - "15"', compose_text) self.assertIn('HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-900}', compose_text) self.assertNotIn('HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS: ${HLL_RCON_HISTORICAL_CAPTURE_INTERVAL_SECONDS:-2}', compose_text) def test_current_match_adminlog_config_defaults_and_overrides(self) -> None: with _temporary_env( CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS=None, CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS=None, CURRENT_MATCH_ADMINLOG_ENABLED=None, ): self.assertEqual(get_current_match_adminlog_interval_seconds(), 10) self.assertEqual(get_current_match_adminlog_lookback_seconds(), 180) self.assertIs(get_current_match_adminlog_enabled(), False) with _temporary_env( CURRENT_MATCH_ADMINLOG_INTERVAL_SECONDS="15", CURRENT_MATCH_ADMINLOG_LOOKBACK_SECONDS="120", CURRENT_MATCH_ADMINLOG_ENABLED="true", ): self.assertEqual(get_current_match_adminlog_interval_seconds(), 15) self.assertEqual(get_current_match_adminlog_lookback_seconds(), 120) self.assertIs(get_current_match_adminlog_enabled(), True) @contextmanager def _temporary_env(**values: str | None): previous = {name: os.environ.get(name) for name in values} try: for name, value in values.items(): if value is None: os.environ.pop(name, None) else: os.environ[name] = value yield finally: for name, value in previous.items(): if value is None: os.environ.pop(name, None) else: os.environ[name] = value