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())