Files
ledgrab/server/tests/api/routes/test_game_integration_routes.py
T
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
Eight roadmap features from the 2026-06-19 review, each a full vertical
(backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests:

- automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared
  with the daylight cycle; window logic mirrors TimeOfDayRule)
- ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest
  (release.yml; amd64 path untouched, continue-on-error)
- game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared
  runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown)
- ui: color-harmony gradient generator (complementary/analogous/triadic/...)
- effects: audio-reactive palette modulation (new audio_energy_tap; brightness/
  saturation modulation across all 12 procedural effects)
- capture: linear-light blending + spatio-temporal dithering, opt-in per
  calibration (new utils/linear_light.py, utils/dither.py)
- devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode)

Also bundles the pending 2026-06-18 production-review fixes and other
in-progress work already in the working tree (manual-trigger rule, etc.),
since they share files and could not be cleanly separated.

Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing
test (automation manual_trigger handler coverage) is a separate in-progress
item owned elsewhere, intentionally left as-is.
2026-06-22 23:21:24 +03:00

585 lines
20 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 ledgrab.api.routes.game_integration import router
from ledgrab.core.game_integration.adapter_registry import AdapterRegistry
from ledgrab.core.game_integration.base_adapter import GameAdapter
from ledgrab.core.game_integration.event_bus import GameEventBus
from ledgrab.core.game_integration.events import GameEvent
from ledgrab.storage.game_integration_store import GameIntegrationStore
from ledgrab.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 ledgrab.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 ledgrab.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"],
)
# The auth_token is a live shared secret and must NEVER be echoed back
# over the API — it is masked to "" in every response.
assert data["adapter_config"] == {"auth_token": ""}
assert data["description"] == "My game"
assert data["tags"] == ["fps"]
def test_update_with_blank_token_preserves_secret(self, client, game_store):
"""The API masks secrets, so the edit form re-submits a blank token for
an unchanged secret. The update must PRESERVE the stored secret rather
than overwrite it with the blank (otherwise a no-op edit wipes the key).
"""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"name": "Renamed", "adapter_config": {"auth_token": ""}},
)
assert resp.status_code == 200, resp.text
# The stored (decrypted) secret is unchanged despite the blank submit.
cfg = game_store.get_integration(gi_id)
assert cfg.adapter_config.get("auth_token") == "secret123"
def test_update_with_new_token_replaces_secret(self, client, game_store):
"""A non-empty token in the update is a deliberate change and is kept."""
created = _create_integration(client, adapter_config={"auth_token": "secret123"})
gi_id = created["id"]
resp = client.put(
f"/api/v1/game-integrations/{gi_id}",
json={"adapter_config": {"auth_token": "rotated456"}},
)
assert resp.status_code == 200, resp.text
assert game_store.get_integration(gi_id).adapter_config.get("auth_token") == "rotated456"
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
# ---------------------------------------------------------------------------
# Failed-auth rate limiting (brute-force defence on the ingest route)
# ---------------------------------------------------------------------------
@pytest.fixture
def _reset_auth_fail_limiter():
"""Clear the module-level failed-auth hit map before and after each test.
The limiter keeps per-IP state in a process-global dict, so without this
reset, attempts from earlier tests would bleed into later ones.
"""
from ledgrab.api.routes import game_integration as gi
gi._auth_fail_hits.clear()
yield
gi._auth_fail_hits.clear()
class TestIngestRateLimiting:
def test_failed_auth_attempts_are_rate_limited(self, client, _reset_auth_fail_limiter):
from ledgrab.api.routes import game_integration as gi
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
url = f"/api/v1/game-integrations/{integration_id}/event"
bad = {"x-auth-token": "wrong_token"}
# Burn through the failed-auth budget — each returns 403.
for _ in range(gi._AUTH_FAIL_LIMIT):
resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
assert resp.status_code == 403
# The next attempt from the same IP is throttled with 429.
resp = client.post(url, json={"data": {"health": 1}}, headers=bad)
assert resp.status_code == 429
def test_successful_ingest_not_rate_limited(self, client, event_bus, _reset_auth_fail_limiter):
from ledgrab.api.routes import game_integration as gi
created = _create_integration(
client,
adapter_config={"auth_token": "correct_token"},
)
integration_id = created["id"]
url = f"/api/v1/game-integrations/{integration_id}/event"
good = {"x-auth-token": "correct_token"}
# High-rate legitimate ingestion well past the failed-auth threshold
# must NOT be throttled — only failures count toward the limit.
for _ in range(gi._AUTH_FAIL_LIMIT + 10):
resp = client.post(url, json={"data": {"health": 50}}, headers=good)
assert resp.status_code == 204
# ---------------------------------------------------------------------------
# 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