175 lines
8.3 KiB
Python
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())
|