Auto merge dev → main (#1353)
* feat(gateway): hashed key store with grant + hot reload Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(gateway): channel registry + aiohttp app (keyed auth, whoami, per-channel ws/proxy) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(gateway): manage_keys CLI (add/list/revoke) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(gateway): retire srebot_external, run relay-gateway under PM2 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(gateway): point ecosystem + README at relay-gateway Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss): replay outbox producer for relay gateway Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss): forward processed games to relay outbox Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss-api): db helpers, app skeleton, info endpoint, fixtures Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss-api): player, games, history, search endpoints Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss-api): live, match, scoreboard, matches-search, maps Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss-api): filter-required leaderboards (players/vehicles/stats) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tss-api): tournament list/detail/standings/matches Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: wire tss upstream through gateway + tssbot-api PM2 app Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED"))
|
||||
|
||||
BATTLES_DDL = """
|
||||
CREATE TABLE match_summary (
|
||||
session_id TEXT PRIMARY KEY, mission_mode TEXT, mission_name TEXT, level_path TEXT,
|
||||
mission_path TEXT, difficulty TEXT, starttime_unix INTEGER, endtime_unix INTEGER,
|
||||
duration REAL, draw INTEGER NOT NULL DEFAULT 0, winning_slot TEXT, losing_slot TEXT,
|
||||
received_unix INTEGER, tournament_id INTEGER, tournament_name TEXT, match_id TEXT, bracket TEXT);
|
||||
CREATE TABLE player_games_hist (
|
||||
UID TEXT NOT NULL, nick TEXT NOT NULL, team_name TEXT, team_slot TEXT, session_id TEXT NOT NULL,
|
||||
vehicle TEXT, vehicle_internal TEXT, ground_kills INTEGER NOT NULL DEFAULT 0,
|
||||
air_kills INTEGER NOT NULL DEFAULT 0, assists INTEGER NOT NULL DEFAULT 0,
|
||||
captures INTEGER NOT NULL DEFAULT 0, deaths INTEGER NOT NULL DEFAULT 0, score INTEGER NOT NULL DEFAULT 0,
|
||||
missile_evades INTEGER NOT NULL DEFAULT 0, shell_interceptions INTEGER NOT NULL DEFAULT 0,
|
||||
team_kills_stat INTEGER NOT NULL DEFAULT 0, country_id INTEGER, victor_bool TEXT NOT NULL DEFAULT 'Loss',
|
||||
endtime_unix INTEGER NOT NULL DEFAULT 0, team_id INTEGER, tss_role TEXT, pvp_ratio REAL,
|
||||
UNIQUE (UID, session_id, vehicle_internal));
|
||||
CREATE TABLE match_logs (
|
||||
session_id TEXT PRIMARY KEY, chat_log_json TEXT, battle_log_json TEXT, built_unix INTEGER, event_log_json TEXT);
|
||||
"""
|
||||
|
||||
TOURN_DDL = """
|
||||
CREATE TABLE tournaments (
|
||||
tournament_id INTEGER PRIMARY KEY, name TEXT, format TEXT, game_mode TEXT, cluster TEXT,
|
||||
date_start INTEGER, date_end INTEGER, team_count INTEGER NOT NULL DEFAULT 0,
|
||||
match_count INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending',
|
||||
first_seen_unix INTEGER, scanned_unix INTEGER);
|
||||
CREATE TABLE tournament_matches (
|
||||
tournament_id INTEGER NOT NULL, match_id TEXT NOT NULL, type_bracket TEXT NOT NULL, side TEXT,
|
||||
round INTEGER, position INTEGER, team_a_uuid TEXT, team_a_name TEXT, team_b_uuid TEXT, team_b_name TEXT,
|
||||
winner_name TEXT, score_a INTEGER NOT NULL DEFAULT 0, score_b INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending', time_start INTEGER,
|
||||
PRIMARY KEY (tournament_id, match_id, type_bracket));
|
||||
CREATE TABLE tournament_battles (
|
||||
session_hex TEXT PRIMARY KEY, session_decimal TEXT, tournament_id INTEGER NOT NULL, match_id TEXT NOT NULL,
|
||||
type_bracket TEXT NOT NULL, position INTEGER, winner_name TEXT, status_replay TEXT);
|
||||
CREATE TABLE tournament_standings (
|
||||
tournament_id INTEGER NOT NULL, group_index INTEGER NOT NULL DEFAULT 0, team_uuid TEXT NOT NULL,
|
||||
team_name TEXT, points INTEGER NOT NULL DEFAULT 0, wins INTEGER NOT NULL DEFAULT 0,
|
||||
draws INTEGER NOT NULL DEFAULT 0, losses INTEGER NOT NULL DEFAULT 0, buchholz REAL NOT NULL DEFAULT 0,
|
||||
rank INTEGER, PRIMARY KEY (tournament_id, group_index, team_uuid));
|
||||
"""
|
||||
|
||||
|
||||
def _seed(battles: Path, tourn: Path) -> None:
|
||||
b = sqlite3.connect(battles); b.executescript(BATTLES_DDL)
|
||||
b.execute("INSERT INTO match_summary VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("sess1", "Tournament", "Gladiators 1x1 - Kursk", "levels/kursk.bin",
|
||||
"gamedata/missions/x.blk", "realistic", 1780937467, 1780937680, 156.84, 0,
|
||||
"1", "2", 1780937817, 24839, "Cadet 1x1 RB Air", "884571", "single-elim"))
|
||||
b.execute("INSERT INTO player_games_hist (UID,nick,team_name,team_slot,session_id,vehicle,vehicle_internal,ground_kills,air_kills,assists,captures,deaths,score,country_id,victor_bool,endtime_unix,team_id,tss_role,pvp_ratio) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("148919027", "Joe", "SunThunder", "1", "sess1", "I-153 M-62", "i-153_m62",
|
||||
0, 1, 0, 0, 1, 270, 3, "Win", 1780937680, 1211052, "captain", 1.0))
|
||||
b.execute("INSERT INTO player_games_hist (UID,nick,team_name,team_slot,session_id,vehicle,vehicle_internal,deaths,score,victor_bool,endtime_unix,team_id,tss_role) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("80080809", "Foe", "RedTeam", "2", "sess1", "I-153 M-62", "i-153_m62",
|
||||
2, 90, "Loss", 1780937680, 1211099, "player"))
|
||||
b.execute("INSERT INTO match_logs VALUES (?,?,?,?,?)",
|
||||
("sess1", "[]", '["[01:18] kill"]', 1781776039, '{"kills": []}'))
|
||||
b.commit(); b.close()
|
||||
|
||||
t = sqlite3.connect(tourn); t.executescript(TOURN_DDL)
|
||||
t.execute("INSERT INTO tournaments VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(24839, "Cadet 1x1 RB Air", "single-elim", "RB", "EU", 1780937400, 1780944668,
|
||||
63, 63, "finished", 1782016814, 1782016814))
|
||||
t.execute("INSERT INTO tournament_matches VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(24839, "336136", "Swiss", "swiss", "", "", "uuid-a", "BenisPutt", "uuid-b", "roflan",
|
||||
"BenisPutt", 1, 0, "played", 1782014460))
|
||||
t.execute("INSERT INTO tournament_battles VALUES (?,?,?,?,?,?,?,?)",
|
||||
("6cbc20b001de1ee", "489698337002217966", 24839, "336136", "Swiss", 0, "BenisPutt", "view replay"))
|
||||
t.execute("INSERT INTO tournament_standings VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(24839, 0, "uuid-a", "BenisPutt", 4, 2, 0, 5, 41.0, 1))
|
||||
t.commit(); t.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
battles = tmp_path / "tss_battles.db"
|
||||
tourn = tmp_path / "tss_tournaments.db"
|
||||
_seed(battles, tourn)
|
||||
monkeypatch.setenv("TSS_API_BATTLES_DB", str(battles))
|
||||
monkeypatch.setenv("TSS_API_TOURNAMENTS_DB", str(tourn))
|
||||
monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path))
|
||||
from fastapi.testclient import TestClient
|
||||
import importlib
|
||||
import web.api.db as dbmod
|
||||
importlib.reload(dbmod)
|
||||
import web.api.app as appmod
|
||||
importlib.reload(appmod)
|
||||
return TestClient(appmod.app)
|
||||
@@ -0,0 +1,33 @@
|
||||
import json
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "SHARED"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bridge(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("STORAGE_VOL_PATH", str(tmp_path))
|
||||
import BOT.receiver_bridge as rb
|
||||
importlib.reload(rb)
|
||||
return rb
|
||||
|
||||
|
||||
async def test_publish_replay_batch_writes_tss_envelope(bridge, tmp_path):
|
||||
await bridge.publish_replay_batch([{"sessionIdHex": "abc", "x": 1}])
|
||||
line = (tmp_path / "tss_bridge_outbox.jsonl").read_text(encoding="utf-8").strip()
|
||||
env = json.loads(line)
|
||||
assert env["type"] == "tss.replay_batch"
|
||||
assert env["source"] == "tss"
|
||||
assert env["version"] == 1
|
||||
assert env["payload"]["replays"][0]["sessionIdHex"] == "abc"
|
||||
assert isinstance(env["sent_at"], float)
|
||||
|
||||
|
||||
async def test_publish_replay_batch_empty_is_noop(bridge, tmp_path):
|
||||
await bridge.publish_replay_batch([])
|
||||
assert not (tmp_path / "tss_bridge_outbox.jsonl").exists()
|
||||
@@ -0,0 +1,9 @@
|
||||
def test_info_reports_counts(client):
|
||||
r = client.get("/api/tss/info")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["service"] == "tss"
|
||||
assert body["counts"]["matches"] == 1
|
||||
assert body["counts"]["player_games"] == 2
|
||||
assert body["counts"]["tournaments"] == 1
|
||||
assert "/api/tss/player/{uid}" in body["endpoints"]
|
||||
@@ -0,0 +1,24 @@
|
||||
def test_leaderboard_requires_filter(client):
|
||||
r = client.get("/api/tss/leaderboard/players")
|
||||
assert r.status_code == 400
|
||||
assert r.json()["code"] == "FILTER_REQUIRED"
|
||||
|
||||
|
||||
def test_leaderboard_players_with_window(client):
|
||||
r = client.get("/api/tss/leaderboard/players?start_date=1780000000&end_date=1790000000")
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["players"]
|
||||
assert rows[0]["uid"] == "148919027" # highest score
|
||||
|
||||
|
||||
def test_leaderboard_vehicles_with_tournament(client):
|
||||
r = client.get("/api/tss/leaderboard/vehicles?tournament_id=24839")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["vehicles"][0]["vehicle_internal"] == "i-153_m62"
|
||||
|
||||
|
||||
def test_leaderboard_stats(client):
|
||||
r = client.get("/api/tss/leaderboard/stats?start_date=1780000000&end_date=1790000000")
|
||||
body = r.json()
|
||||
assert body["totals"]["battles"] == 2
|
||||
assert body["top_vehicles"][0]["vehicle_internal"] == "i-153_m62"
|
||||
@@ -0,0 +1,35 @@
|
||||
def test_live_recent_matches(client):
|
||||
r = client.get("/api/tss/live?limit=10")
|
||||
assert r.json()["matches"][0]["session_id"] == "sess1"
|
||||
|
||||
|
||||
def test_match_with_rosters(client):
|
||||
r = client.get("/api/tss/match/sess1")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["match"]["mission_name"].startswith("Gladiators")
|
||||
slots = {t["team_slot"] for t in body["teams"]}
|
||||
assert slots == {"1", "2"}
|
||||
win = next(t for t in body["teams"] if t["team_slot"] == "1")
|
||||
assert win["is_winner"] is True
|
||||
assert win["players"][0]["UID"] == "148919027"
|
||||
|
||||
|
||||
def test_match_unknown_404(client):
|
||||
assert client.get("/api/tss/match/nope").status_code == 404
|
||||
|
||||
|
||||
def test_scoreboard_includes_logs(client):
|
||||
r = client.get("/api/tss/match/sess1/scoreboard")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["logs"]["available"] is True
|
||||
|
||||
|
||||
def test_matches_search_by_player(client):
|
||||
r = client.get("/api/tss/matches/search?player=148919027")
|
||||
assert r.json()["matches"][0]["session_id"] == "sess1"
|
||||
|
||||
|
||||
def test_maps_distinct(client):
|
||||
maps = client.get("/api/tss/maps").json()["maps"]
|
||||
assert any(m["mission_name"].startswith("Gladiators") for m in maps)
|
||||
@@ -0,0 +1,37 @@
|
||||
def test_player_summary_and_team_history(client):
|
||||
r = client.get("/api/tss/player/148919027")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["uid"] == "148919027"
|
||||
assert body["summary"]["battles"] == 1
|
||||
assert body["summary"]["wins"] == 1
|
||||
assert body["summary"]["air_kills"] == 1
|
||||
assert any(v["vehicle_internal"] == "i-153_m62" for v in body["vehicles"])
|
||||
assert any(t["team_name"] == "SunThunder" and t["tss_role"] == "captain"
|
||||
for t in body["team_history"])
|
||||
assert any(n["nick"] == "Joe" for n in body["nicks"])
|
||||
|
||||
|
||||
def test_player_unknown_is_404(client):
|
||||
assert client.get("/api/tss/player/000").status_code == 404
|
||||
|
||||
|
||||
def test_player_games_rows(client):
|
||||
r = client.get("/api/tss/player/148919027/games")
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["games"]
|
||||
assert rows[0]["session_id"] == "sess1"
|
||||
assert rows[0]["UID"] == "148919027"
|
||||
|
||||
|
||||
def test_player_history_daily(client):
|
||||
r = client.get("/api/tss/player/148919027/history")
|
||||
body = r.json()
|
||||
assert body["days_with_battles_only"] is True
|
||||
assert body["history"][0]["battles"] == 1
|
||||
|
||||
|
||||
def test_search_by_nick(client):
|
||||
r = client.get("/api/tss/search/Joe")
|
||||
hits = r.json()["players"]
|
||||
assert hits[0]["uid"] == "148919027"
|
||||
@@ -0,0 +1,28 @@
|
||||
def test_list_tournaments(client):
|
||||
r = client.get("/api/tss/tournaments")
|
||||
assert r.json()["tournaments"][0]["tournament_id"] == 24839
|
||||
|
||||
|
||||
def test_tournament_detail(client):
|
||||
r = client.get("/api/tss/tournament/24839")
|
||||
body = r.json()
|
||||
assert body["tournament"]["name"] == "Cadet 1x1 RB Air"
|
||||
assert body["standings"][0]["team_name"] == "BenisPutt"
|
||||
assert len(body["matches"]) == 1
|
||||
|
||||
|
||||
def test_tournament_unknown_404(client):
|
||||
assert client.get("/api/tss/tournament/999").status_code == 404
|
||||
|
||||
|
||||
def test_tournament_matches_coerce_empty_to_null(client):
|
||||
r = client.get("/api/tss/tournament/24839/matches")
|
||||
m = r.json()["matches"][0]
|
||||
assert m["round"] is None
|
||||
assert m["position"] is None
|
||||
assert m["session_ids"] == ["6cbc20b001de1ee"]
|
||||
|
||||
|
||||
def test_tournament_standings(client):
|
||||
r = client.get("/api/tss/tournament/24839/standings")
|
||||
assert r.json()["standings"][0]["rank"] == 1
|
||||
Reference in New Issue
Block a user