267 lines
10 KiB
Python
267 lines
10 KiB
Python
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
|