"""The browser's config Use endpoint must execute the complete Mapping path. Only the physical Agent socket is replaced. Authentication, MatchAccess, registration, publication, builtin DB score resolution and positional ACK/cache handling are production code on a temporary SQLite database. """ from __future__ import annotations import asyncio import json import logging from datetime import datetime from types import SimpleNamespace import httpx import pytest from fastapi import HTTPException from sqlalchemy import select from hockey_data import program_log from hockey_data.agent_bridge import LiveAgent from hockey_data.mapping_context import MappingDataService from hockey_data.models import Game, OperatorSession, VmixAssignment, VmixDevice, VmixMappingField, VmixMappingProfile from hockey_data.program_log import ProgramLogHandler, ProgramLogStore, configure_program_logging from hockey_data.web_observability import WebAuditMiddleware, install_runtime_audit from tests.test_stage1_match_access import env DEVICE = "GFX-CONFIG-195" TOKEN = "build195-local-admin-session" @pytest.fixture def audited_env(env, tmp_path): """Install the production app's real logging hooks with a local journal.""" previous_store = program_log._DEFAULT_STORE loggers = [logging.getLogger(name) for name in ("", "uvicorn", "uvicorn.error", "uvicorn.access")] previous = [(logger, logger.level, [h for h in logger.handlers if isinstance(h, ProgramLogHandler)]) for logger in loggers] store = ProgramLogStore(tmp_path / "runtime-journal") store.start() assert store._ready.wait(3) handler = configure_program_logging(store) previous_app = env.app try: # The shared fixture has already served participant joins. Wrapping its # ASGI application installs the exact middleware without mutating an # already built Starlette middleware stack. env.app = WebAuditMiddleware(previous_app) install_runtime_audit(env.service, env.hub) yield env, store finally: env.app = previous_app for logger, level, old_handlers in previous: logger.removeHandler(handler) for old in old_handlers: if old not in logger.handlers: logger.addHandler(old) logger.setLevel(level) program_log._DEFAULT_STORE = previous_store assert store.close() class ImmediateAckSocket: """A physical-socket double; it does not replace a hub transport method.""" def __init__(self, hub): self.hub = hub self.client = SimpleNamespace(host="127.0.0.1") self.sent = [] self.acknowledged = [] async def send_json(self, packet): self.sent.append(packet) if packet.get("type") not in {"vmix.command", "vmix.batch"}: return rows = packet["commands"] if packet["type"] == "vmix.batch" else [packet["command"]] ack = {"type": "command.batch.ack" if packet["type"] == "vmix.batch" else "command.ack", "request_id": packet["request_id"], "assignment_id": packet["assignment_id"], "match_id": packet["match_id"], "ok": True} if packet["type"] == "vmix.batch": ack["results"] = [{"ok": True, "reason": ""} for _ in rows] await self.hub.receive_command_ack(DEVICE, ack) self.acknowledged.append(ack) async def close(self, **_kwargs): pass def _exercise_config_use(env, *, capable=True, include_apply=False, field_count=2): async def scenario(): hub = env.hub socket = ImmediateAckSocket(hub) definitions = [("score.home" if index % 2 == 0 else "score.away", ("Home.Text", "Away.Text")[index] if field_count == 2 else f"Score{index}.Text") for index in range(field_count)] expected_values = {field: "5" if key == "score.home" else "4" for key, field in definitions} inventory = {"fingerprint": "a" * 64, "inputs": [{ "key": "score-guid", "number": "1", "title": "Score", "fields": [{"name": field, "type": "text"} for _, field in definitions], }]} try: await hub.register(socket, {"device_id": DEVICE, "device_secret": "x" * 40, "agent_version": "1.5.5" if capable else "1.5.3", "capabilities": {"mapping_title_batch_v1": capable}, "vmix": {"connected": True}}) await hub.pair_device(DEVICE, env.users["admin"]) await hub.receive_inventory(DEVICE, {"inventory": inventory}, websocket=socket) assert hub._live[DEVICE].mapping_title_batch_v1 is capable assert DEVICE in hub._mapping_inventory_ready # The real catalog must remain installed, including builtin score # context. Neither SQL selection nor score persistence is stubbed. assert hub.mapping_data.data_catalog.__func__ is MappingDataService.data_catalog with env.db.session() as session: game = session.scalar(select(Game).where(Game.external_id == "g")) game.home_score, game.away_score, game.score_raw = 5, 4, "5:4" device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == DEVICE)) # An explicit Use must publish this template; it is not picked # implicitly as a legacy active template during assignment. source = VmixMappingProfile(name="HTTP config baseline", active=False, version=1, project_fingerprint=device.project_fingerprint, inventory_json=device.project_inventory_json) session.add(source) session.flush() profile_id = source.id for index, (key, field) in enumerate(definitions): session.add(VmixMappingField(profile_id=profile_id, graphic="score", data_key=key, vmix_input_title="Score", vmix_field=field, field_type="text", enabled=True, sort_order=index)) session.add(OperatorSession(session_token=TOKEN, wfl_user_id="admin", login_snapshot="admin", tournament_external_id="t", game_external_id="g", status="active", browser_participant_id=env.participants["admin"], vmix_device_uuid=DEVICE, last_activity_at=datetime.utcnow())) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=env.app), base_url="http://test") as client: joined = await client.post("/api/hockey/games/g/access/join", headers=env.headers("admin"), json={"device_id": DEVICE}) assert joined.status_code == 200, joined.text claimed = await client.post("/api/hockey/games/g/access/claim", headers=env.headers("admin"), json={}) assert claimed.status_code == 200, claimed.text assigned = await hub.assign_match(wfl_user_id="admin", game_external_id="g", tournament_external_id="t", device_id=DEVICE, operator_session_token=TOKEN, wait_for_mapping=False) assert assigned and assigned["delivered"] is True before = len(socket.sent) response = await client.post(f"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/use", headers={**env.headers("admin"), "X-Hockey-Session": TOKEN}, json={"device_id": DEVICE, "game_id": "g", "session_token": TOKEN}) assert response.status_code == 200, response.text result = response.json() assert result["ok"] is True and result["device_id"] == DEVICE applied = result["applied"] assert applied["ok"] is True and applied["complete"] is True, applied assert applied["applied"] == applied["mapping_commands_confirmed"] == field_count assert applied["transport"] == ("title_batch_ack" if capable else "bounded_batch_ack") packets = socket.sent[before:] publication = next(i for i, packet in enumerate(packets) if packet.get("type") == "mapping.assigned") batches = [(i, packet) for i, packet in enumerate(packets) if packet.get("type") == "vmix.batch"] assert len(batches) == 1 and publication < batches[0][0] packet = batches[0][1] assert (packet.get("mapping_bulk") is True) is capable and packet["match_id"] == "g" assert {command["SelectedName"]: command["Value"] for command in packet["commands"]} == expected_values if field_count == 372: assert packet["mapping_deadline_ms"] == 59000 assert len(json.dumps(packet, ensure_ascii=False, separators=(",", ":")).encode()) <= 48 * 1024 assert socket.acknowledged[-1]["request_id"] == packet["request_id"] assert len(hub._mapping_value_cache) == field_count and not hub._pending_commands with env.db.session() as session: device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == DEVICE)) assignment = session.scalar(select(VmixAssignment).where( VmixAssignment.assignment_key == packet["assignment_id"])) assert device.mapping_profile_id == result["runtime_profile_id"] != profile_id assert device.mapping_source_profile_id == profile_id and device.mapping_profile_version == 1 assert assignment.active is True and assignment.wfl_user_id == "admin" assert assignment.game_external_id == "g" and assignment.operator_session_token == TOKEN control = await client.get("/api/hockey/games/g/control", headers=env.headers("admin")) assert control.status_code == 200, control.text assert (control.json()["score"]["home"], control.json()["score"]["away"]) == (5, 4) if include_apply: before_apply = len(socket.sent) response = await client.post(f"/api/hockey/agents/devices/{DEVICE}/apply-mapping", headers={**env.headers("admin"), "X-Hockey-Session": TOKEN}) assert response.status_code == 200, response.text reapplied = response.json() assert reapplied["ok"] is True and reapplied["complete"] is True, reapplied assert reapplied["applied"] == reapplied["mapping_commands_confirmed"] == field_count assert reapplied["transport"] == ("title_batch_ack" if capable else "bounded_batch_ack") fresh_batches = [row for row in socket.sent[before_apply:] if row.get("type") == "vmix.batch"] assert len(fresh_batches) == 1 assert {row["SelectedName"]: row["Value"] for row in fresh_batches[0]["commands"]} == expected_values assert (fresh_batches[0].get("mapping_bulk") is True) is capable assert not hub._pending_commands finally: await hub.stop_auto_refresh() await env.service.stop_live_refreshes() await env.service.client.aclose() asyncio.run(scenario()) def test_http_config_use_with_real_controller_and_capable_agent_delivers_builtin_db_score(env): _exercise_config_use(env) def test_http_config_use_with_real_installed_runtime_audit_delivers_score(audited_env): env, store = audited_env # Execute the identical HTTP/registration/ACK assertions through the hooks # installed by app.py, rather than replacing any hub boundary with a fake. test_http_config_use_with_real_controller_and_capable_agent_delivers_builtin_db_score(env) assert store.flush() records = store.query(preset="all", device_id=DEVICE)["items"] assert any(row["event"] == "vmix.batch.sent" for row in records) assert any(row["event"] == "command.ack" and row["level"] == "INFO" for row in records) assert TOKEN not in json.dumps(records) def test_http_apply_mapping_with_real_installed_runtime_audit_delivers_score(audited_env): env, store = audited_env _exercise_config_use(env, include_apply=True) assert store.flush() assert sum(row["event"] == "vmix.batch.sent" for row in store.query(preset="all", device_id=DEVICE)["items"]) == 2 def test_instrumented_legacy_agent_keeps_acknowledged_nonbulk_fallback(audited_env): env, store = audited_env _exercise_config_use(env, capable=False, include_apply=True) assert store.flush() batches = [row for row in store.query(preset="all", device_id=DEVICE)["items"] if row["event"] == "vmix.batch.sent"] assert len(batches) == 2 and all(row["details"].get("mapping_bulk") is not True for row in batches) def test_instrumented_http_config_use_logs_and_confirms_a_complete_372_field_title(audited_env): env, store = audited_env _exercise_config_use(env, field_count=372) assert store.flush() rows = store.query(preset="all", device_id=DEVICE)["items"] assert sum(row["event"] == "vmix.batch.sent" for row in rows) == 1 assert TOKEN not in json.dumps(rows) @pytest.mark.parametrize("change", ["current", "connection", "generation", "capability_string"]) def test_instrumented_bulk_rechecks_exact_connection_generation_and_capability_after_wire_wait(audited_env, change): env, store = audited_env async def scenario(): hub = env.hub first, replacement = ImmediateAckSocket(hub), ImmediateAckSocket(hub) task = None try: await hub.register(first, {"device_id": DEVICE, "device_secret": "x" * 40, "agent_version": "1.5.5", "capabilities": {"mapping_title_batch_v1": True}, "vmix": {"connected": True}}) await hub.pair_device(DEVICE, env.users["admin"]) assignment = await hub.assign_match(wfl_user_id="admin", game_external_id="g", tournament_external_id="t", device_id=DEVICE, wait_for_mapping=False) assert assignment and assignment["delivered"] live, generation = hub._live[DEVICE], hub._mapping_generations[DEVICE] wire = hub._wire_send_locks[DEVICE] before = len(first.sent) async with wire: task = asyncio.create_task(hub.send_vmix_batch(DEVICE, assignment_id=assignment["assignment_id"], match_id="g", commands=[{"Function": "SetText", "Input": "Score", "SelectedName": "Home.Text", "Value": "5"}], mapping_bulk=True, mapping_deadline_ms=4000, delivery_generation=generation, timeout=2)) await asyncio.sleep(0) assert not task.done() and len(hub._pending_commands) == 1 assert len(first.sent) == before and not replacement.sent if change == "connection": # Replace only LiveAgent identity, keeping generation equal: # dropping expected_connection must be independently caught. hub._live[DEVICE] = LiveAgent(replacement, datetime.utcnow(), True) assert hub._mapping_generations[DEVICE] == generation elif change == "generation": hub._invalidate_mapping_delivery(DEVICE) assert hub._live[DEVICE] is live elif change == "capability_string": # A truthy string must not pass the final strict bool check. live.mapping_title_batch_v1 = "true" if change == "current": answer = await task assert answer["ok"] is True assert len(first.sent) == before + 1 and not replacement.sent else: with pytest.raises(HTTPException) as rejected: await task assert rejected.value.status_code == 409 assert len(first.sent) == before and not replacement.sent assert not hub._pending_commands and not hub._mapping_value_cache finally: if task is not None and not task.done(): task.cancel() await asyncio.gather(task, return_exceptions=True) await hub.stop_auto_refresh() await env.service.stop_live_refreshes() await env.service.client.aclose() asyncio.run(scenario()) assert store.flush() serialized = json.dumps(store.query(preset="all", device_id=DEVICE)["items"]) assert "expected_connection" not in serialized and "expected_generation" not in serialized assert "LiveAgent(" not in serialized