6745e25b20
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.
585 lines
20 KiB
Python
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
|