Fix
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user