Fix
This commit is contained in:
524
backend/tests/test_current_match_payload.py
Normal file
524
backend/tests/test_current_match_payload.py
Normal file
@@ -0,0 +1,524 @@
|
||||
from http import HTTPStatus
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.payloads import build_current_match_payload
|
||||
from app.rcon_admin_log_storage import list_current_match_player_stats, persist_rcon_admin_log_entries
|
||||
from app.rcon_client import RconServerTarget
|
||||
from app.routes import resolve_get_payload
|
||||
|
||||
|
||||
TARGET = RconServerTarget(
|
||||
name="Comunidad Hispana #01",
|
||||
host="127.0.0.1",
|
||||
port=7779,
|
||||
password="test-password",
|
||||
source_name="test-rcon",
|
||||
external_server_id="comunidad-hispana-01",
|
||||
)
|
||||
|
||||
|
||||
def test_current_match_payload_projects_rich_live_rcon_session_fields():
|
||||
data = _build_with_rcon_sample(
|
||||
{
|
||||
"normalized": {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"current_map": "carentan_warfare",
|
||||
"game_mode": "Warfare",
|
||||
"allied_score": 2,
|
||||
"axis_score": 2,
|
||||
"allied_players": 0,
|
||||
"axis_players": 0,
|
||||
"players": 0,
|
||||
"max_players": 100,
|
||||
"match_time_seconds": 5400,
|
||||
"remaining_match_time_seconds": 0,
|
||||
},
|
||||
"raw_session": {"mapId": "carentan_warfare", "mapName": "CARENTAN"},
|
||||
}
|
||||
)
|
||||
|
||||
assert data["map"] == "Carentan"
|
||||
assert data["map_id"] == "carentan_warfare"
|
||||
assert data["map_pretty_name"] == "Carentan"
|
||||
assert data["game_mode"] == "Warfare"
|
||||
assert data["allied_score"] == 2
|
||||
assert data["axis_score"] == 2
|
||||
assert data["players"] == 0
|
||||
assert data["player_count_quality"] == "rcon-session-unverified"
|
||||
assert data["player_count_source"] == "rcon-session"
|
||||
assert data["score_source"] == "rcon-session"
|
||||
assert data["map_source"] == "rcon-session"
|
||||
assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es"
|
||||
assert "/games" not in data["public_scoreboard_url"]
|
||||
|
||||
|
||||
def test_current_match_payload_preserves_missing_values_as_null():
|
||||
data = _build_with_rcon_sample(
|
||||
{
|
||||
"normalized": {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"current_map": None,
|
||||
"game_mode": None,
|
||||
"players": None,
|
||||
"max_players": None,
|
||||
},
|
||||
"raw_session": {},
|
||||
}
|
||||
)
|
||||
|
||||
assert data["map"] is None
|
||||
assert data["map_id"] is None
|
||||
assert data["game_mode"] is None
|
||||
assert data["allied_score"] is None
|
||||
assert data["axis_score"] is None
|
||||
assert data["players"] is None
|
||||
assert data["player_count_quality"] is None
|
||||
assert data["player_count_source"] is None
|
||||
assert data["score_source"] is None
|
||||
assert data["map_source"] is None
|
||||
|
||||
|
||||
def test_current_match_payload_keeps_explicit_zero_score():
|
||||
data = _build_with_rcon_sample(
|
||||
{
|
||||
"normalized": {
|
||||
"server_name": "Comunidad Hispana #01",
|
||||
"status": "online",
|
||||
"current_map": "stmariedumont_warfare",
|
||||
"allied_score": 0,
|
||||
"axis_score": 0,
|
||||
},
|
||||
"raw_session": {
|
||||
"mapId": "stmariedumont_warfare",
|
||||
"mapName": "ST MARIE DU MONT",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert data["map"] == "St. Marie Du Mont"
|
||||
assert data["allied_score"] == 0
|
||||
assert data["axis_score"] == 0
|
||||
assert data["score_source"] == "rcon-session"
|
||||
|
||||
|
||||
def test_current_match_payload_fallback_resolves_legacy_rcon_external_id_for_01():
|
||||
data = _build_with_snapshot_fallback(
|
||||
"comunidad-hispana-01",
|
||||
{
|
||||
"external_server_id": "rcon:152.114.195.174:7779",
|
||||
"server_name": "#01 [ESP] Comunidad Hispana",
|
||||
"status": "online",
|
||||
"current_map": "St. Marie Du Mont",
|
||||
"players": 0,
|
||||
"max_players": 100,
|
||||
"captured_at": "2026-03-24T14:08:41.008487Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert data["found"] is True
|
||||
assert data["map"] == "St. Marie Du Mont"
|
||||
assert data["map_pretty_name"] == "St. Marie Du Mont"
|
||||
assert data["status"] == "online"
|
||||
assert data["players"] == 0
|
||||
assert data["max_players"] == 100
|
||||
assert data["captured_at"] == "2026-03-24T14:08:41.008487Z"
|
||||
assert data["updated_at"] == "2026-03-24T14:08:41.008487Z"
|
||||
assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es"
|
||||
|
||||
|
||||
def test_current_match_payload_fallback_resolves_legacy_rcon_source_ref_for_02():
|
||||
data = _build_with_snapshot_fallback(
|
||||
"comunidad-hispana-02",
|
||||
{
|
||||
"external_server_id": "snapshot-server-02",
|
||||
"source_ref": "rcon://152.114.195.150:7879",
|
||||
"status": "online",
|
||||
"current_map": "Elsenborn Ridge",
|
||||
"captured_at": "2026-03-24T14:08:41.008487Z",
|
||||
},
|
||||
)
|
||||
|
||||
assert data["found"] is True
|
||||
assert data["server_slug"] == "comunidad-hispana-02"
|
||||
assert data["map"] == "Elsenborn Ridge"
|
||||
assert data["map_pretty_name"] == "Elsenborn Ridge"
|
||||
assert data["public_scoreboard_url"] == "https://scoreboard.comunidadhll.es:5443"
|
||||
|
||||
|
||||
def test_current_match_payload_fallback_resolves_community_server_names():
|
||||
number_first = _build_with_snapshot_fallback(
|
||||
"comunidad-hispana-01",
|
||||
{
|
||||
"external_server_id": "snapshot-server-01",
|
||||
"server_name": "#01 [ESP] Comunidad Hispana - Spa Onl",
|
||||
"current_map": "Mortain",
|
||||
},
|
||||
)
|
||||
community_first = _build_with_snapshot_fallback(
|
||||
"comunidad-hispana-02",
|
||||
{
|
||||
"external_server_id": "snapshot-server-02",
|
||||
"name": "Comunidad Hispana #02",
|
||||
"current_map": "Carentan",
|
||||
},
|
||||
)
|
||||
|
||||
assert number_first["found"] is True
|
||||
assert number_first["map"] == "Mortain"
|
||||
assert community_first["found"] is True
|
||||
assert community_first["map"] == "Carentan"
|
||||
|
||||
|
||||
def test_current_match_payload_fallback_does_not_match_unknown_snapshot():
|
||||
data = _build_with_snapshot_fallback(
|
||||
"comunidad-hispana-01",
|
||||
{
|
||||
"external_server_id": "rcon:203.0.113.10:9000",
|
||||
"source_ref": "rcon://203.0.113.10:9000",
|
||||
"server_name": "#03 Comunidad Hispana",
|
||||
"current_map": "Unknown Match",
|
||||
},
|
||||
)
|
||||
|
||||
assert data["found"] is False
|
||||
assert data["map"] is None
|
||||
assert data["status"] == "unavailable"
|
||||
|
||||
|
||||
def test_current_match_route_rejects_unsupported_server():
|
||||
status, payload = resolve_get_payload("/api/current-match?server=not-trusted")
|
||||
|
||||
assert status == HTTPStatus.NOT_FOUND
|
||||
assert payload["status"] == "error"
|
||||
|
||||
|
||||
def test_current_match_player_route_rejects_unsupported_server():
|
||||
status, payload = resolve_get_payload("/api/current-match/players?server=not-trusted")
|
||||
|
||||
assert status == HTTPStatus.NOT_FOUND
|
||||
assert payload["status"] == "error"
|
||||
|
||||
|
||||
def test_current_match_player_stats_aggregate_safe_admin_log_rows(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: Bravo(Axis/steam-bravo) -> "
|
||||
"Alpha(Allies/steam-alpha) with MP40"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:02:00Z",
|
||||
"message": (
|
||||
"[3:00 min (140)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Charlie(Allies/steam-charlie) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:03:00Z",
|
||||
"message": (
|
||||
"[4:00 min (160)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Bravo(Axis/steam-bravo) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert stats["scope"] == "open-admin-log-match-window"
|
||||
assert stats["confidence"] == "admin-log-boundary"
|
||||
assert stats["source"] == "rcon-admin-log-current-match-summary"
|
||||
assert [item["player_name"] for item in stats["items"]] == ["Alpha", "Bravo", "Charlie"]
|
||||
assert stats["items"][0] == {
|
||||
"player_name": "Alpha",
|
||||
"player_id": "steam-alpha",
|
||||
"team": "Allies",
|
||||
"kills": 1,
|
||||
"deaths": 1,
|
||||
"teamkills": 1,
|
||||
"deaths_by_teamkill": 0,
|
||||
"is_connected": None,
|
||||
"connected": None,
|
||||
"last_seen_at": "2026-05-21T10:03:00Z",
|
||||
"favorite_weapon": "M1 GARAND",
|
||||
"source": "kill",
|
||||
"confidence": "admin-log-boundary",
|
||||
}
|
||||
assert "raw_message" not in stats["items"][0]
|
||||
|
||||
|
||||
def test_current_match_player_stats_include_connected_players_without_kills(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": "[2:00 min (120)] CONNECTED Quiet Player (steam-quiet)",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert stats["scope"] == "open-admin-log-match-window"
|
||||
assert stats["items"] == [
|
||||
{
|
||||
"player_name": "Quiet Player",
|
||||
"player_id": "steam-quiet",
|
||||
"team": None,
|
||||
"kills": 0,
|
||||
"deaths": 0,
|
||||
"teamkills": 0,
|
||||
"deaths_by_teamkill": 0,
|
||||
"favorite_weapon": None,
|
||||
"last_seen_at": "2026-05-21T10:01:00Z",
|
||||
"is_connected": True,
|
||||
"connected": True,
|
||||
"source": "connected",
|
||||
"confidence": "admin-log-boundary",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_current_match_player_stats_keep_disconnected_participants_visible(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": "[2:00 min (120)] CONNECTED Brief Player (steam-brief)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:05:00Z",
|
||||
"message": "[6:00 min (180)] DISCONNECTED Brief Player (steam-brief)",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert stats["items"] == [
|
||||
{
|
||||
"player_name": "Brief Player",
|
||||
"player_id": "steam-brief",
|
||||
"team": None,
|
||||
"kills": 0,
|
||||
"deaths": 0,
|
||||
"teamkills": 0,
|
||||
"deaths_by_teamkill": 0,
|
||||
"favorite_weapon": None,
|
||||
"last_seen_at": "2026-05-21T10:05:00Z",
|
||||
"is_connected": False,
|
||||
"connected": False,
|
||||
"source": "connected,disconnected",
|
||||
"confidence": "admin-log-boundary",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_current_match_player_stats_include_victim_only_players(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: Killer One(Axis/steam-killer) -> "
|
||||
"Victim Only(Allies/steam-victim) with MP40"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
by_name = {item["player_name"]: item for item in stats["items"]}
|
||||
|
||||
assert by_name["Victim Only"]["kills"] == 0
|
||||
assert by_name["Victim Only"]["deaths"] == 1
|
||||
assert by_name["Victim Only"]["favorite_weapon"] is None
|
||||
|
||||
|
||||
def test_current_match_player_stats_exclude_players_before_open_match_start(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T09:55:00Z",
|
||||
"message": "[0:30 min (90)] CONNECTED Old Match Player (steam-old)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": "[2:00 min (120)] CONNECTED New Match Player (steam-new)",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert [item["player_name"] for item in stats["items"]] == ["New Match Player"]
|
||||
|
||||
|
||||
def test_current_match_player_stats_sort_connected_before_disconnected_with_same_stats(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": "[2:00 min (120)] CONNECTED Connected Alpha (steam-connected)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:02:00Z",
|
||||
"message": "[3:00 min (140)] CONNECTED Disconnected Bravo (steam-disconnected)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:03:00Z",
|
||||
"message": "[4:00 min (160)] DISCONNECTED Disconnected Bravo (steam-disconnected)",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert [item["player_name"] for item in stats["items"]] == [
|
||||
"Connected Alpha",
|
||||
"Disconnected Bravo",
|
||||
]
|
||||
|
||||
|
||||
def test_current_match_player_stats_filter_stale_recent_events(tmp_path):
|
||||
db_path = tmp_path / "admin-log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T09:30:00Z",
|
||||
"message": (
|
||||
"[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> "
|
||||
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||
),
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
stats = list_current_match_player_stats(
|
||||
server_key="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert stats["scope"] == "no-current-match-events"
|
||||
assert stats["confidence"] == "stale-filtered"
|
||||
assert stats["items"] == []
|
||||
|
||||
|
||||
def _build_with_rcon_sample(sample: dict[str, object]) -> dict[str, object]:
|
||||
with (
|
||||
patch("app.payloads.load_rcon_targets", return_value=(TARGET,)),
|
||||
patch("app.payloads.query_live_server_sample", return_value=sample),
|
||||
):
|
||||
payload = build_current_match_payload(server_slug="comunidad-hispana-01")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def _build_with_snapshot_fallback(
|
||||
server_slug: str,
|
||||
item: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
with (
|
||||
patch("app.payloads._query_current_match_rcon_sample", return_value=None),
|
||||
patch(
|
||||
"app.payloads.build_servers_payload",
|
||||
return_value={
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"last_snapshot_at": "2026-03-24T14:08:41.008487Z",
|
||||
"items": [item],
|
||||
},
|
||||
},
|
||||
),
|
||||
):
|
||||
payload = build_current_match_payload(server_slug=server_slug)
|
||||
return payload["data"]
|
||||
448
backend/tests/test_database_maintenance.py
Normal file
448
backend/tests/test_database_maintenance.py
Normal file
@@ -0,0 +1,448 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing, redirect_stdout
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app.database_maintenance import run_database_maintenance_cleanup
|
||||
from app.rcon_admin_log_materialization import MATCH_RESULT_SOURCE, initialize_rcon_materialized_storage
|
||||
from app.rcon_admin_log_storage import initialize_rcon_admin_log_storage
|
||||
from app.storage import initialize_storage
|
||||
|
||||
|
||||
class DatabaseMaintenanceTests(unittest.TestCase):
|
||||
def test_dry_run_does_not_delete(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_server_snapshot(db_path, snapshot_id=1, captured_at="2026-05-01T00:00:00Z")
|
||||
|
||||
payload = run_database_maintenance_cleanup(
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "ok")
|
||||
self.assertEqual(payload["mode"], "dry-run")
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
self.assertEqual(
|
||||
connection.execute("SELECT COUNT(*) FROM server_snapshots").fetchone()[0],
|
||||
1,
|
||||
)
|
||||
|
||||
def test_apply_deletes_old_server_snapshots(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_server_snapshot(db_path, snapshot_id=1, captured_at="2026-05-01T00:00:00Z")
|
||||
_insert_server_snapshot(db_path, snapshot_id=2, captured_at="2026-06-18T00:00:00Z")
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
recent_matches_keep=1,
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
ids = [row[0] for row in connection.execute("SELECT id FROM server_snapshots ORDER BY id")]
|
||||
self.assertEqual(ids, [2])
|
||||
|
||||
def test_apply_deletes_old_noncritical_admin_log_events(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_admin_log_event(
|
||||
db_path,
|
||||
event_id=1,
|
||||
event_type="chat",
|
||||
event_timestamp="2026-04-01T00:00:00Z",
|
||||
server_time=100,
|
||||
)
|
||||
_insert_admin_log_event(
|
||||
db_path,
|
||||
event_id=2,
|
||||
event_type="chat",
|
||||
event_timestamp="2026-06-15T00:00:00Z",
|
||||
server_time=200,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
remaining = [
|
||||
tuple(row)
|
||||
for row in connection.execute(
|
||||
"SELECT id, event_type FROM rcon_admin_log_events ORDER BY id"
|
||||
)
|
||||
]
|
||||
self.assertEqual(remaining, [(2, "chat")])
|
||||
|
||||
def test_apply_preserves_critical_events_within_retention(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_admin_log_event(
|
||||
db_path,
|
||||
event_id=1,
|
||||
event_type="kill",
|
||||
event_timestamp="2026-06-10T00:00:00Z",
|
||||
server_time=100,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_admin_log_events WHERE event_type = 'kill'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_apply_preserves_latest_100_materialized_matches(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
for index in range(101):
|
||||
ended_at = (
|
||||
datetime(2026, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=index)
|
||||
).isoformat().replace("+00:00", "Z")
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=index + 1,
|
||||
match_key=f"match-{index + 1}",
|
||||
ended_at=ended_at,
|
||||
server_time_start=(index + 1) * 10,
|
||||
server_time_end=(index + 1) * 10 + 5,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
remaining = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_materialized_matches"
|
||||
).fetchone()[0]
|
||||
oldest = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_materialized_matches WHERE match_key = 'match-1'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(remaining, 100)
|
||||
self.assertEqual(oldest, 0)
|
||||
|
||||
def test_apply_preserves_current_month_matches(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=1,
|
||||
match_key="old",
|
||||
ended_at="2026-01-10T12:00:00Z",
|
||||
server_time_start=10,
|
||||
server_time_end=20,
|
||||
)
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=2,
|
||||
match_key="current-month",
|
||||
ended_at="2026-06-03T12:00:00Z",
|
||||
server_time_start=30,
|
||||
server_time_end=40,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
recent_matches_keep=1,
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
keys = [row[0] for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")]
|
||||
self.assertEqual(keys, ["current-month"])
|
||||
|
||||
def test_apply_preserves_previous_month_when_now_day_is_early(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=1,
|
||||
match_key="previous-month",
|
||||
ended_at="2026-05-15T12:00:00Z",
|
||||
server_time_start=10,
|
||||
server_time_end=20,
|
||||
)
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=2,
|
||||
match_key="older",
|
||||
ended_at="2026-04-15T12:00:00Z",
|
||||
server_time_start=30,
|
||||
server_time_end=40,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-05T12:00:00Z",
|
||||
recent_matches_keep=1,
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
keys = [row[0] for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")]
|
||||
self.assertEqual(keys, ["previous-month"])
|
||||
|
||||
def test_apply_preserves_current_week(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=1,
|
||||
match_key="current-week",
|
||||
ended_at="2026-06-10T12:00:00Z",
|
||||
server_time_start=10,
|
||||
server_time_end=20,
|
||||
)
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=2,
|
||||
match_key="older",
|
||||
ended_at="2026-05-01T12:00:00Z",
|
||||
server_time_start=30,
|
||||
server_time_end=40,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-10T13:00:00Z",
|
||||
recent_matches_keep=1,
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
keys = [row[0] for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")]
|
||||
self.assertEqual(keys, ["current-week"])
|
||||
|
||||
def test_apply_preserves_previous_week_when_fallback_may_need_it(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=1,
|
||||
match_key="previous-week",
|
||||
ended_at="2026-06-03T12:00:00Z",
|
||||
server_time_start=10,
|
||||
server_time_end=20,
|
||||
)
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=2,
|
||||
match_key="current-week-sample",
|
||||
ended_at="2026-06-09T12:00:00Z",
|
||||
server_time_start=30,
|
||||
server_time_end=40,
|
||||
)
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=3,
|
||||
match_key="older",
|
||||
ended_at="2026-05-01T12:00:00Z",
|
||||
server_time_start=50,
|
||||
server_time_end=60,
|
||||
)
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-10T13:00:00Z",
|
||||
recent_matches_keep=1,
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
keys = {
|
||||
row[0]
|
||||
for row in connection.execute("SELECT match_key FROM rcon_materialized_matches")
|
||||
}
|
||||
self.assertEqual(keys, {"previous-week", "current-week-sample"})
|
||||
|
||||
def test_apply_deletes_old_non_protected_match_and_child_stats(self) -> None:
|
||||
with _temp_db() as db_path:
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=1,
|
||||
match_key="delete-me",
|
||||
ended_at="2026-01-10T12:00:00Z",
|
||||
server_time_start=10,
|
||||
server_time_end=20,
|
||||
)
|
||||
_insert_materialized_match(
|
||||
db_path,
|
||||
match_id=2,
|
||||
match_key="keep-me",
|
||||
ended_at="2026-06-18T12:00:00Z",
|
||||
server_time_start=30,
|
||||
server_time_end=40,
|
||||
)
|
||||
_insert_player_stat(db_path, match_key="delete-me", player_id="player-1")
|
||||
_insert_player_stat(db_path, match_key="keep-me", player_id="player-2")
|
||||
|
||||
run_database_maintenance_cleanup(
|
||||
apply=True,
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
recent_matches_keep=1,
|
||||
)
|
||||
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
deleted_match_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_materialized_matches WHERE match_key = 'delete-me'"
|
||||
).fetchone()[0]
|
||||
deleted_stat_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_match_player_stats WHERE match_key = 'delete-me'"
|
||||
).fetchone()[0]
|
||||
kept_stat_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_match_player_stats WHERE match_key = 'keep-me'"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(deleted_match_count, 0)
|
||||
self.assertEqual(deleted_stat_count, 0)
|
||||
self.assertEqual(kept_stat_count, 1)
|
||||
|
||||
def test_missing_optional_tables_are_logged_and_do_not_crash(self) -> None:
|
||||
with _temp_db(create_schema=False) as db_path:
|
||||
stream = io.StringIO()
|
||||
with redirect_stdout(stream):
|
||||
payload = run_database_maintenance_cleanup(
|
||||
db_path=db_path,
|
||||
now="2026-06-20T12:00:00Z",
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "ok")
|
||||
self.assertIn("database-maintenance-table-skipped", stream.getvalue())
|
||||
|
||||
|
||||
def _temp_db(*, create_schema: bool = True):
|
||||
class _TempDbContext:
|
||||
def __enter__(self) -> Path:
|
||||
self._tmpdir = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db_path = Path(self._tmpdir.name) / "maintenance.sqlite3"
|
||||
if create_schema:
|
||||
initialize_storage(db_path=self.db_path)
|
||||
initialize_rcon_admin_log_storage(db_path=self.db_path)
|
||||
initialize_rcon_materialized_storage(db_path=self.db_path)
|
||||
return self.db_path
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
return _TempDbContext()
|
||||
|
||||
|
||||
def _insert_server_snapshot(db_path: Path, *, snapshot_id: int, captured_at: str) -> None:
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO game_sources (
|
||||
id, slug, display_name, provider_kind, is_active, created_at, updated_at
|
||||
) VALUES (1, 'current-hll', 'Current Hell Let Loose', 'development', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO servers (
|
||||
id, game_source_id, external_server_id, server_name, region, first_seen_at, last_seen_at
|
||||
) VALUES (1, 1, 'server-1', 'Server 1', 'ES', ?, ?)
|
||||
""",
|
||||
(captured_at, captured_at),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO server_snapshots (
|
||||
id, server_id, captured_at, status, players, max_players, current_map, source_name
|
||||
) VALUES (?, 1, ?, 'online', 10, 100, 'hurtgen', 'test')
|
||||
""",
|
||||
(snapshot_id, captured_at),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _insert_admin_log_event(
|
||||
db_path: Path,
|
||||
*,
|
||||
event_id: int,
|
||||
event_type: str,
|
||||
event_timestamp: str,
|
||||
server_time: int,
|
||||
) -> None:
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_admin_log_events (
|
||||
id, target_key, external_server_id, event_timestamp, server_time,
|
||||
relative_time, event_type, raw_message, canonical_message,
|
||||
parsed_payload_json, raw_entry_json
|
||||
) VALUES (?, 'comunidad-hispana-01', 'comunidad-hispana-01', ?, ?, '', ?, '', '', '{}', '{}')
|
||||
""",
|
||||
(event_id, event_timestamp, server_time, event_type),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _insert_materialized_match(
|
||||
db_path: Path,
|
||||
*,
|
||||
match_id: int,
|
||||
match_key: str,
|
||||
ended_at: str,
|
||||
server_time_start: int,
|
||||
server_time_end: int,
|
||||
) -> None:
|
||||
started_at = _shift_iso(ended_at, hours=-1)
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_materialized_matches (
|
||||
id, target_key, external_server_id, match_key, map_name, map_pretty_name,
|
||||
game_mode, started_server_time, ended_server_time, started_at, ended_at,
|
||||
allied_score, axis_score, winner, confidence_mode, source_basis
|
||||
) VALUES (?, 'comunidad-hispana-01', 'comunidad-hispana-01', ?, 'hurtgen', 'Hurtgen Forest',
|
||||
'warfare', ?, ?, ?, ?, 5, 3, 'allied', 'exact', ?)
|
||||
""",
|
||||
(
|
||||
match_id,
|
||||
match_key,
|
||||
server_time_start,
|
||||
server_time_end,
|
||||
started_at,
|
||||
ended_at,
|
||||
MATCH_RESULT_SOURCE,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _insert_player_stat(db_path: Path, *, match_key: str, player_id: str) -> None:
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_match_player_stats (
|
||||
target_key, match_key, player_id, player_name, team,
|
||||
kills, deaths, teamkills, deaths_by_teamkill,
|
||||
weapons_json, death_by_weapons_json, most_killed_json, death_by_json
|
||||
) VALUES (
|
||||
'comunidad-hispana-01', ?, ?, ?, 'Allies',
|
||||
1, 1, 0, 0, '{}', '{}', '{}', '{}'
|
||||
)
|
||||
""",
|
||||
(match_key, player_id, player_id),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _shift_iso(value: str, *, hours: int) -> str:
|
||||
point = datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
shifted = point + timedelta(hours=hours)
|
||||
return shifted.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
153
backend/tests/test_historical_runner_maintenance.py
Normal file
153
backend/tests/test_historical_runner_maintenance.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
from contextlib import nullcontext, redirect_stdout
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import app.historical_runner as historical_runner
|
||||
from app.historical_runner import _maybe_run_database_maintenance, _run_refresh_with_retries
|
||||
|
||||
|
||||
class HistoricalRunnerMaintenanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
historical_runner._LAST_DATABASE_MAINTENANCE_RUN_AT = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
historical_runner._LAST_DATABASE_MAINTENANCE_RUN_AT = None
|
||||
|
||||
def test_scheduler_disabled_does_not_call_cleanup(self) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"HLL_DB_MAINTENANCE_ENABLED": "false"}, clear=False),
|
||||
patch("app.historical_runner.run_database_maintenance_cleanup") as cleanup,
|
||||
):
|
||||
result = _maybe_run_database_maintenance(
|
||||
now=datetime(2026, 6, 20, 12, tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
cleanup.assert_not_called()
|
||||
self.assertEqual(result["status"], "skipped")
|
||||
self.assertEqual(result["reason"], "disabled")
|
||||
|
||||
def test_scheduler_enabled_but_not_due_does_not_call_cleanup(self) -> None:
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HLL_DB_MAINTENANCE_ENABLED": "true",
|
||||
"HLL_DB_MAINTENANCE_INTERVAL_SECONDS": "43200",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
patch(
|
||||
"app.historical_runner.run_database_maintenance_cleanup",
|
||||
return_value={"status": "ok"},
|
||||
) as cleanup,
|
||||
):
|
||||
first = _maybe_run_database_maintenance(
|
||||
now=datetime(2026, 6, 20, 0, tzinfo=timezone.utc)
|
||||
)
|
||||
second = _maybe_run_database_maintenance(
|
||||
now=datetime(2026, 6, 20, 1, tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
self.assertEqual(first["status"], "ok")
|
||||
self.assertEqual(second["status"], "skipped")
|
||||
self.assertEqual(second["reason"], "not-due")
|
||||
cleanup.assert_called_once()
|
||||
|
||||
def test_scheduler_enabled_and_due_calls_cleanup(self) -> None:
|
||||
with (
|
||||
patch.dict(os.environ, {"HLL_DB_MAINTENANCE_ENABLED": "true"}, clear=False),
|
||||
patch(
|
||||
"app.historical_runner.run_database_maintenance_cleanup",
|
||||
return_value={"status": "ok"},
|
||||
) as cleanup,
|
||||
):
|
||||
result = _maybe_run_database_maintenance(
|
||||
now=datetime(2026, 6, 20, 12, tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
cleanup.assert_called_once()
|
||||
self.assertEqual(result["status"], "ok")
|
||||
|
||||
def test_cleanup_exception_is_logged_and_runner_continues(self) -> None:
|
||||
stream = io.StringIO()
|
||||
with (
|
||||
patch.dict(os.environ, {"HLL_DB_MAINTENANCE_ENABLED": "true"}, clear=False),
|
||||
patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()),
|
||||
patch(
|
||||
"app.historical_runner._run_primary_rcon_capture",
|
||||
return_value={"status": "ok", "targets": []},
|
||||
),
|
||||
patch(
|
||||
"app.historical_runner.run_incremental_refresh",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
patch(
|
||||
"app.historical_runner.generate_historical_snapshots",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
patch(
|
||||
"app.historical_runner.rebuild_elo_mmr_models",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
patch(
|
||||
"app.historical_runner.run_database_maintenance_cleanup",
|
||||
side_effect=RuntimeError("maintenance failed"),
|
||||
),
|
||||
redirect_stdout(stream),
|
||||
):
|
||||
result = _run_refresh_with_retries(
|
||||
max_retries=0,
|
||||
retry_delay_seconds=0,
|
||||
server_slug="comunidad-hispana-01",
|
||||
max_pages=None,
|
||||
page_size=None,
|
||||
run_number=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "ok")
|
||||
self.assertEqual(result["database_maintenance_result"]["status"], "error")
|
||||
self.assertIn("database-maintenance-scheduler-failed", stream.getvalue())
|
||||
|
||||
def test_interval_parsing_handles_invalid_values_safely(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HLL_DB_MAINTENANCE_ENABLED": "true",
|
||||
"HLL_DB_MAINTENANCE_INTERVAL_SECONDS": "bad",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
interval_seconds, source = historical_runner._resolve_db_maintenance_interval_seconds()
|
||||
|
||||
self.assertEqual(interval_seconds, 43200)
|
||||
self.assertEqual(source, "default-invalid-env-fallback")
|
||||
|
||||
def test_maintenance_state_is_tracked_in_process(self) -> None:
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HLL_DB_MAINTENANCE_ENABLED": "true",
|
||||
"HLL_DB_MAINTENANCE_INTERVAL_SECONDS": "3600",
|
||||
},
|
||||
clear=False,
|
||||
),
|
||||
patch(
|
||||
"app.historical_runner.run_database_maintenance_cleanup",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
):
|
||||
_maybe_run_database_maintenance(now=datetime(2026, 6, 20, 12, tzinfo=timezone.utc))
|
||||
self.assertEqual(
|
||||
historical_runner._LAST_DATABASE_MAINTENANCE_RUN_AT,
|
||||
datetime(2026, 6, 20, 12, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
126
backend/tests/test_historical_snapshot_refresh.py
Normal file
126
backend/tests/test_historical_snapshot_refresh.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Regression coverage for historical snapshot runner refreshes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from contextlib import nullcontext, redirect_stdout
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.config import (
|
||||
get_historical_refresh_interval_seconds,
|
||||
get_historical_refresh_max_retries,
|
||||
get_historical_refresh_retry_delay_seconds,
|
||||
)
|
||||
from app.historical_runner import _run_refresh_with_retries, run_periodic_historical_refresh
|
||||
from app.historical_snapshots import _normalize_snapshot_limit
|
||||
from app.postgres_display_storage import _json_payload_default
|
||||
from app.rcon_historical_read_model import (
|
||||
_calculate_coverage_hours,
|
||||
_calculate_duration_seconds,
|
||||
)
|
||||
|
||||
|
||||
class HistoricalSnapshotRefreshTests(unittest.TestCase):
|
||||
def test_runner_numeric_env_values_are_parsed_before_use(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "300",
|
||||
"HLL_HISTORICAL_REFRESH_MAX_RETRIES": "4",
|
||||
"HLL_HISTORICAL_REFRESH_RETRY_DELAY_SECONDS": "0.5",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
self.assertEqual(get_historical_refresh_interval_seconds(), 300)
|
||||
self.assertEqual(get_historical_refresh_max_retries(), 4)
|
||||
self.assertEqual(get_historical_refresh_retry_delay_seconds(), 0.5)
|
||||
|
||||
def test_runner_numeric_env_values_fail_with_clear_names(self) -> None:
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS": "hourly"},
|
||||
clear=False,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
ValueError,
|
||||
"HLL_HISTORICAL_SNAPSHOT_REFRESH_INTERVAL_SECONDS must be an integer",
|
||||
):
|
||||
get_historical_refresh_interval_seconds()
|
||||
|
||||
def test_rcon_coverage_accepts_postgres_datetime_values(self) -> None:
|
||||
start = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
|
||||
end = datetime(2026, 5, 21, 11, 30, tzinfo=timezone.utc)
|
||||
|
||||
self.assertEqual(_calculate_coverage_hours(start, end), 1.5)
|
||||
self.assertEqual(_calculate_duration_seconds(start, end), 5400)
|
||||
|
||||
def test_snapshot_limits_are_numeric_before_snapshot_queries(self) -> None:
|
||||
self.assertEqual(_normalize_snapshot_limit("recent_matches_limit", "10"), 10)
|
||||
with self.assertRaisesRegex(ValueError, "recent_matches_limit"):
|
||||
_normalize_snapshot_limit("recent_matches_limit", "ten")
|
||||
|
||||
def test_postgres_snapshot_payload_serializes_datetime_values(self) -> None:
|
||||
payload = {
|
||||
"captured_at": datetime(2026, 5, 21, 20, 12, 54, tzinfo=timezone.utc),
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
json.loads(json.dumps(payload, default=_json_payload_default)),
|
||||
{"captured_at": "2026-05-21T20:12:54Z"},
|
||||
)
|
||||
|
||||
def test_runner_failure_log_includes_exception_type_and_traceback(self) -> None:
|
||||
stream = io.StringIO()
|
||||
with (
|
||||
patch("app.historical_runner.backend_writer_lock", return_value=nullcontext()),
|
||||
patch(
|
||||
"app.historical_runner._run_primary_rcon_capture",
|
||||
side_effect=TypeError("bad timestamp"),
|
||||
),
|
||||
redirect_stdout(stream),
|
||||
):
|
||||
result = _run_refresh_with_retries(
|
||||
max_retries=0,
|
||||
retry_delay_seconds=0,
|
||||
server_slug=None,
|
||||
max_pages=None,
|
||||
page_size=None,
|
||||
run_number=1,
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "error")
|
||||
self.assertEqual(result["error_type"], "TypeError")
|
||||
self.assertIn("Traceback", result["traceback"])
|
||||
self.assertIn('"event": "historical-refresh-attempt-failed"', stream.getvalue())
|
||||
|
||||
def test_runner_success_log_serializes_datetime_values(self) -> None:
|
||||
stream = io.StringIO()
|
||||
with (
|
||||
patch(
|
||||
"app.historical_runner._run_refresh_with_retries",
|
||||
return_value={
|
||||
"status": "ok",
|
||||
"rcon_capture_result": {
|
||||
"captured_at": datetime(2026, 5, 22, tzinfo=timezone.utc),
|
||||
},
|
||||
},
|
||||
),
|
||||
redirect_stdout(stream),
|
||||
):
|
||||
run_periodic_historical_refresh(
|
||||
interval_seconds=1,
|
||||
max_retries=0,
|
||||
retry_delay_seconds=0,
|
||||
max_runs=1,
|
||||
)
|
||||
|
||||
self.assertIn('"status": "ok"', stream.getvalue())
|
||||
self.assertIn('"captured_at": "2026-05-22 00:00:00+00:00"', stream.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
27
backend/tests/test_json_serialization.py
Normal file
27
backend/tests/test_json_serialization.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Regression coverage for API JSON encoding of PostgreSQL value types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from app.main import _json_default
|
||||
|
||||
|
||||
class JsonSerializationTests(unittest.TestCase):
|
||||
def test_json_default_serializes_postgres_datetime_and_date_values(self) -> None:
|
||||
payload = {
|
||||
"started_at": datetime(2026, 5, 21, 10, 11, 12, tzinfo=timezone.utc),
|
||||
"day": date(2026, 5, 21),
|
||||
}
|
||||
|
||||
encoded = json.loads(json.dumps(payload, default=_json_default))
|
||||
|
||||
self.assertEqual(
|
||||
encoded,
|
||||
{
|
||||
"started_at": "2026-05-21T10:11:12+00:00",
|
||||
"day": "2026-05-21",
|
||||
},
|
||||
)
|
||||
166
backend/tests/test_rcon_admin_log_parser.py
Normal file
166
backend/tests/test_rcon_admin_log_parser.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from app.rcon_admin_log_parser import parse_rcon_admin_log_message
|
||||
|
||||
|
||||
from app.rcon_admin_log_parser import parse_rcon_player_profile_snapshot
|
||||
|
||||
|
||||
def test_parse_match_start():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[2:09:15 hours (1779178245)] MATCH START UTAH BEACH Warfare"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "match_start"
|
||||
assert parsed.server_time == 1779178245
|
||||
assert parsed.map_name == "UTAH BEACH"
|
||||
assert parsed.game_mode == "Warfare"
|
||||
|
||||
|
||||
def test_parse_match_end():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[20:36:53 hours (1779111786)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS "
|
||||
)
|
||||
|
||||
assert parsed.event_type == "match_end"
|
||||
assert parsed.map_name == "ST MARIE DU MONT Warfare"
|
||||
assert parsed.allied_score == 5
|
||||
assert parsed.axis_score == 0
|
||||
assert parsed.winner == "allied"
|
||||
|
||||
|
||||
def test_parse_kill():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[1:20:19 hours (1779181181)] KILL: AntonioPruna(Allies/76561198000000000) -> "
|
||||
"[7DV] NEⓇA TACTICAL FEMB✡Y(Axis/76561199000000000) with M1 GARAND"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "kill"
|
||||
assert parsed.killer_name == "AntonioPruna"
|
||||
assert parsed.killer_team == "Allies"
|
||||
assert parsed.killer_id == "76561198000000000"
|
||||
assert parsed.victim_name == "[7DV] NEⓇA TACTICAL FEMB✡Y"
|
||||
assert parsed.victim_team == "Axis"
|
||||
assert parsed.victim_id == "76561199000000000"
|
||||
assert parsed.weapon == "M1 GARAND"
|
||||
|
||||
|
||||
def test_parse_team_switch():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[21:34:19 hours (1779108340)] TEAMSWITCH Ekenef (None > Allies)"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "team_switch"
|
||||
assert parsed.player_name == "Ekenef"
|
||||
assert parsed.from_team == "None"
|
||||
assert parsed.to_team == "Allies"
|
||||
|
||||
|
||||
def test_parse_connected():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[21:34:22 hours (1779108337)] CONNECTED Ekenef (76561198109813520)"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "connected"
|
||||
assert parsed.player_name == "Ekenef"
|
||||
assert parsed.player_id == "76561198109813520"
|
||||
|
||||
|
||||
def test_parse_disconnected():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[21:10:53 hours (1779109746)] DISCONNECTED [BxB] Rab◯l◯k◯ (76561198111111111)"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "disconnected"
|
||||
assert parsed.player_name == "[BxB] Rab◯l◯k◯"
|
||||
assert parsed.player_id == "76561198111111111"
|
||||
|
||||
|
||||
def test_parse_chat():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[18:38:35 hours (1779118884)] CHAT[Team][BXB Ivanxu(Axis/6215e24a1f05c5815ed9e8bf185f94fd)]: !vip"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "chat"
|
||||
assert parsed.chat_scope == "Team"
|
||||
assert parsed.player_name == "BXB Ivanxu"
|
||||
assert parsed.chat_team == "Axis"
|
||||
assert parsed.player_id == "6215e24a1f05c5815ed9e8bf185f94fd"
|
||||
assert parsed.content == "!vip"
|
||||
|
||||
|
||||
def test_parse_kick():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[2:09:10 hours (1779178249)] KICK: [[7DV] NEⓇA TACTICAL FEMB✡Y] has been kicked. "
|
||||
"[Making free spaces for members of the Spanish Discord community.]"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "kick"
|
||||
assert parsed.player_name == "[7DV] NEⓇA TACTICAL FEMB✡Y"
|
||||
assert "Making free spaces" in parsed.reason
|
||||
|
||||
|
||||
def test_parse_message_profile():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[21:34:19 hours (1779108340)] MESSAGE: player [Ekenef(76561198109813520)], "
|
||||
"content [─ Ekenef ─\\n▒ Totales ▒\\nbajas : 141 (6 TKs)\\nmuertes : 268 (5 TKs)]"
|
||||
)
|
||||
|
||||
assert parsed.event_type == "message"
|
||||
assert parsed.player_name == "Ekenef"
|
||||
assert parsed.player_id == "76561198109813520"
|
||||
assert "bajas : 141" in parsed.content
|
||||
|
||||
|
||||
def test_parse_player_profile_snapshot_spanish_sections():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
|
||||
"content [─ Jugador Uno ─\n"
|
||||
"▒ Totales ▒\n"
|
||||
"Visto por primera vez : 2026-01-01\n"
|
||||
"sesiones : 12\n"
|
||||
"partidas jugadas : 9\n"
|
||||
"tiempo jugado : 18 h 30 min\n"
|
||||
"bajas : 141 (6 TKs)\n"
|
||||
"muertes : 268 (5 TKs)\n"
|
||||
"K/D : 0,53\n"
|
||||
"▒ VÃctimas ▒\n"
|
||||
"Rival Dos : 7\n"
|
||||
"▒ Némesis ▒\n"
|
||||
"Rival Tres : 4\n"
|
||||
"▒ Armas favoritas ▒\n"
|
||||
"M1 GARAND : 31\n"
|
||||
"▒ Promedios ▒\n"
|
||||
"bajas por partida : 15,6\n"
|
||||
"▒ Sanciones ▒\n"
|
||||
"kicks : 1]"
|
||||
)
|
||||
|
||||
snapshot = parse_rcon_player_profile_snapshot(
|
||||
parsed,
|
||||
event_timestamp="2026-05-19T10:00:00Z",
|
||||
)
|
||||
|
||||
assert snapshot is not None
|
||||
assert snapshot.player_name == "Jugador Uno"
|
||||
assert snapshot.player_id == "steam-profile-1"
|
||||
assert snapshot.source_server_time == 1779108340
|
||||
assert snapshot.sessions == 12
|
||||
assert snapshot.matches_played == 9
|
||||
assert snapshot.total_kills == 141
|
||||
assert snapshot.total_deaths == 268
|
||||
assert snapshot.teamkills_done == 6
|
||||
assert snapshot.teamkills_received == 5
|
||||
assert snapshot.kd_ratio == 0.53
|
||||
assert snapshot.favorite_weapons == {"M1 GARAND": 31}
|
||||
assert snapshot.victims == {"Rival Dos": 7}
|
||||
assert snapshot.nemesis == {"Rival Tres": 4}
|
||||
assert snapshot.averages == {"bajas por partida": 15.6}
|
||||
assert snapshot.sanctions == {"kicks": 1.0}
|
||||
|
||||
|
||||
def test_non_profile_message_does_not_parse_as_profile_snapshot():
|
||||
parsed = parse_rcon_admin_log_message(
|
||||
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
|
||||
"content [Bienvenido al servidor]"
|
||||
)
|
||||
|
||||
assert parse_rcon_player_profile_snapshot(parsed) is None
|
||||
497
backend/tests/test_rcon_admin_log_storage.py
Normal file
497
backend/tests/test_rcon_admin_log_storage.py
Normal file
@@ -0,0 +1,497 @@
|
||||
import gc
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.rcon_admin_log_storage import (
|
||||
initialize_rcon_admin_log_storage,
|
||||
list_current_match_kill_feed,
|
||||
list_rcon_admin_log_event_counts,
|
||||
persist_rcon_admin_log_entries,
|
||||
)
|
||||
|
||||
|
||||
TARGET = {
|
||||
"target_key": "test-rcon-target",
|
||||
"external_server_id": "test-rcon-target",
|
||||
}
|
||||
|
||||
|
||||
def test_initialize_rcon_admin_log_storage_creates_event_table(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
|
||||
resolved_path = initialize_rcon_admin_log_storage(db_path=db_path)
|
||||
|
||||
assert resolved_path == db_path
|
||||
connection = sqlite3.connect(db_path)
|
||||
try:
|
||||
table_names = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
}
|
||||
columns = {
|
||||
row[1]
|
||||
for row in connection.execute("PRAGMA table_info(rcon_admin_log_events)")
|
||||
}
|
||||
finally:
|
||||
connection.close()
|
||||
gc.collect()
|
||||
|
||||
assert "rcon_admin_log_events" in table_names
|
||||
assert "rcon_player_profile_snapshots" in table_names
|
||||
assert {
|
||||
"target_key",
|
||||
"event_type",
|
||||
"raw_message",
|
||||
"canonical_message",
|
||||
"parsed_payload_json",
|
||||
"raw_entry_json",
|
||||
}.issubset(columns)
|
||||
|
||||
|
||||
def test_persist_rcon_admin_log_entries_inserts_then_reports_duplicates(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
entries = [
|
||||
{
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": "[1:00 min (100)] CONNECTED Player One (steam-1)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-19T10:01:00Z",
|
||||
"message": "[2:00 min (120)] DISCONNECTED Player One (steam-1)",
|
||||
},
|
||||
]
|
||||
|
||||
first_delta = persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=entries,
|
||||
db_path=db_path,
|
||||
)
|
||||
second_delta = persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=entries,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert first_delta == {
|
||||
"events_seen": 2,
|
||||
"events_inserted": 2,
|
||||
"duplicate_events": 0,
|
||||
}
|
||||
assert second_delta == {
|
||||
"events_seen": 2,
|
||||
"events_inserted": 0,
|
||||
"duplicate_events": 2,
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_profile_message_snapshots_are_materialized_and_deduped(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
entry = {
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": (
|
||||
"[21:34:19 hours (1779108340)] MESSAGE: player [Jugador Uno(steam-profile-1)], "
|
||||
"content [─ Jugador Uno ─\n"
|
||||
"▒ Totales ▒\n"
|
||||
"sesiones : 12\n"
|
||||
"partidas jugadas : 9\n"
|
||||
"bajas : 141 (6 TKs)\n"
|
||||
"muertes : 268 (5 TKs)\n"
|
||||
"K/D : 0.53\n"
|
||||
"▒ VÃctimas ▒\n"
|
||||
"Rival Dos : 7\n"
|
||||
"▒ Némesis ▒\n"
|
||||
"Rival Tres : 4\n"
|
||||
"▒ Armas favoritas ▒\n"
|
||||
"M1 GARAND : 31\n"
|
||||
"▒ Promedios ▒\n"
|
||||
"bajas por partida : 15.6\n"
|
||||
"▒ Sanciones ▒\n"
|
||||
"kicks : 1]"
|
||||
),
|
||||
}
|
||||
|
||||
persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path)
|
||||
persist_rcon_admin_log_entries(target=TARGET, entries=[entry], db_path=db_path)
|
||||
|
||||
connection = sqlite3.connect(db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = connection.execute("SELECT * FROM rcon_player_profile_snapshots").fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
gc.collect()
|
||||
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["target_key"] == "test-rcon-target"
|
||||
assert row["player_id"] == "steam-profile-1"
|
||||
assert row["source_server_time"] == 1779108340
|
||||
assert row["sessions"] == 12
|
||||
assert row["matches_played"] == 9
|
||||
assert row["total_kills"] == 141
|
||||
assert row["total_deaths"] == 268
|
||||
assert row["teamkills_done"] == 6
|
||||
assert row["teamkills_received"] == 5
|
||||
assert row["kd_ratio"] == 0.53
|
||||
assert json.loads(row["favorite_weapons_json"]) == {"M1 GARAND": 31}
|
||||
assert json.loads(row["victims_json"]) == {"Rival Dos": 7}
|
||||
assert json.loads(row["nemesis_json"]) == {"Rival Tres": 4}
|
||||
assert "bajas : 141" in row["raw_content"]
|
||||
|
||||
|
||||
def test_non_profile_messages_do_not_create_profile_snapshots(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]",
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
connection = sqlite3.connect(db_path)
|
||||
try:
|
||||
count = connection.execute(
|
||||
"SELECT COUNT(*) FROM rcon_player_profile_snapshots"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
gc.collect()
|
||||
|
||||
assert count == 0
|
||||
|
||||
|
||||
def test_canonical_message_dedupes_changing_relative_prefixes(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
original_entry = {
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]",
|
||||
}
|
||||
repeated_read_entry = {
|
||||
"timestamp": "2026-05-19T10:05:00Z",
|
||||
"message": "[6:00 min (100)] MESSAGE: player [Player One(steam-1)], content [hello]",
|
||||
}
|
||||
|
||||
first_delta = persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[original_entry],
|
||||
db_path=db_path,
|
||||
)
|
||||
second_delta = persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[repeated_read_entry],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert first_delta["events_inserted"] == 1
|
||||
assert second_delta == {
|
||||
"events_seen": 1,
|
||||
"events_inserted": 0,
|
||||
"duplicate_events": 1,
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_list_rcon_admin_log_event_counts_groups_by_target_and_event_type(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
other_target = {
|
||||
"target_key": "other-rcon-target",
|
||||
"external_server_id": "other-rcon-target",
|
||||
}
|
||||
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": "[1:00 min (100)] CONNECTED Player One (steam-1)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-19T10:01:00Z",
|
||||
"message": "[2:00 min (120)] DISCONNECTED Player One (steam-1)",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
persist_rcon_admin_log_entries(
|
||||
target=other_target,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-19T10:02:00Z",
|
||||
"message": "[3:00 min (140)] CONNECTED Player Two (steam-2)",
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
counts = {
|
||||
(row["target_key"], row["event_type"]): row
|
||||
for row in list_rcon_admin_log_event_counts(db_path=db_path)
|
||||
}
|
||||
|
||||
assert counts == {
|
||||
("other-rcon-target", "connected"): {
|
||||
"target_key": "other-rcon-target",
|
||||
"event_type": "connected",
|
||||
"event_count": 1,
|
||||
"first_server_time": 140,
|
||||
"last_server_time": 140,
|
||||
},
|
||||
("test-rcon-target", "connected"): {
|
||||
"target_key": "test-rcon-target",
|
||||
"event_type": "connected",
|
||||
"event_count": 1,
|
||||
"first_server_time": 100,
|
||||
"last_server_time": 100,
|
||||
},
|
||||
("test-rcon-target", "disconnected"): {
|
||||
"target_key": "test-rcon-target",
|
||||
"event_type": "disconnected",
|
||||
"event_count": 1,
|
||||
"first_server_time": 120,
|
||||
"last_server_time": 120,
|
||||
},
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_prefers_open_match_window_and_normalizes_rows(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-19T09:59:00Z",
|
||||
"message": (
|
||||
"[0:59 min (90)] KILL: Old Killer(Allies/steam-old) -> "
|
||||
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-19T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-19T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: Alpha(Allies/steam-alpha) -> "
|
||||
"Bravo(Allies/steam-bravo) with GRENADE"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
assert feed["scope"] == "open-admin-log-match-window"
|
||||
assert feed["confidence"] == "admin-log-boundary"
|
||||
assert len(feed["items"]) == 1
|
||||
assert feed["items"][0] == {
|
||||
"event_id": "rcon-admin-log:test-rcon-target:3",
|
||||
"event_timestamp": "2026-05-19T10:01:00Z",
|
||||
"server_time": 120,
|
||||
"killer_name": "Alpha",
|
||||
"killer_team": "Allies",
|
||||
"victim_name": "Bravo",
|
||||
"victim_team": "Allies",
|
||||
"weapon": "GRENADE",
|
||||
"is_teamkill": True,
|
||||
}
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_filters_stale_recent_fallback_rows(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T09:30:00Z",
|
||||
"message": (
|
||||
"[1:00 min (1779355800)] KILL: Old Killer(Allies/steam-old) -> "
|
||||
"Old Victim(Axis/steam-victim-old) with M1 GARAND"
|
||||
),
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert feed["scope"] == "no-current-match-events"
|
||||
assert feed["confidence"] == "stale-filtered"
|
||||
assert feed["stale_events_filtered"] == 1
|
||||
assert feed["items"] == []
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_marks_fresh_recent_fallback_rows_partial(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T09:50:00Z",
|
||||
"message": (
|
||||
"[1:00 min (1779357000)] KILL: Fresh Killer(Allies/steam-fresh) -> "
|
||||
"Fresh Victim(Axis/steam-victim-fresh) with M1 GARAND"
|
||||
),
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
now=datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert feed["scope"] == "recent-admin-log-window"
|
||||
assert feed["confidence"] == "partial"
|
||||
assert feed["stale_events_filtered"] == 0
|
||||
assert [item["killer_name"] for item in feed["items"]] == ["Fresh Killer"]
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_filters_rows_before_incremental_cursor(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> "
|
||||
"First Victim(Axis/steam-first-victim) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:02:00Z",
|
||||
"message": (
|
||||
"[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> "
|
||||
"Next Victim(Allies/steam-next-victim) with MP40"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
since_event_id="rcon-admin-log:test-rcon-target:2",
|
||||
)
|
||||
|
||||
assert [item["killer_name"] for item in feed["items"]] == ["Next Killer"]
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_without_cursor_omits_nullable_id_predicate(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: Cursor Killer(Allies/steam-cursor) -> "
|
||||
"Cursor Victim(Axis/steam-cursor-victim) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
traced_sql = []
|
||||
connect = sqlite3.connect
|
||||
|
||||
def connect_with_trace(*args, **kwargs):
|
||||
connection = connect(*args, **kwargs)
|
||||
connection.set_trace_callback(traced_sql.append)
|
||||
return connection
|
||||
|
||||
with patch(
|
||||
"app.rcon_admin_log_storage.sqlite3.connect",
|
||||
side_effect=connect_with_trace,
|
||||
):
|
||||
feed = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
kill_queries = [
|
||||
sql
|
||||
for sql in traced_sql
|
||||
if "FROM rcon_admin_log_events" in sql and "event_type = 'kill'" in sql
|
||||
]
|
||||
assert [item["killer_name"] for item in feed["items"]] == ["Cursor Killer"]
|
||||
assert kill_queries
|
||||
assert all("IS NULL OR id >" not in sql for sql in kill_queries)
|
||||
assert all("AND id >" not in sql for sql in kill_queries)
|
||||
gc.collect()
|
||||
|
||||
|
||||
def test_current_match_kill_feed_invalid_cursor_behaves_like_no_cursor(tmp_path):
|
||||
db_path = tmp_path / "admin_log.sqlite3"
|
||||
persist_rcon_admin_log_entries(
|
||||
target=TARGET,
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-21T10:00:00Z",
|
||||
"message": "[1:00 min (100)] MATCH START Mortain Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:01:00Z",
|
||||
"message": (
|
||||
"[2:00 min (120)] KILL: First Killer(Allies/steam-first) -> "
|
||||
"First Victim(Axis/steam-first-victim) with M1 GARAND"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-21T10:02:00Z",
|
||||
"message": (
|
||||
"[3:00 min (140)] KILL: Next Killer(Axis/steam-next) -> "
|
||||
"Next Victim(Allies/steam-next-victim) with MP40"
|
||||
),
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
without_cursor = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
)
|
||||
with_invalid_cursor = list_current_match_kill_feed(
|
||||
server_key="test-rcon-target",
|
||||
db_path=db_path,
|
||||
since_event_id="not-an-admin-log-event",
|
||||
)
|
||||
|
||||
assert with_invalid_cursor == without_cursor
|
||||
gc.collect()
|
||||
171
backend/tests/test_rcon_historical_backfill.py
Normal file
171
backend/tests/test_rcon_historical_backfill.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from contextlib import closing
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.rcon_admin_log_materialization import (
|
||||
MATCH_RESULT_SOURCE,
|
||||
initialize_rcon_materialized_storage,
|
||||
)
|
||||
from app.rcon_historical_backfill import (
|
||||
count_recent_materialized_closed_matches,
|
||||
run_rcon_historical_backfill,
|
||||
select_backfill_targets,
|
||||
)
|
||||
from app.rcon_historical_leaderboards import list_rcon_materialized_leaderboard
|
||||
|
||||
|
||||
TARGETS_JSON = json.dumps(
|
||||
[
|
||||
{
|
||||
"name": "Comunidad Hispana #01",
|
||||
"slug": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
"host": "127.0.0.1",
|
||||
"port": 7779,
|
||||
"password": "secret",
|
||||
},
|
||||
{
|
||||
"name": "Comunidad Hispana #02",
|
||||
"slug": "comunidad-hispana-02",
|
||||
"external_server_id": "comunidad-hispana-02",
|
||||
"host": "127.0.0.1",
|
||||
"port": 7879,
|
||||
"password": "secret",
|
||||
},
|
||||
{
|
||||
"name": "Comunidad Hispana #03",
|
||||
"slug": "comunidad-hispana-03",
|
||||
"external_server_id": "comunidad-hispana-03",
|
||||
"host": "127.0.0.1",
|
||||
"port": 7979,
|
||||
"password": "secret",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class RconHistoricalBackfillTests(unittest.TestCase):
|
||||
def test_monthly_window_selects_previous_month_on_days_1_to_7(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
||||
payload = list_rcon_materialized_leaderboard(
|
||||
server_key="all-servers",
|
||||
timeframe="monthly",
|
||||
metric="kills",
|
||||
db_path=Path(tmpdir) / "historical.sqlite3",
|
||||
now=datetime(2026, 5, 7, 12, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["window_kind"], "previous-month")
|
||||
self.assertEqual(payload["selected_month_start"], "2026-04-01T00:00:00Z")
|
||||
self.assertEqual(payload["selected_month_end"], "2026-05-01T00:00:00Z")
|
||||
|
||||
def test_monthly_window_selects_current_month_on_day_8_plus(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
|
||||
payload = list_rcon_materialized_leaderboard(
|
||||
server_key="all-servers",
|
||||
timeframe="monthly",
|
||||
metric="kills",
|
||||
db_path=Path(tmpdir) / "historical.sqlite3",
|
||||
now=datetime(2026, 5, 8, 12, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["window_kind"], "current-month")
|
||||
self.assertEqual(payload["selected_month_start"], "2026-05-01T00:00:00Z")
|
||||
|
||||
def test_recent_match_ensure_stops_when_count_is_already_satisfied(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets():
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
_insert_closed_matches(db_path, 100)
|
||||
|
||||
payload = run_rcon_historical_backfill(
|
||||
servers="comunidad-hispana-01,comunidad-hispana-02",
|
||||
ensure_recent_matches=100,
|
||||
dry_run=True,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["recent_materialized_closed_match_count_before"], 100)
|
||||
self.assertEqual(payload["actual_windows_scanned"], [])
|
||||
|
||||
def test_unknown_server_is_rejected(self) -> None:
|
||||
with _patched_targets():
|
||||
with self.assertRaises(ValueError):
|
||||
select_backfill_targets("unknown-server")
|
||||
|
||||
def test_comunidad_hispana_03_is_not_included_by_default(self) -> None:
|
||||
with _patched_targets():
|
||||
selected = select_backfill_targets(None)
|
||||
|
||||
self.assertEqual(
|
||||
[target.external_server_id for target in selected],
|
||||
["comunidad-hispana-01", "comunidad-hispana-02"],
|
||||
)
|
||||
|
||||
def test_dry_run_does_not_insert_data(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets():
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
payload = run_rcon_historical_backfill(
|
||||
servers="comunidad-hispana-01",
|
||||
ensure_current_month=True,
|
||||
dry_run=True,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
count_after = count_recent_materialized_closed_matches(db_path=db_path)
|
||||
|
||||
self.assertEqual(payload["status"], "dry-run")
|
||||
self.assertEqual(payload["events_inserted"], 0)
|
||||
self.assertEqual(count_after, 0)
|
||||
|
||||
def test_backfill_output_is_json_serializable(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir, _patched_targets():
|
||||
payload = run_rcon_historical_backfill(
|
||||
servers="comunidad-hispana-01",
|
||||
ensure_current_month=True,
|
||||
dry_run=True,
|
||||
db_path=Path(tmpdir) / "historical.sqlite3",
|
||||
)
|
||||
|
||||
json.dumps(payload, ensure_ascii=True)
|
||||
|
||||
|
||||
def _insert_closed_matches(db_path: Path, count: int) -> None:
|
||||
initialize_rcon_materialized_storage(db_path=db_path)
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
for index in range(count):
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO rcon_materialized_matches (
|
||||
target_key, external_server_id, match_key, map_name, map_pretty_name,
|
||||
started_at, ended_at, confidence_mode, source_basis
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"comunidad-hispana-01",
|
||||
"comunidad-hispana-01",
|
||||
f"match-{index}",
|
||||
"stmariedumont",
|
||||
"ST MARIE DU MONT",
|
||||
"2026-05-01T10:00:00Z",
|
||||
f"2026-05-{(index % 28) + 1:02d}T12:00:00Z",
|
||||
"exact",
|
||||
MATCH_RESULT_SOURCE,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
def _patched_targets():
|
||||
return patch.dict(os.environ, {"HLL_BACKEND_RCON_TARGETS": TARGETS_JSON})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
171
backend/tests/test_rcon_historical_worker.py
Normal file
171
backend/tests/test_rcon_historical_worker.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.rcon_historical_worker import (
|
||||
CAPTURE_MODE_CURRENT_LIVE,
|
||||
CAPTURE_MODE_HISTORICAL,
|
||||
build_arg_parser,
|
||||
main,
|
||||
run_rcon_historical_capture,
|
||||
run_rcon_historical_capture_unlocked,
|
||||
)
|
||||
|
||||
|
||||
TARGET = SimpleNamespace(
|
||||
external_server_id="comunidad-hispana-01",
|
||||
name="Comunidad Hispana #01",
|
||||
host="203.0.113.10",
|
||||
port=7779,
|
||||
region="ES",
|
||||
game_port=7777,
|
||||
query_port=7778,
|
||||
source_name="community-hispana-rcon",
|
||||
)
|
||||
|
||||
|
||||
class RconHistoricalWorkerTests(unittest.TestCase):
|
||||
def test_current_live_capture_skips_materialization(self) -> None:
|
||||
with (
|
||||
patch("app.rcon_historical_worker.initialize_rcon_historical_storage"),
|
||||
patch("app.rcon_historical_worker._select_targets", return_value=[TARGET]),
|
||||
patch(
|
||||
"app.rcon_historical_worker.query_live_server_sample",
|
||||
return_value={"normalized": {"players": 10}, "raw_session": {"raw": True}},
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_worker.persist_rcon_historical_sample",
|
||||
return_value={"samples_inserted": 1, "duplicate_samples": 0},
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_worker._ingest_target_admin_log",
|
||||
return_value={
|
||||
"status": "ok",
|
||||
"errors": [],
|
||||
"totals": {
|
||||
"events_seen": 4,
|
||||
"events_inserted": 2,
|
||||
"duplicate_events": 2,
|
||||
"failed_targets": 0,
|
||||
},
|
||||
},
|
||||
),
|
||||
patch("app.rcon_historical_worker.start_rcon_historical_capture_run", return_value=101),
|
||||
patch("app.rcon_historical_worker.finalize_rcon_historical_capture_run"),
|
||||
patch(
|
||||
"app.rcon_historical_worker.list_rcon_historical_target_statuses",
|
||||
return_value=[{"target_key": "comunidad-hispana-01"}],
|
||||
),
|
||||
patch("app.rcon_historical_worker.materialize_rcon_admin_log") as materialize,
|
||||
):
|
||||
payload = run_rcon_historical_capture_unlocked(capture_mode=CAPTURE_MODE_CURRENT_LIVE)
|
||||
|
||||
materialize.assert_not_called()
|
||||
self.assertEqual(payload["capture_mode"], CAPTURE_MODE_CURRENT_LIVE)
|
||||
self.assertIs(payload["materialization_skipped"], True)
|
||||
self.assertEqual(payload["admin_log_events_seen"], 4)
|
||||
self.assertEqual(payload["admin_log_events_inserted"], 2)
|
||||
self.assertEqual(payload["duplicate_events"], 2)
|
||||
self.assertEqual(payload["samples_inserted"], 1)
|
||||
self.assertEqual(payload["materialization_result"]["status"], "skipped")
|
||||
|
||||
def test_historical_capture_keeps_materialization(self) -> None:
|
||||
with (
|
||||
patch("app.rcon_historical_worker.initialize_rcon_historical_storage"),
|
||||
patch("app.rcon_historical_worker._select_targets", return_value=[TARGET]),
|
||||
patch(
|
||||
"app.rcon_historical_worker.query_live_server_sample",
|
||||
return_value={"normalized": {"players": 10}, "raw_session": {"raw": True}},
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_worker.persist_rcon_historical_sample",
|
||||
return_value={"samples_inserted": 1, "duplicate_samples": 0},
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_worker._ingest_target_admin_log",
|
||||
return_value={
|
||||
"status": "ok",
|
||||
"errors": [],
|
||||
"totals": {
|
||||
"events_seen": 1,
|
||||
"events_inserted": 1,
|
||||
"duplicate_events": 0,
|
||||
"failed_targets": 0,
|
||||
},
|
||||
},
|
||||
),
|
||||
patch("app.rcon_historical_worker.start_rcon_historical_capture_run", return_value=102),
|
||||
patch("app.rcon_historical_worker.finalize_rcon_historical_capture_run"),
|
||||
patch(
|
||||
"app.rcon_historical_worker.list_rcon_historical_target_statuses",
|
||||
return_value=[{"target_key": "comunidad-hispana-01"}],
|
||||
),
|
||||
patch(
|
||||
"app.rcon_historical_worker.materialize_rcon_admin_log",
|
||||
return_value={"matches_materialized": 3, "matches_updated": 2},
|
||||
) as materialize,
|
||||
):
|
||||
payload = run_rcon_historical_capture_unlocked(capture_mode=CAPTURE_MODE_HISTORICAL)
|
||||
|
||||
materialize.assert_called_once_with()
|
||||
self.assertEqual(payload["capture_mode"], CAPTURE_MODE_HISTORICAL)
|
||||
self.assertIs(payload["materialization_skipped"], False)
|
||||
self.assertEqual(payload["totals"]["materialized_matches_inserted"], 3)
|
||||
self.assertEqual(payload["totals"]["materialized_matches_updated"], 2)
|
||||
|
||||
def test_cli_and_env_can_activate_current_live_mode(self) -> None:
|
||||
with _temporary_env(
|
||||
HLL_RCON_CURRENT_MATCH_MODE="true",
|
||||
HLL_RCON_CURRENT_MATCH_CAPTURE_INTERVAL_SECONDS="5",
|
||||
):
|
||||
args = build_arg_parser().parse_args(["loop"])
|
||||
with patch("app.rcon_historical_worker.run_periodic_rcon_historical_capture") as runner:
|
||||
exit_code = main(["loop"])
|
||||
|
||||
self.assertEqual(args.capture_mode, CAPTURE_MODE_CURRENT_LIVE)
|
||||
self.assertEqual(exit_code, 0)
|
||||
runner.assert_called_once()
|
||||
self.assertEqual(runner.call_args.kwargs["capture_mode"], CAPTURE_MODE_CURRENT_LIVE)
|
||||
self.assertEqual(runner.call_args.kwargs["interval_seconds"], 5)
|
||||
|
||||
explicit = build_arg_parser().parse_args(["capture", "--skip-materialization"])
|
||||
self.assertIs(explicit.skip_materialization, True)
|
||||
|
||||
def test_current_live_capture_uses_short_lock_timeout(self) -> None:
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
@contextmanager
|
||||
def fake_lock(**kwargs):
|
||||
seen.update(kwargs)
|
||||
yield {"holder": kwargs["holder"]}
|
||||
|
||||
with (
|
||||
_temporary_env(HLL_RCON_CURRENT_MATCH_WRITER_LOCK_TIMEOUT_SECONDS="3.5"),
|
||||
patch("app.rcon_historical_worker.backend_writer_lock", side_effect=fake_lock),
|
||||
patch(
|
||||
"app.rcon_historical_worker.run_rcon_historical_capture_unlocked",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
):
|
||||
run_rcon_historical_capture(capture_mode=CAPTURE_MODE_CURRENT_LIVE)
|
||||
|
||||
self.assertEqual(seen["timeout_seconds"], 3.5)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _temporary_env(**values: str):
|
||||
previous = {name: os.environ.get(name) for name in values}
|
||||
try:
|
||||
for name, value in values.items():
|
||||
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
|
||||
400
backend/tests/test_rcon_materialization_pipeline.py
Normal file
400
backend/tests/test_rcon_materialization_pipeline.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""Regression tests for the materialized RCON AdminLog pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.historical_storage import upsert_historical_match
|
||||
from app.payloads import build_recent_historical_matches_payload
|
||||
from app.rcon_admin_log_materialization import (
|
||||
get_materialized_rcon_match_detail,
|
||||
materialize_rcon_admin_log,
|
||||
summarize_rcon_materialization_status,
|
||||
)
|
||||
from app.rcon_admin_log_storage import persist_rcon_admin_log_entries
|
||||
from app.rcon_historical_read_model import (
|
||||
get_rcon_historical_match_detail,
|
||||
list_rcon_historical_recent_activity,
|
||||
)
|
||||
from app.scoreboard_origins import resolve_trusted_scoreboard_match_url
|
||||
|
||||
|
||||
class RconMaterializationPipelineTests(unittest.TestCase):
|
||||
def test_materializes_match_result_and_player_stats_idempotently(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
_persist_admin_log_fixture(db_path)
|
||||
|
||||
first = materialize_rcon_admin_log(db_path=db_path)
|
||||
second = materialize_rcon_admin_log(db_path=db_path)
|
||||
detail = get_materialized_rcon_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_key="comunidad-hispana-01:100:500:stmariedumontwarfare",
|
||||
db_path=db_path,
|
||||
)
|
||||
status = summarize_rcon_materialization_status(db_path=db_path)
|
||||
|
||||
self.assertEqual(first["matches_materialized"], 1)
|
||||
self.assertEqual(second["matches_materialized"], 0)
|
||||
self.assertEqual(second["matches_updated"], 1)
|
||||
self.assertIsNotNone(detail)
|
||||
match = detail["match"]
|
||||
self.assertEqual(match["allied_score"], 5)
|
||||
self.assertEqual(match["axis_score"], 0)
|
||||
self.assertEqual(match["winner"], "allied")
|
||||
players = {row["player_name"]: row for row in detail["players"]}
|
||||
self.assertEqual(players["Alpha"]["kills"], 1)
|
||||
self.assertEqual(players["Alpha"]["teamkills"], 1)
|
||||
self.assertEqual(players["Bravo"]["deaths"], 1)
|
||||
self.assertEqual(players["Charlie"]["deaths_by_teamkill"], 1)
|
||||
self.assertEqual(status["materialized_matches"], 1)
|
||||
self.assertEqual(status["matches_with_player_stats"], 1)
|
||||
gc.collect()
|
||||
|
||||
def test_match_detail_read_model_hides_raw_player_ids(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_admin_log_fixture(db_path)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id="comunidad-hispana-01:100:500:stmariedumontwarfare",
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertEqual(detail["result_source"], "admin-log-match-ended")
|
||||
self.assertEqual(detail["result"]["allied_score"], 5)
|
||||
self.assertEqual(detail["timestamp_confidence"], "absolute")
|
||||
players = {row["player_name"]: row for row in detail["players"]}
|
||||
self.assertNotIn("player_id", players["Alpha"])
|
||||
self.assertIn("kd_ratio", players["Alpha"])
|
||||
self.assertEqual(players["Alpha"]["steam_id_64"], "76561198000000001")
|
||||
self.assertEqual(players["Alpha"]["platform"], "steam")
|
||||
self.assertEqual(
|
||||
players["Alpha"]["external_profile_links"]["hellor"],
|
||||
"https://hellor.pro/player/76561198000000001",
|
||||
)
|
||||
self.assertEqual(players["Charlie"]["platform"], "unknown")
|
||||
self.assertNotIn("steam_id_64", players["Charlie"])
|
||||
self.assertNotIn("external_profile_links", players["Charlie"])
|
||||
gc.collect()
|
||||
|
||||
def test_match_detail_marks_equal_materialized_timestamps_as_server_time_only(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-01T12:00:00Z",
|
||||
"message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T12:00:00Z",
|
||||
"message": "[91 min (5500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id="comunidad-hispana-01:100:5500:stmariedumontwarfare",
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIsNone(detail["started_at"])
|
||||
self.assertIsNone(detail["ended_at"])
|
||||
self.assertEqual(detail["closed_at"], "2026-05-01T12:00:00Z")
|
||||
self.assertEqual(detail["timestamp_confidence"], "server-time-only")
|
||||
self.assertEqual(detail["duration_seconds"], 5400)
|
||||
gc.collect()
|
||||
|
||||
def test_equal_timestamp_materialized_detail_uses_closed_at_window_for_scoreboard_link(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
upsert_historical_match(
|
||||
server_slug="comunidad-hispana-02",
|
||||
match_payload={
|
||||
"id": "1779183861",
|
||||
"creation_time": "2026-05-01T10:30:00Z",
|
||||
"start": "2026-05-01T10:30:00Z",
|
||||
"end": "2026-05-01T12:00:00Z",
|
||||
"map": {"name": "ST MARIE DU MONT Warfare"},
|
||||
"result": {"allied": 5, "axis": 0},
|
||||
"player_stats": [],
|
||||
},
|
||||
db_path=db_path,
|
||||
)
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-02",
|
||||
"external_server_id": "comunidad-hispana-02",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-01T12:00:00Z",
|
||||
"message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T12:00:00Z",
|
||||
"message": "[91 min (5500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-02",
|
||||
match_id="comunidad-hispana-02:100:5500:stmariedumontwarfare",
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIsNone(detail["started_at"])
|
||||
self.assertIsNone(detail["ended_at"])
|
||||
self.assertEqual(detail["duration_seconds"], 5400)
|
||||
self.assertEqual(
|
||||
detail["match_url"],
|
||||
"https://scoreboard.comunidadhll.es:5443/games/1779183861",
|
||||
)
|
||||
gc.collect()
|
||||
|
||||
def test_match_detail_adds_safe_profile_summary_when_snapshot_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_admin_log_fixture(db_path)
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-01T10:30:00Z",
|
||||
"message": (
|
||||
"[31 min (300)] MESSAGE: player [Alpha(76561198000000001)], "
|
||||
"content [─ Alpha ─\n"
|
||||
"▒ Totales ▒\n"
|
||||
"sesiones : 12\n"
|
||||
"partidas jugadas : 9\n"
|
||||
"bajas : 141 (6 TKs)\n"
|
||||
"muertes : 268 (5 TKs)\n"
|
||||
"K/D : 0.53\n"
|
||||
"▒ Armas favoritas ▒\n"
|
||||
"M1 Garand : 31]"
|
||||
),
|
||||
}
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id="comunidad-hispana-01:100:500:stmariedumontwarfare",
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
players = {row["player_name"]: row for row in detail["players"]}
|
||||
self.assertIn("profile_summary", players["Alpha"])
|
||||
self.assertNotIn("profile_summary", players["Bravo"])
|
||||
profile_summary = players["Alpha"]["profile_summary"]
|
||||
self.assertEqual(profile_summary["sessions"], 12)
|
||||
self.assertEqual(profile_summary["matches_played"], 9)
|
||||
self.assertEqual(profile_summary["totals"]["kills"], 141)
|
||||
self.assertEqual(profile_summary["favorite_weapons"], {"M1 Garand": 31})
|
||||
self.assertNotIn("raw_content", profile_summary)
|
||||
self.assertNotIn("player_id", players["Alpha"])
|
||||
gc.collect()
|
||||
|
||||
def test_recent_matches_prefer_materialized_rcon_over_scoreboard_fallback(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_admin_log_fixture(db_path)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
_persist_scoreboard_match(db_path)
|
||||
|
||||
payload = build_recent_historical_matches_payload(
|
||||
limit=5,
|
||||
server_slug="comunidad-hispana-01",
|
||||
)
|
||||
recent = list_rcon_historical_recent_activity(
|
||||
server_key="comunidad-hispana-01",
|
||||
limit=5,
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertEqual(payload["data"]["selected_source"], "rcon")
|
||||
self.assertEqual(payload["data"]["items"][0]["result_source"], "admin-log-match-ended")
|
||||
self.assertEqual(recent[0]["result_source"], "admin-log-match-ended")
|
||||
self.assertNotEqual(payload["data"]["selected_source"], "public-scoreboard")
|
||||
gc.collect()
|
||||
|
||||
def test_recent_materialized_detail_id_resolves_through_detail_read_model(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_admin_log_fixture(db_path)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
recent = list_rcon_historical_recent_activity(
|
||||
server_key="comunidad-hispana-01",
|
||||
limit=1,
|
||||
)[0]
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id=str(recent["internal_detail_match_id"]),
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertEqual(detail["match_id"], recent["internal_detail_match_id"])
|
||||
gc.collect()
|
||||
|
||||
def test_public_scoreboard_fallback_used_only_without_rcon_activity(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_scoreboard_match(db_path)
|
||||
payload = build_recent_historical_matches_payload(
|
||||
limit=5,
|
||||
server_slug="comunidad-hispana-01",
|
||||
)
|
||||
finally:
|
||||
_restore_env("HLL_BACKEND_STORAGE_PATH", previous_storage_path)
|
||||
|
||||
self.assertTrue(payload["data"]["fallback_used"])
|
||||
self.assertEqual(payload["data"]["selected_source"], "public-scoreboard")
|
||||
self.assertEqual(payload["data"]["items"][0]["result_source"], "public-scoreboard-fallback")
|
||||
gc.collect()
|
||||
|
||||
def test_safe_scoreboard_match_url_allowlist_for_active_origins(self) -> None:
|
||||
self.assertEqual(
|
||||
resolve_trusted_scoreboard_match_url(
|
||||
"https://scoreboard.comunidadhll.es/games/1561515",
|
||||
"comunidad-hispana-01",
|
||||
),
|
||||
"https://scoreboard.comunidadhll.es/games/1561515",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_trusted_scoreboard_match_url(
|
||||
"https://scoreboard.comunidadhll.es:5443/games/222",
|
||||
"comunidad-hispana-02",
|
||||
),
|
||||
"https://scoreboard.comunidadhll.es:5443/games/222",
|
||||
)
|
||||
self.assertIsNone(
|
||||
resolve_trusted_scoreboard_match_url(
|
||||
"https://example.com/games/222",
|
||||
"comunidad-hispana-02",
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
resolve_trusted_scoreboard_match_url(
|
||||
"https://scoreboard.comunidadhll.es:5443/admin/222",
|
||||
"comunidad-hispana-02",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _persist_admin_log_fixture(db_path: Path) -> None:
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-01T10:00:00Z",
|
||||
"message": "[1 min (100)] MATCH START ST MARIE DU MONT Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T10:05:00Z",
|
||||
"message": "[6 min (150)] CONNECTED Alpha (76561198000000001)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T10:06:00Z",
|
||||
"message": "[7 min (160)] TEAMSWITCH Alpha (None > Allies)",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T10:10:00Z",
|
||||
"message": (
|
||||
"[11 min (200)] KILL: Alpha(Allies/76561198000000001) -> "
|
||||
"Bravo(Axis/76561198000000002) with M1 Garand"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T10:12:00Z",
|
||||
"message": (
|
||||
"[13 min (220)] KILL: Alpha(Allies/76561198000000001) -> "
|
||||
"Charlie(Allies/nonsteam-local) with M1 Garand"
|
||||
),
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-01T11:20:00Z",
|
||||
"message": "[81 min (500)] MATCH ENDED `ST MARIE DU MONT Warfare` ALLIED (5 - 0) AXIS",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
|
||||
def _persist_scoreboard_match(db_path: Path) -> None:
|
||||
upsert_historical_match(
|
||||
server_slug="comunidad-hispana-01",
|
||||
match_payload={
|
||||
"id": "1561515",
|
||||
"creation_time": "2026-05-01T10:00:00Z",
|
||||
"start": "2026-05-01T10:00:00Z",
|
||||
"end": "2026-05-01T11:20:00Z",
|
||||
"map": {"name": "ST MARIE DU MONT Warfare"},
|
||||
"result": {"allied": 2, "axis": 3},
|
||||
"player_stats": [],
|
||||
},
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
|
||||
def _restore_env(name: str, previous_value: str | None) -> None:
|
||||
if previous_value is None:
|
||||
os.environ.pop(name, None)
|
||||
else:
|
||||
os.environ[name] = previous_value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
436
backend/tests/test_scoreboard_match_links.py
Normal file
436
backend/tests/test_scoreboard_match_links.py
Normal file
@@ -0,0 +1,436 @@
|
||||
"""Regression checks for persisted public-scoreboard match links."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.scoreboard_candidate_backfill import run_backfill
|
||||
from app.historical_storage import (
|
||||
get_historical_match_detail,
|
||||
initialize_historical_storage,
|
||||
list_recent_historical_matches,
|
||||
upsert_historical_match,
|
||||
)
|
||||
from app.rcon_historical_storage import initialize_rcon_historical_storage
|
||||
from app.rcon_historical_storage import persist_rcon_historical_sample
|
||||
from app.rcon_historical_storage import start_rcon_historical_capture_run
|
||||
from app.rcon_historical_read_model import get_rcon_historical_match_detail
|
||||
from app.rcon_admin_log_materialization import materialize_rcon_admin_log
|
||||
from app.rcon_admin_log_storage import persist_rcon_admin_log_entries
|
||||
from app.rcon_scoreboard_relink import relink_materialized_matches
|
||||
from app.scoreboard_correlation_diagnostics import inspect_materialized_match_correlation
|
||||
|
||||
|
||||
class PersistedScoreboardMatchLinkTests(unittest.TestCase):
|
||||
def test_list_backfill_persists_foy_candidate_before_detail_fetch_failure(self) -> None:
|
||||
stored: dict[tuple[str, str], dict[str, object]] = {}
|
||||
|
||||
class FoyListProvider:
|
||||
def fetch_match_page(self, *, base_url: str, page: int, limit: int) -> dict[str, object]:
|
||||
return {"maps": [_foy_list_match()]} if page == 1 else {"maps": []}
|
||||
|
||||
def fetch_match_details(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
match_ids: list[str],
|
||||
max_workers: int,
|
||||
) -> list[dict[str, object]]:
|
||||
raise RuntimeError("detail endpoint unavailable")
|
||||
|
||||
def fake_upsert(*, server_slug: str, candidate: dict[str, object]) -> str:
|
||||
key = (server_slug, str(candidate["external_match_id"]))
|
||||
outcome = "updated" if key in stored else "inserted"
|
||||
stored[key] = dict(candidate)
|
||||
return outcome
|
||||
|
||||
server = {
|
||||
"slug": "comunidad-hispana-02",
|
||||
"scoreboard_base_url": "https://scoreboard.comunidadhll.es:5443",
|
||||
"server_number": 2,
|
||||
}
|
||||
with (
|
||||
patch("app.scoreboard_candidate_backfill.initialize_historical_storage"),
|
||||
patch(
|
||||
"app.scoreboard_candidate_backfill.PublicScoreboardHistoricalDataSource",
|
||||
return_value=FoyListProvider(),
|
||||
),
|
||||
patch(
|
||||
"app.scoreboard_candidate_backfill.upsert_scoreboard_candidate",
|
||||
side_effect=fake_upsert,
|
||||
),
|
||||
):
|
||||
first = run_backfill(
|
||||
server=server,
|
||||
start_at=_backfill_timestamp("2026-05-20T00:00:00Z"),
|
||||
end_at=_backfill_timestamp("2026-05-21T23:59:59Z"),
|
||||
max_pages=2,
|
||||
page_size=100,
|
||||
detail_workers=1,
|
||||
)
|
||||
second = run_backfill(
|
||||
server=server,
|
||||
start_at=_backfill_timestamp("2026-05-20T00:00:00Z"),
|
||||
end_at=_backfill_timestamp("2026-05-21T23:59:59Z"),
|
||||
max_pages=2,
|
||||
page_size=100,
|
||||
detail_workers=1,
|
||||
)
|
||||
|
||||
candidate = stored[("comunidad-hispana-02", "1562115")]
|
||||
self.assertEqual(
|
||||
candidate["match_url"],
|
||||
"https://scoreboard.comunidadhll.es:5443/games/1562115",
|
||||
)
|
||||
self.assertEqual(first["list_candidates_inserted"], 1)
|
||||
self.assertEqual(first["list_candidates_updated"], 0)
|
||||
self.assertEqual(first["errors"][0]["stage"], "fetch_match_details")
|
||||
self.assertEqual(second["list_candidates_inserted"], 0)
|
||||
self.assertEqual(second["list_candidates_updated"], 1)
|
||||
self.assertEqual(len(stored), 1)
|
||||
|
||||
def test_recent_and_detail_payloads_expose_safe_persisted_match_url(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
match_url = "https://scoreboard.comunidadhll.es:5443/games/12345"
|
||||
_persist_match(db_path, server_slug="comunidad-hispana-02", match_id="12345")
|
||||
|
||||
recent_items = list_recent_historical_matches(
|
||||
server_slug="comunidad-hispana-02",
|
||||
limit=5,
|
||||
db_path=db_path,
|
||||
)
|
||||
detail = get_historical_match_detail(
|
||||
server_slug="comunidad-hispana-02",
|
||||
match_id="12345",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertEqual(recent_items[0]["match_url"], match_url)
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertEqual(detail["match_url"], match_url)
|
||||
gc.collect()
|
||||
|
||||
def test_untrusted_persisted_match_url_is_not_exposed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
_persist_match(db_path, server_slug="comunidad-hispana-01", match_id="999")
|
||||
_set_raw_payload_ref(
|
||||
db_path,
|
||||
match_id="999",
|
||||
raw_payload_ref="https://scoreboard.comunidadhll.es:3443/games/999",
|
||||
)
|
||||
|
||||
recent_items = list_recent_historical_matches(
|
||||
server_slug="comunidad-hispana-01",
|
||||
limit=5,
|
||||
db_path=db_path,
|
||||
)
|
||||
detail = get_historical_match_detail(
|
||||
server_slug="comunidad-hispana-01",
|
||||
match_id="999",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertIsNone(recent_items[0]["match_url"])
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIsNone(detail["match_url"])
|
||||
gc.collect()
|
||||
|
||||
def test_detail_player_links_use_trusted_scoreboard_steam_id(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
_persist_match(
|
||||
db_path,
|
||||
server_slug="comunidad-hispana-02",
|
||||
match_id="steam-player-match",
|
||||
player_stats=[
|
||||
{
|
||||
"player": "Steam Player",
|
||||
"steaminfo": {"profile": {"steamid": "76561198000000009"}},
|
||||
"team": {"side": "allies"},
|
||||
"kills": 4,
|
||||
"deaths": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
detail = get_historical_match_detail(
|
||||
server_slug="comunidad-hispana-02",
|
||||
match_id="steam-player-match",
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
player = detail["players"][0]
|
||||
self.assertEqual(player["steam_id_64"], "76561198000000009")
|
||||
self.assertEqual(player["platform"], "steam")
|
||||
self.assertEqual(
|
||||
player["external_profile_links"]["hll_records"],
|
||||
"https://hllrecords.com/profiles/76561198000000009",
|
||||
)
|
||||
gc.collect()
|
||||
|
||||
def test_rcon_match_detail_does_not_fabricate_external_scoreboard_url(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
initialize_rcon_historical_storage(db_path=db_path)
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id="rcon:synthetic-window",
|
||||
)
|
||||
finally:
|
||||
if previous_storage_path is None:
|
||||
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
|
||||
else:
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
|
||||
|
||||
self.assertIsNone(detail)
|
||||
gc.collect()
|
||||
|
||||
def test_rcon_match_detail_exposes_correlated_scoreboard_url_on_strong_evidence(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_match(
|
||||
db_path,
|
||||
server_slug="comunidad-hispana-01",
|
||||
match_id="1561515",
|
||||
map_name="St. Mere Eglise",
|
||||
started_at="2026-04-12T16:20:00Z",
|
||||
ended_at="2026-04-12T17:45:00Z",
|
||||
)
|
||||
session_key = _persist_rcon_window(
|
||||
db_path,
|
||||
map_name="St. Mere Eglise",
|
||||
first_seen_at="2026-04-12T16:28:55.761810Z",
|
||||
last_seen_at="2026-04-12T16:43:55.761810Z",
|
||||
players=94,
|
||||
max_players=98,
|
||||
)
|
||||
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id=session_key,
|
||||
)
|
||||
finally:
|
||||
if previous_storage_path is None:
|
||||
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
|
||||
else:
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertEqual(
|
||||
detail["match_url"],
|
||||
"https://scoreboard.comunidadhll.es/games/1561515",
|
||||
)
|
||||
gc.collect()
|
||||
|
||||
def test_rcon_match_detail_keeps_low_confidence_correlation_unlinked(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_match(
|
||||
db_path,
|
||||
server_slug="comunidad-hispana-01",
|
||||
match_id="1561515",
|
||||
map_name="Carentan",
|
||||
started_at="2026-04-12T10:00:00Z",
|
||||
ended_at="2026-04-12T11:30:00Z",
|
||||
)
|
||||
session_key = _persist_rcon_window(
|
||||
db_path,
|
||||
map_name="St. Mere Eglise",
|
||||
first_seen_at="2026-04-12T16:28:55.761810Z",
|
||||
last_seen_at="2026-04-12T16:43:55.761810Z",
|
||||
players=94,
|
||||
max_players=98,
|
||||
)
|
||||
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-01",
|
||||
match_id=session_key,
|
||||
)
|
||||
finally:
|
||||
if previous_storage_path is None:
|
||||
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
|
||||
else:
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
|
||||
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertIsNone(detail["match_url"])
|
||||
gc.collect()
|
||||
|
||||
def test_foy_relink_reports_existing_materialized_match_url(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "historical.sqlite3"
|
||||
previous_storage_path = os.environ.get("HLL_BACKEND_STORAGE_PATH")
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = str(db_path)
|
||||
try:
|
||||
_persist_match(
|
||||
db_path,
|
||||
server_slug="comunidad-hispana-02",
|
||||
match_id="1562115",
|
||||
map_name="Foy Warfare",
|
||||
started_at="2026-05-20T20:54:11Z",
|
||||
ended_at="2026-05-20T22:24:11Z",
|
||||
)
|
||||
persist_rcon_admin_log_entries(
|
||||
target={
|
||||
"target_key": "comunidad-hispana-02",
|
||||
"external_server_id": "comunidad-hispana-02",
|
||||
},
|
||||
entries=[
|
||||
{
|
||||
"timestamp": "2026-05-20T20:54:11Z",
|
||||
"message": "[1 min (1779310451)] MATCH START Foy Warfare",
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-20T22:24:11Z",
|
||||
"message": "[91 min (1779315851)] MATCH ENDED `Foy Warfare` ALLIED (4 - 1) AXIS",
|
||||
},
|
||||
],
|
||||
db_path=db_path,
|
||||
)
|
||||
materialize_rcon_admin_log(db_path=db_path)
|
||||
report = relink_materialized_matches(
|
||||
server_key="comunidad-hispana-02",
|
||||
db_path=db_path,
|
||||
)
|
||||
detail = get_rcon_historical_match_detail(
|
||||
server_key="comunidad-hispana-02",
|
||||
match_id="comunidad-hispana-02:1779310451:1779315851:foywarfare",
|
||||
)
|
||||
diagnostics = inspect_materialized_match_correlation(
|
||||
server_key="comunidad-hispana-02",
|
||||
match_key="comunidad-hispana-02:1779310451:1779315851:foywarfare",
|
||||
db_path=db_path,
|
||||
)
|
||||
finally:
|
||||
if previous_storage_path is None:
|
||||
os.environ.pop("HLL_BACKEND_STORAGE_PATH", None)
|
||||
else:
|
||||
os.environ["HLL_BACKEND_STORAGE_PATH"] = previous_storage_path
|
||||
|
||||
self.assertEqual(report["matches_scanned"], 1)
|
||||
self.assertEqual(report["matches_linked"], 1)
|
||||
self.assertGreaterEqual(report["candidates_scanned"], 1)
|
||||
self.assertIsNotNone(detail)
|
||||
self.assertEqual(
|
||||
detail["match_url"],
|
||||
"https://scoreboard.comunidadhll.es:5443/games/1562115",
|
||||
)
|
||||
self.assertEqual(diagnostics["final_reason"], "linked")
|
||||
self.assertEqual(diagnostics["selected_candidate"]["external_match_id"], "1562115")
|
||||
self.assertEqual(diagnostics["top_candidates"][0]["map"], "Foy Warfare")
|
||||
gc.collect()
|
||||
|
||||
|
||||
def _persist_match(
|
||||
db_path: Path,
|
||||
*,
|
||||
server_slug: str,
|
||||
match_id: str,
|
||||
map_name: str = "carentan",
|
||||
started_at: str = "2026-05-01T10:00:00Z",
|
||||
ended_at: str = "2026-05-01T11:20:00Z",
|
||||
player_stats: list[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
upsert_historical_match(
|
||||
server_slug=server_slug,
|
||||
match_payload={
|
||||
"id": match_id,
|
||||
"creation_time": started_at,
|
||||
"start": started_at,
|
||||
"end": ended_at,
|
||||
"map": {"name": map_name},
|
||||
"result": {"allied": 3, "axis": 2},
|
||||
"player_stats": player_stats or [],
|
||||
},
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
|
||||
def _foy_list_match() -> dict[str, object]:
|
||||
return {
|
||||
"id": 1562115,
|
||||
"server_number": 2,
|
||||
"start": "2026-05-20T20:54:11+00:00",
|
||||
"end": "2026-05-20T22:24:11+00:00",
|
||||
"map": {"id": "foywarfare", "pretty_name": "Foy Warfare"},
|
||||
"result": {"allied": 4, "axis": 1},
|
||||
}
|
||||
|
||||
|
||||
def _backfill_timestamp(raw_value: str):
|
||||
from app.scoreboard_candidate_backfill import _parse_timestamp
|
||||
|
||||
return _parse_timestamp(raw_value, option_name="test")
|
||||
|
||||
|
||||
def _persist_rcon_window(
|
||||
db_path: Path,
|
||||
*,
|
||||
map_name: str,
|
||||
first_seen_at: str,
|
||||
last_seen_at: str,
|
||||
players: int,
|
||||
max_players: int,
|
||||
) -> str:
|
||||
initialize_rcon_historical_storage(db_path=db_path)
|
||||
run_id = start_rcon_historical_capture_run(
|
||||
mode="test",
|
||||
target_scope="comunidad-hispana-01",
|
||||
db_path=db_path,
|
||||
)
|
||||
target = {
|
||||
"target_key": "comunidad-hispana-01",
|
||||
"external_server_id": "comunidad-hispana-01",
|
||||
"name": "Comunidad Hispana #01",
|
||||
"host": "127.0.0.1",
|
||||
"port": 7779,
|
||||
}
|
||||
for captured_at in (first_seen_at, last_seen_at):
|
||||
persist_rcon_historical_sample(
|
||||
run_id=run_id,
|
||||
captured_at=captured_at,
|
||||
target=target,
|
||||
normalized_payload={
|
||||
"status": "online",
|
||||
"players": players,
|
||||
"max_players": max_players,
|
||||
"current_map": map_name,
|
||||
},
|
||||
raw_payload={},
|
||||
db_path=db_path,
|
||||
)
|
||||
return f"1:{first_seen_at}"
|
||||
|
||||
|
||||
def _set_raw_payload_ref(db_path: Path, *, match_id: str, raw_payload_ref: str) -> None:
|
||||
with sqlite3.connect(db_path) as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE historical_matches
|
||||
SET raw_payload_ref = ?
|
||||
WHERE external_match_id = ?
|
||||
""",
|
||||
(raw_payload_ref, match_id),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user