Files
ledgrab/server/tests/api/routes/test_game_integration_routes.py
T
alexei.dolgolyov 492bdb95e3 feat: game integration system
Receive real-time events from games (CS2, Dota 2, LoL, etc.) and drive
LED effects through the existing color strip and value source pipelines.

Core:
- GameEventBus (thread-safe pub/sub) with standardized 23-type event vocabulary
- GameAdapter ABC + AdapterRegistry + MappingAdapter (YAML-driven)
- Built-in adapters: CS2 GSI, Dota 2 GSI, LoL Live Client, Generic Webhook
- Community YAML adapters: Minecraft, Valorant, Rocket League
- GameEventColorStripStream with 5 effects (flash/pulse/sweep/color_shift/breathing)
- GameEventValueSource with EMA smoothing and timeout
- 4 built-in effect presets (FPS Combat, MOBA Health, Racing, Generic Alert)
- Auto-setup for Valve GSI games (Steam path detection, cfg file writing)
- Demo capture engine exposed to non-demo mode

Frontend:
- Game tab in Streams tree navigation with integration cards
- Game integration editor modal with adapter picker, config fields, event mappings
- game_event source type in CSS and ValueSource editors
- Setup instructions overlay (markdown rendered)
- Live event monitor and connection test

API:
- Full CRUD for game integrations
- Event ingestion endpoint (adapter-level auth)
- Adapter metadata, presets, auto-setup, status/diagnostics endpoints
2026-03-31 13:17:52 +03:00

496 lines
16 KiB
Python

"""Tests for game integration API routes.
Uses FastAPI TestClient with dependency overrides to test route handlers
in isolation from the real application.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wled_controller.api.routes.game_integration import router
from wled_controller.core.game_integration.adapter_registry import AdapterRegistry
from wled_controller.core.game_integration.base_adapter import GameAdapter
from wled_controller.core.game_integration.event_bus import GameEventBus
from wled_controller.core.game_integration.events import GameEvent
from wled_controller.storage.game_integration_store import GameIntegrationStore
from wled_controller.api import dependencies as deps
# ---------------------------------------------------------------------------
# Test adapter for ingestion tests
# ---------------------------------------------------------------------------
class _TestAdapter(GameAdapter):
ADAPTER_TYPE = "test_adapter"
DISPLAY_NAME = "Test Adapter"
GAME_NAME = "Test Game"
SUPPORTED_EVENTS = ["health", "kill"]
@classmethod
def parse_payload(cls, payload, adapter_config, prev_state):
events = []
if "health" in payload:
events.append(
GameEvent(
adapter_id=adapter_config.get("integration_id", "test"),
event_type="health",
value=payload["health"] / 100.0,
)
)
return events, prev_state
@classmethod
def validate_auth(cls, headers, payload, adapter_config):
token = adapter_config.get("auth_token")
if not token:
return True
return headers.get("x-auth-token") == token
@classmethod
def get_config_schema(cls):
return {
"type": "object",
"properties": {
"auth_token": {"type": "string", "description": "Auth token"},
},
}
@classmethod
def get_setup_instructions(cls):
return "Configure the test adapter with an auth token."
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_app():
app = FastAPI()
app.include_router(router)
return app
@pytest.fixture
def _route_db(tmp_path):
from wled_controller.storage.database import Database
db = Database(tmp_path / "test.db")
yield db
db.close()
@pytest.fixture
def game_store(_route_db):
return GameIntegrationStore(_route_db)
@pytest.fixture
def event_bus():
return GameEventBus()
@pytest.fixture(autouse=True)
def _register_test_adapter():
"""Register and clean up the test adapter for each test."""
AdapterRegistry.register(_TestAdapter)
yield
AdapterRegistry.clear_registry()
@pytest.fixture
def client(game_store, event_bus):
app = _make_app()
# Override auth to always pass
from wled_controller.api.auth import verify_api_key
app.dependency_overrides[verify_api_key] = lambda: "test-user"
app.dependency_overrides[deps.get_game_integration_store] = lambda: game_store
app.dependency_overrides[deps.get_game_event_bus] = lambda: event_bus
return TestClient(app, raise_server_exceptions=False)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _create_integration(client, name="Test Integration", adapter_type="test_adapter", **kwargs):
"""Helper to create an integration via API."""
body = {
"name": name,
"adapter_type": adapter_type,
**kwargs,
}
resp = client.post("/api/v1/game-integrations", json=body)
assert resp.status_code == 201, resp.text
return resp.json()
# ---------------------------------------------------------------------------
# CRUD tests
# ---------------------------------------------------------------------------
class TestCreateIntegration:
def test_create_basic(self, client):
data = _create_integration(client)
assert data["id"].startswith("gi_")
assert data["name"] == "Test Integration"
assert data["adapter_type"] == "test_adapter"
assert data["enabled"] is True
def test_create_with_mappings(self, client):
data = _create_integration(
client,
event_mappings=[
{"event_type": "health", "effect": "gradient", "color": [255, 0, 0]},
{"event_type": "kill", "effect": "flash", "color": [0, 255, 0]},
],
)
assert len(data["event_mappings"]) == 2
assert data["event_mappings"][0]["event_type"] == "health"
def test_create_with_config(self, client):
data = _create_integration(
client,
adapter_config={"auth_token": "secret123"},
description="My game",
tags=["fps"],
)
assert data["adapter_config"] == {"auth_token": "secret123"}
assert data["description"] == "My game"
assert data["tags"] == ["fps"]
def test_create_duplicate_name(self, client):
_create_integration(client, name="Unique")
resp = client.post(
"/api/v1/game-integrations",
json={"name": "Unique", "adapter_type": "test_adapter"},
)
assert resp.status_code == 400
assert "already exists" in resp.json()["detail"]
def test_create_empty_name(self, client):
resp = client.post(
"/api/v1/game-integrations",
json={"name": "", "adapter_type": "test_adapter"},
)
assert resp.status_code == 422 # Pydantic validation
class TestListIntegrations:
def test_list_empty(self, client):
resp = client.get("/api/v1/game-integrations")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["integrations"] == []
def test_list_multiple(self, client):
_create_integration(client, name="Int 1")
_create_integration(client, name="Int 2")
resp = client.get("/api/v1/game-integrations")
data = resp.json()
assert data["count"] == 2
names = {i["name"] for i in data["integrations"]}
assert names == {"Int 1", "Int 2"}
class TestGetIntegration:
def test_get_existing(self, client):
created = _create_integration(client)
resp = client.get(f"/api/v1/game-integrations/{created['id']}")
assert resp.status_code == 200
assert resp.json()["name"] == "Test Integration"
def test_get_nonexistent(self, client):
resp = client.get("/api/v1/game-integrations/gi_nonexist")
assert resp.status_code == 404
class TestUpdateIntegration:
def test_update_name(self, client):
created = _create_integration(client)
resp = client.put(
f"/api/v1/game-integrations/{created['id']}",
json={"name": "Updated Name"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "Updated Name"
def test_update_enabled(self, client):
created = _create_integration(client)
resp = client.put(
f"/api/v1/game-integrations/{created['id']}",
json={"enabled": False},
)
assert resp.status_code == 200
assert resp.json()["enabled"] is False
def test_update_nonexistent(self, client):
resp = client.put(
"/api/v1/game-integrations/gi_nonexist",
json={"name": "x"},
)
assert resp.status_code == 404
def test_update_to_duplicate_name(self, client):
_create_integration(client, name="Name A")
b = _create_integration(client, name="Name B")
resp = client.put(
f"/api/v1/game-integrations/{b['id']}",
json={"name": "Name A"},
)
assert resp.status_code == 400
class TestDeleteIntegration:
def test_delete_existing(self, client):
created = _create_integration(client)
resp = client.delete(f"/api/v1/game-integrations/{created['id']}")
assert resp.status_code == 204
# Verify it's gone
resp = client.get(f"/api/v1/game-integrations/{created['id']}")
assert resp.status_code == 404
def test_delete_nonexistent(self, client):
resp = client.delete("/api/v1/game-integrations/gi_nonexist")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Event ingestion tests
# ---------------------------------------------------------------------------
class TestEventIngestion:
def test_ingest_basic(self, client, event_bus):
created = _create_integration(client)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 75}},
)
assert resp.status_code == 204
# Verify event was published to bus
recent = event_bus.get_recent_events()
assert len(recent) == 1
assert recent[0].event_type == "health"
assert recent[0].value == 0.75
def test_ingest_disabled_integration(self, client):
created = _create_integration(client)
integration_id = created["id"]
# Disable the integration
client.put(
f"/api/v1/game-integrations/{integration_id}",
json={"enabled": False},
)
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 50}},
)
assert resp.status_code == 409
def test_ingest_nonexistent_integration(self, client):
resp = client.post(
"/api/v1/game-integrations/gi_nonexist/event",
json={"data": {"health": 50}},
)
assert resp.status_code == 404
def test_ingest_auth_failure(self, client):
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 50}},
headers={"x-auth-token": "wrong_token"},
)
assert resp.status_code == 403
def test_ingest_auth_success(self, client, event_bus):
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 50}},
headers={"x-auth-token": "correct_token"},
)
assert resp.status_code == 204
recent = event_bus.get_recent_events()
assert len(recent) == 1
# ---------------------------------------------------------------------------
# Status / diagnostics tests
# ---------------------------------------------------------------------------
class TestStatus:
def test_status_no_events(self, client):
created = _create_integration(client)
resp = client.get(f"/api/v1/game-integrations/{created['id']}/status")
assert resp.status_code == 200
data = resp.json()
assert data["integration_id"] == created["id"]
assert data["enabled"] is True
assert data["connected"] is False
assert data["event_count"] == 0
def test_status_after_events(self, client):
created = _create_integration(client)
integration_id = created["id"]
# Send an event
client.post(
f"/api/v1/game-integrations/{integration_id}/event",
json={"data": {"health": 80}},
)
resp = client.get(f"/api/v1/game-integrations/{integration_id}/status")
data = resp.json()
assert data["event_count"] == 1
assert data["connected"] is True
assert "health" in data["event_counts_by_type"]
def test_status_nonexistent(self, client):
resp = client.get("/api/v1/game-integrations/gi_nonexist/status")
assert resp.status_code == 404
class TestRecentEvents:
def test_recent_events_empty(self, client):
created = _create_integration(client)
resp = client.get(f"/api/v1/game-integrations/{created['id']}/events")
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 0
assert data["events"] == []
def test_recent_events_nonexistent(self, client):
resp = client.get("/api/v1/game-integrations/gi_nonexist/events")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Adapter metadata tests
# ---------------------------------------------------------------------------
class TestAdapterMetadata:
def test_list_adapters(self, client):
resp = client.get("/api/v1/game-adapters")
assert resp.status_code == 200
data = resp.json()
assert data["count"] >= 1
adapter = data["adapters"][0]
assert adapter["adapter_type"] == "test_adapter"
assert adapter["display_name"] == "Test Adapter"
assert adapter["game_name"] == "Test Game"
assert "health" in adapter["supported_events"]
assert "config_schema" in adapter
assert "setup_instructions" in adapter
# ---------------------------------------------------------------------------
# Preset tests
# ---------------------------------------------------------------------------
class TestPresets:
def test_list_presets(self, client):
resp = client.get("/api/v1/game-integrations/presets")
assert resp.status_code == 200
data = resp.json()
assert data["count"] >= 4
keys = {p["key"] for p in data["presets"]}
assert "fps_combat" in keys
assert "moba_health" in keys
assert "racing" in keys
assert "generic_alert" in keys
def test_preset_has_mappings(self, client):
resp = client.get("/api/v1/game-integrations/presets")
data = resp.json()
for preset in data["presets"]:
assert len(preset["event_mappings"]) > 0
for m in preset["event_mappings"]:
assert "event_type" in m
assert "effect" in m
assert "color" in m
def test_apply_preset_replace(self, client):
created = _create_integration(
client,
event_mappings=[{"event_type": "kill", "effect": "flash", "color": [255, 0, 0]}],
)
integration_id = created["id"]
assert len(created["event_mappings"]) == 1
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/apply-preset",
json={"preset_key": "fps_combat", "replace": True},
)
assert resp.status_code == 200
data = resp.json()
# Should have replaced the single mapping with preset mappings
assert len(data["event_mappings"]) >= 3
def test_apply_preset_append(self, client):
created = _create_integration(
client,
event_mappings=[{"event_type": "kill", "effect": "flash", "color": [255, 0, 0]}],
)
integration_id = created["id"]
resp = client.post(
f"/api/v1/game-integrations/{integration_id}/apply-preset",
json={"preset_key": "generic_alert", "replace": False},
)
assert resp.status_code == 200
data = resp.json()
# Should have original + preset mappings
assert len(data["event_mappings"]) >= 5
def test_apply_preset_unknown_key(self, client):
created = _create_integration(client)
resp = client.post(
f"/api/v1/game-integrations/{created['id']}/apply-preset",
json={"preset_key": "nonexistent"},
)
assert resp.status_code == 404
assert "not found" in resp.json()["detail"]
def test_apply_preset_unknown_integration(self, client):
resp = client.post(
"/api/v1/game-integrations/gi_nonexist/apply-preset",
json={"preset_key": "fps_combat"},
)
assert resp.status_code == 404