498 lines
15 KiB
Python
498 lines
15 KiB
Python
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()
|