refactor: comprehensive code quality, security, and release readiness improvements
Some checks failed
Lint & Test / test (push) Failing after 48s
Some checks failed
Lint & Test / test (push) Failing after 48s
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in automations, guard WebSocket JSON.parse, validate ADB address input, seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors. Code quality: add Pydantic models for brightness/power endpoints, fix thread safety and name uniqueness in DeviceStore, immutable update pattern, split 6 oversized files into 16 focused modules, enable TypeScript strictNullChecks (741→102 errors), type state variables, add dom-utils helper, migrate 3 modules from inline onclick to event delegation, ProcessorDependencies dataclass. Performance: async store saves, health endpoint log level, command palette debounce, optimized entity-events comparison, fix service worker precache list. Testing: expand from 45 to 293 passing tests — add store tests (141), route tests (25), core logic tests (42), E2E flow tests (33), organize into tests/api/, tests/storage/, tests/core/, tests/e2e/. DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage build with non-root user and health check, docker-compose improvements, version bump to 0.2.0. Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76), create contexts/server-operations.md, fix .js→.ts references, fix env var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and .env.example.
This commit is contained in:
255
server/tests/storage/test_automation_store.py
Normal file
255
server/tests/storage/test_automation_store.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Tests for AutomationStore — CRUD, conditions, name uniqueness."""
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.storage.automation import (
|
||||
AlwaysCondition,
|
||||
ApplicationCondition,
|
||||
Automation,
|
||||
Condition,
|
||||
DisplayStateCondition,
|
||||
MQTTCondition,
|
||||
StartupCondition,
|
||||
SystemIdleCondition,
|
||||
TimeOfDayCondition,
|
||||
WebhookCondition,
|
||||
)
|
||||
from wled_controller.storage.automation_store import AutomationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path) -> AutomationStore:
|
||||
return AutomationStore(str(tmp_path / "automations.json"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Condition models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConditionModels:
|
||||
def test_always_round_trip(self):
|
||||
c = AlwaysCondition()
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, AlwaysCondition)
|
||||
|
||||
def test_application_round_trip(self):
|
||||
c = ApplicationCondition(apps=["chrome.exe", "firefox.exe"], match_type="topmost")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, ApplicationCondition)
|
||||
assert restored.apps == ["chrome.exe", "firefox.exe"]
|
||||
assert restored.match_type == "topmost"
|
||||
|
||||
def test_time_of_day_round_trip(self):
|
||||
c = TimeOfDayCondition(start_time="22:00", end_time="06:00")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, TimeOfDayCondition)
|
||||
assert restored.start_time == "22:00"
|
||||
assert restored.end_time == "06:00"
|
||||
|
||||
def test_system_idle_round_trip(self):
|
||||
c = SystemIdleCondition(idle_minutes=10, when_idle=False)
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, SystemIdleCondition)
|
||||
assert restored.idle_minutes == 10
|
||||
assert restored.when_idle is False
|
||||
|
||||
def test_display_state_round_trip(self):
|
||||
c = DisplayStateCondition(state="off")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, DisplayStateCondition)
|
||||
assert restored.state == "off"
|
||||
|
||||
def test_mqtt_round_trip(self):
|
||||
c = MQTTCondition(topic="home/tv", payload="on", match_mode="contains")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, MQTTCondition)
|
||||
assert restored.topic == "home/tv"
|
||||
assert restored.match_mode == "contains"
|
||||
|
||||
def test_webhook_round_trip(self):
|
||||
c = WebhookCondition(token="abc123")
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, WebhookCondition)
|
||||
assert restored.token == "abc123"
|
||||
|
||||
def test_startup_round_trip(self):
|
||||
c = StartupCondition()
|
||||
data = c.to_dict()
|
||||
restored = Condition.from_dict(data)
|
||||
assert isinstance(restored, StartupCondition)
|
||||
|
||||
def test_unknown_condition_type_raises(self):
|
||||
with pytest.raises(ValueError, match="Unknown condition type"):
|
||||
Condition.from_dict({"condition_type": "nonexistent"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automation model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutomationModel:
|
||||
def test_round_trip(self, make_automation):
|
||||
auto = make_automation(
|
||||
name="Test Auto",
|
||||
conditions=[AlwaysCondition(), WebhookCondition(token="tok1")],
|
||||
scene_preset_id="sp_123",
|
||||
deactivation_mode="revert",
|
||||
)
|
||||
data = auto.to_dict()
|
||||
restored = Automation.from_dict(data)
|
||||
|
||||
assert restored.id == auto.id
|
||||
assert restored.name == "Test Auto"
|
||||
assert len(restored.conditions) == 2
|
||||
assert isinstance(restored.conditions[0], AlwaysCondition)
|
||||
assert isinstance(restored.conditions[1], WebhookCondition)
|
||||
assert restored.scene_preset_id == "sp_123"
|
||||
assert restored.deactivation_mode == "revert"
|
||||
|
||||
def test_from_dict_skips_unknown_conditions(self):
|
||||
data = {
|
||||
"id": "a1",
|
||||
"name": "Skip",
|
||||
"enabled": True,
|
||||
"condition_logic": "or",
|
||||
"conditions": [
|
||||
{"condition_type": "always"},
|
||||
{"condition_type": "future_unknown"},
|
||||
],
|
||||
"scene_preset_id": None,
|
||||
"deactivation_mode": "none",
|
||||
"deactivation_scene_preset_id": None,
|
||||
"created_at": "2025-01-01T00:00:00+00:00",
|
||||
"updated_at": "2025-01-01T00:00:00+00:00",
|
||||
}
|
||||
auto = Automation.from_dict(data)
|
||||
assert len(auto.conditions) == 1 # unknown was skipped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AutomationStore CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutomationStoreCRUD:
|
||||
def test_create(self, store):
|
||||
a = store.create_automation(name="Auto A")
|
||||
assert a.id.startswith("auto_")
|
||||
assert a.name == "Auto A"
|
||||
assert a.enabled is True
|
||||
assert a.condition_logic == "or"
|
||||
assert store.count() == 1
|
||||
|
||||
def test_create_with_conditions(self, store):
|
||||
conditions = [
|
||||
AlwaysCondition(),
|
||||
WebhookCondition(token="secret123"),
|
||||
]
|
||||
a = store.create_automation(
|
||||
name="Full",
|
||||
enabled=False,
|
||||
condition_logic="and",
|
||||
conditions=conditions,
|
||||
scene_preset_id="sp_001",
|
||||
deactivation_mode="fallback_scene",
|
||||
deactivation_scene_preset_id="sp_002",
|
||||
tags=["test"],
|
||||
)
|
||||
assert a.enabled is False
|
||||
assert a.condition_logic == "and"
|
||||
assert len(a.conditions) == 2
|
||||
assert a.scene_preset_id == "sp_001"
|
||||
assert a.tags == ["test"]
|
||||
|
||||
def test_get_all(self, store):
|
||||
store.create_automation("A")
|
||||
store.create_automation("B")
|
||||
assert len(store.get_all_automations()) == 2
|
||||
|
||||
def test_get(self, store):
|
||||
created = store.create_automation("Get")
|
||||
got = store.get_automation(created.id)
|
||||
assert got.name == "Get"
|
||||
|
||||
def test_delete(self, store):
|
||||
a = store.create_automation("Del")
|
||||
store.delete_automation(a.id)
|
||||
assert store.count() == 0
|
||||
|
||||
def test_delete_not_found(self, store):
|
||||
with pytest.raises(ValueError, match="not found"):
|
||||
store.delete_automation("nope")
|
||||
|
||||
def test_update(self, store):
|
||||
a = store.create_automation(name="Old", enabled=True)
|
||||
updated = store.update_automation(a.id, name="New", enabled=False)
|
||||
assert updated.name == "New"
|
||||
assert updated.enabled is False
|
||||
|
||||
def test_update_conditions(self, store):
|
||||
a = store.create_automation(name="Conds")
|
||||
new_conds = [ApplicationCondition(apps=["notepad.exe"])]
|
||||
updated = store.update_automation(a.id, conditions=new_conds)
|
||||
assert len(updated.conditions) == 1
|
||||
assert isinstance(updated.conditions[0], ApplicationCondition)
|
||||
|
||||
def test_update_scene_preset_id_clear(self, store):
|
||||
a = store.create_automation(name="SP", scene_preset_id="sp_1")
|
||||
updated = store.update_automation(a.id, scene_preset_id="")
|
||||
assert updated.scene_preset_id is None
|
||||
|
||||
def test_update_partial(self, store):
|
||||
a = store.create_automation(name="Partial", enabled=True, tags=["orig"])
|
||||
updated = store.update_automation(a.id, tags=["new"])
|
||||
assert updated.name == "Partial"
|
||||
assert updated.enabled is True
|
||||
assert updated.tags == ["new"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name uniqueness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutomationNameUniqueness:
|
||||
def test_duplicate_name_create(self, store):
|
||||
store.create_automation("Dup")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.create_automation("Dup")
|
||||
|
||||
def test_duplicate_name_update(self, store):
|
||||
store.create_automation("First")
|
||||
a2 = store.create_automation("Second")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.update_automation(a2.id, name="First")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutomationPersistence:
|
||||
def test_persist_and_reload(self, tmp_path):
|
||||
path = str(tmp_path / "auto_persist.json")
|
||||
s1 = AutomationStore(path)
|
||||
a = s1.create_automation(
|
||||
name="Persist",
|
||||
conditions=[WebhookCondition(token="t1")],
|
||||
)
|
||||
aid = a.id
|
||||
|
||||
s2 = AutomationStore(path)
|
||||
loaded = s2.get_automation(aid)
|
||||
assert loaded.name == "Persist"
|
||||
assert len(loaded.conditions) == 1
|
||||
assert isinstance(loaded.conditions[0], WebhookCondition)
|
||||
Reference in New Issue
Block a user