Files
hockey_new/tests/test_hotfix_idle_matches.py
2026-09-26 23:18:54 +03:00

175 lines
8.3 KiB
Python

import asyncio
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from hockey_data import auth_bridge
from hockey_data.auth_bridge import WFLAuthAdapter, _token_hash
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.idle_matches import IdleMatchReaper
from hockey_data.models import HockeyWebSession, MatchControlLease, MatchParticipant, OperatorSession, VmixAssignment, VmixDevice
from tests.support import LocalTestDatabase
@pytest.fixture
def env(tmp_path):
db = LocalTestDatabase(tmp_path / "idle_matches.sqlite")
db.create_all()
now = datetime.utcnow()
with db.session() as s:
for user in ("owner", "viewer"):
activity = now - timedelta(hours=2) if user == "owner" else now
s.add(HockeyWebSession(token_hash=_token_hash(user), wfl_user_id=user,
last_activity_at=activity, expires_at=activity + timedelta(hours=2)))
s.add(OperatorSession(session_token=user, wfl_user_id=user,
tournament_external_id="t", game_external_id="same-game",
vmix_device_uuid=user + "-device", last_activity_at=now))
d = VmixDevice(device_uuid=user + "-device", device_secret_hash="x", wfl_user_id=user,
is_active_for_account=True, current_match_external_id="same-game",
current_assignment_key=user + "-assignment", last_seen_at=now)
s.add(d)
s.flush()
s.add(VmixAssignment(assignment_key=user + "-assignment", device_id=d.id,
wfl_user_id=user, operator_session_token=user,
game_external_id="same-game", last_activity_at=now))
s.add(MatchParticipant(game_external_id="same-game", participant_id=user + "-browser",
user_id=user, last_seen_at=now))
s.add(MatchControlLease(game_external_id="same-game", owner_user_id="owner",
owner_participant_id="owner-browser", owner_device_id="owner-device",
generation=4, revision=9, expires_at=now + timedelta(seconds=90)))
hub = VmixAgentHub(db)
sent = []
async def send(device_id, message):
sent.append((device_id, message))
return True
hub.send = send
return SimpleNamespace(db=db, now=now, hub=hub, reaper=IdleMatchReaper(db, hub), sent=sent)
def test_two_hour_boundary_clears_only_idle_account_and_keeps_links_and_pairing(env):
e = env
async def scenario():
assert await e.reaper.run_once(now=e.now - timedelta(microseconds=1)) == {"released_accounts": 0}
assert await e.reaper.run_once(now=e.now) == {"released_accounts": 1}
assert await e.reaper.run_once(now=e.now) == {"released_accounts": 0}
asyncio.run(scenario())
with e.db.session() as s:
lease = s.get(MatchControlLease, "same-game")
assert (lease.owner_user_id, lease.owner_participant_id, lease.expires_at) == ("", "", None)
assert (lease.generation, lease.revision) == (5, 10)
rows = {row.wfl_user_id: row for row in s.scalars(select(OperatorSession))}
assert rows["owner"].game_external_id == ""
assert rows["owner"].session_token == "owner" and rows["owner"].status == "active"
assert rows["viewer"].game_external_id == "same-game"
devices = {row.wfl_user_id: row for row in s.scalars(select(VmixDevice))}
assert devices["owner"].current_assignment_key == ""
assert devices["owner"].is_active_for_account and devices["owner"].wfl_user_id == "owner"
assert devices["viewer"].current_assignment_key == "viewer-assignment"
assignments = {row.wfl_user_id: row for row in s.scalars(select(VmixAssignment))}
assert not assignments["owner"].active and assignments["owner"].ended_at == e.now
assert assignments["viewer"].active
assert s.get(MatchParticipant, ("same-game", "viewer-browser")) is not None
assert len(e.sent) == 1
assert e.sent[0][0] == "owner-device"
assert e.sent[0][1]["type"] == "match.assign" and e.sent[0][1]["game_id"] == ""
def test_activity_in_another_login_of_same_account_preserves_match(env):
with env.db.session() as s:
s.add(HockeyWebSession(token_hash="other-login", wfl_user_id="owner",
last_activity_at=env.now - timedelta(seconds=10),
expires_at=env.now + timedelta(hours=2)))
assert asyncio.run(env.reaper.run_once(now=env.now)) == {"released_accounts": 0}
assert env.sent == []
def test_candidate_is_rechecked_after_real_browser_activity(env, monkeypatch):
e = env
# Discover the account, then simulate a queued real action renewing a still
# valid session before the transaction that clears its match takes the lock.
candidates = e.reaper._candidates(e.now)
assert candidates == ["owner"]
with e.db.session() as s:
web = s.scalar(select(HockeyWebSession).where(HockeyWebSession.wfl_user_id == "owner"))
web.last_activity_at = e.now - timedelta(minutes=119)
web.expires_at = e.now + timedelta(minutes=1)
adapter = WFLAuthAdapter(None, e.db)
monkeypatch.setattr(adapter, "_wfl_account", lambda **_: dict(id="owner", username="owner", is_active=True, role="operator"))
assert adapter.session_status("owner", idle_ms=2000) is not None
assert e.reaper._release("owner", e.now) is None
with e.db.session() as s:
assert s.get(MatchControlLease, "same-game").owner_user_id == "owner"
def test_transfer_to_active_other_operator_survives_idle_cleanup(env):
with env.db.session() as s:
lease = s.get(MatchControlLease, "same-game")
lease.owner_user_id = "viewer"
lease.owner_participant_id = "viewer-browser"
lease.generation += 1
asyncio.run(env.reaper.run_once(now=env.now))
with env.db.session() as s:
lease = s.get(MatchControlLease, "same-game")
assert lease.owner_user_id == "viewer" and lease.generation == 5
def test_offline_cleanup_and_reconnect_never_restore_expired_match(env):
e = env
async def offline(*_):
return False
e.hub.send = offline
asyncio.run(e.reaper.run_once(now=e.now))
async def reconnect_send(device_id, message):
e.sent.append((device_id, message))
return True
e.hub.send = reconnect_send
asyncio.run(e.hub.reconcile_device("owner-device"))
assert [msg["type"] for _, msg in e.sent] == ["pairing.confirmed", "match.assign"]
assert e.sent[-1][1]["game_id"] == ""
assert e.sent[0][1]["active_for_account"] is True
def test_late_request_cannot_restore_idle_assignment(env):
e = env
async def scenario():
await e.reaper.run_once(now=e.now)
assert await e.hub.assign_match(wfl_user_id="owner", game_external_id="same-game",
device_id="owner-device", operator_session_token="owner",
wait_for_mapping=False) is None
asyncio.run(scenario())
def test_late_cleanup_notification_keeps_new_assignment_after_login(env):
e = env
e.reaper._release("owner", e.now)
with e.db.session() as s:
web = s.scalar(select(HockeyWebSession).where(HockeyWebSession.wfl_user_id == "owner"))
web.last_activity_at = e.now
web.expires_at = e.now + timedelta(hours=2)
async def scenario():
assignment = await e.hub.assign_match(wfl_user_id="owner", game_external_id="new-game",
device_id="owner-device", operator_session_token="owner",
wait_for_mapping=False)
assert assignment["delivered"]
await e.hub.send_current_assignment("owner-device")
asyncio.run(scenario())
assert all(message["game_id"] == "new-game" for _, message in e.sent)
def test_worker_starts_immediately_and_stops_cleanly(env):
async def scenario():
done = asyncio.Event()
async def run_once(**_):
done.set()
env.reaper.run_once = run_once
env.reaper.start()
first = env.reaper._task
env.reaper.start()
assert env.reaper._task is first
await asyncio.wait_for(done.wait(), 2)
await env.reaper.stop()
assert first.done()
asyncio.run(scenario())