diff --git a/ai/tasks/pending/TASK-121-add-rcon-adminlog-storage-read-tests.md b/ai/tasks/done/TASK-121-add-rcon-adminlog-storage-read-tests.md similarity index 69% rename from ai/tasks/pending/TASK-121-add-rcon-adminlog-storage-read-tests.md rename to ai/tasks/done/TASK-121-add-rcon-adminlog-storage-read-tests.md index 4771ad9..8c7117c 100644 --- a/ai/tasks/pending/TASK-121-add-rcon-adminlog-storage-read-tests.md +++ b/ai/tasks/done/TASK-121-add-rcon-adminlog-storage-read-tests.md @@ -1,7 +1,7 @@ --- id: TASK-121 title: Add RCON AdminLog storage read tests -status: pending +status: done type: backend team: Backend Senior supporting_teams: @@ -72,3 +72,17 @@ AdminLog storage and manual ingestion exist, and real validation confirmed canon - Stage only intended files. - Commit the completed implementation. - Push the branch to origin. + +## Outcome + +- Added deterministic offline storage tests in `backend/tests/test_rcon_admin_log_storage.py`. +- Covered AdminLog table initialization, first insert behavior, duplicate reporting, canonical-message dedupe when relative prefixes change, and event-count grouping. +- Tests use pytest `tmp_path` temporary SQLite paths and do not use real RCON or the runtime database. + +## Validation Result + +- `python -m compileall backend/app` passed. +- `python -m pytest backend/tests/test_rcon_admin_log_parser.py backend/tests/test_rcon_admin_log_storage.py` could not run locally because `pytest` is not installed. +- `docker compose exec backend python -m pytest backend/tests/test_rcon_admin_log_parser.py backend/tests/test_rcon_admin_log_storage.py` also could not run because `pytest` is not installed in the backend image. +- Direct Python invocation of the new storage test functions passed with `PYTHONPATH=backend`. +- `git diff --name-only` matched the expected task scope after accounting for the new untracked test file. diff --git a/backend/tests/test_rcon_admin_log_storage.py b/backend/tests/test_rcon_admin_log_storage.py new file mode 100644 index 0000000..8648714 --- /dev/null +++ b/backend/tests/test_rcon_admin_log_storage.py @@ -0,0 +1,178 @@ +import gc +import sqlite3 + +from app.rcon_admin_log_storage import ( + initialize_rcon_admin_log_storage, + 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 { + "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_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()