301 lines
16 KiB
Python
301 lines
16 KiB
Python
"""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
|