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:
314
server/tests/storage/test_base_store.py
Normal file
314
server/tests/storage/test_base_store.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Tests for BaseJsonStore — the shared data-layer base class."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from wled_controller.storage.base_store import BaseJsonStore, EntityNotFoundError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal concrete store for testing the base class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Item:
|
||||
id: str
|
||||
name: str
|
||||
value: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.id, "name": self.name, "value": self.value}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: dict) -> "_Item":
|
||||
return _Item(id=data["id"], name=data["name"], value=data.get("value", 0))
|
||||
|
||||
|
||||
class _TestStore(BaseJsonStore[_Item]):
|
||||
_json_key = "items"
|
||||
_entity_name = "Item"
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, _Item.from_dict)
|
||||
|
||||
def add(self, item: _Item) -> None:
|
||||
with self._lock:
|
||||
self._check_name_unique(item.name)
|
||||
self._items[item.id] = item
|
||||
self._save()
|
||||
|
||||
|
||||
class _LegacyStore(BaseJsonStore[_Item]):
|
||||
"""Store that supports legacy JSON keys for migration testing."""
|
||||
_json_key = "items_v2"
|
||||
_entity_name = "Item"
|
||||
_legacy_json_keys = ["items_v1", "old_items"]
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
super().__init__(file_path, _Item.from_dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store_file(tmp_path) -> Path:
|
||||
return tmp_path / "test_store.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(store_file) -> _TestStore:
|
||||
return _TestStore(str(store_file))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_empty_init(self, store):
|
||||
assert store.count() == 0
|
||||
assert store.get_all() == []
|
||||
|
||||
def test_file_not_found_starts_empty(self, tmp_path):
|
||||
s = _TestStore(str(tmp_path / "missing.json"))
|
||||
assert s.count() == 0
|
||||
|
||||
def test_load_from_existing_file(self, store_file):
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"items": {
|
||||
"a": {"id": "a", "name": "Alpha", "value": 1},
|
||||
"b": {"id": "b", "name": "Beta", "value": 2},
|
||||
},
|
||||
}
|
||||
store_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
s = _TestStore(str(store_file))
|
||||
assert s.count() == 2
|
||||
assert s.get("a").name == "Alpha"
|
||||
assert s.get("b").value == 2
|
||||
|
||||
def test_load_skips_corrupt_items(self, store_file):
|
||||
"""Items that fail deserialization are skipped, not fatal."""
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"items": {
|
||||
"good": {"id": "good", "name": "OK"},
|
||||
"bad": {"missing_required": True},
|
||||
},
|
||||
}
|
||||
store_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
s = _TestStore(str(store_file))
|
||||
assert s.count() == 1
|
||||
assert s.get("good").name == "OK"
|
||||
|
||||
def test_load_corrupt_json_raises(self, store_file):
|
||||
"""Completely invalid JSON file raises on load."""
|
||||
store_file.write_text("{bad json", encoding="utf-8")
|
||||
with pytest.raises(Exception):
|
||||
_TestStore(str(store_file))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CRUD operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCRUD:
|
||||
def test_get_all_returns_list(self, store):
|
||||
store.add(_Item(id="x", name="X"))
|
||||
items = store.get_all()
|
||||
assert isinstance(items, list)
|
||||
assert len(items) == 1
|
||||
|
||||
def test_get_existing(self, store):
|
||||
store.add(_Item(id="x", name="X", value=42))
|
||||
item = store.get("x")
|
||||
assert item.id == "x"
|
||||
assert item.value == 42
|
||||
|
||||
def test_get_not_found_raises(self, store):
|
||||
with pytest.raises(EntityNotFoundError, match="not found"):
|
||||
store.get("nonexistent")
|
||||
|
||||
def test_delete_existing(self, store):
|
||||
store.add(_Item(id="x", name="X"))
|
||||
store.delete("x")
|
||||
assert store.count() == 0
|
||||
|
||||
def test_delete_not_found_raises(self, store):
|
||||
with pytest.raises(EntityNotFoundError, match="not found"):
|
||||
store.delete("nonexistent")
|
||||
|
||||
def test_count(self, store):
|
||||
assert store.count() == 0
|
||||
store.add(_Item(id="a", name="A"))
|
||||
assert store.count() == 1
|
||||
store.add(_Item(id="b", name="B"))
|
||||
assert store.count() == 2
|
||||
store.delete("a")
|
||||
assert store.count() == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence (save/load round-trip)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_save_and_reload(self, store_file):
|
||||
s1 = _TestStore(str(store_file))
|
||||
s1.add(_Item(id="p1", name="Persisted", value=99))
|
||||
|
||||
# Load fresh from the same file
|
||||
s2 = _TestStore(str(store_file))
|
||||
assert s2.count() == 1
|
||||
assert s2.get("p1").value == 99
|
||||
|
||||
def test_delete_persists(self, store_file):
|
||||
s1 = _TestStore(str(store_file))
|
||||
s1.add(_Item(id="del", name="ToDelete"))
|
||||
s1.delete("del")
|
||||
|
||||
s2 = _TestStore(str(store_file))
|
||||
assert s2.count() == 0
|
||||
|
||||
def test_json_file_structure(self, store, store_file):
|
||||
store.add(_Item(id="s1", name="Struct", value=7))
|
||||
raw = json.loads(store_file.read_text(encoding="utf-8"))
|
||||
assert "version" in raw
|
||||
assert "items" in raw
|
||||
assert raw["items"]["s1"]["name"] == "Struct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Name uniqueness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNameUniqueness:
|
||||
def test_duplicate_name_raises(self, store):
|
||||
store.add(_Item(id="a", name="Unique"))
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
store.add(_Item(id="b", name="Unique"))
|
||||
|
||||
def test_different_names_ok(self, store):
|
||||
store.add(_Item(id="a", name="Alpha"))
|
||||
store.add(_Item(id="b", name="Beta"))
|
||||
assert store.count() == 2
|
||||
|
||||
def test_empty_name_raises(self, store):
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
store._check_name_unique("")
|
||||
|
||||
def test_whitespace_name_raises(self, store):
|
||||
with pytest.raises(ValueError, match="required"):
|
||||
store._check_name_unique(" ")
|
||||
|
||||
def test_exclude_id_allows_self(self, store):
|
||||
store.add(_Item(id="a", name="Alpha"))
|
||||
# Checking uniqueness for a rename of item "a" — should not conflict with itself
|
||||
store._check_name_unique("Alpha", exclude_id="a") # should not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThreadSafety:
|
||||
def test_concurrent_reads(self, store):
|
||||
for i in range(20):
|
||||
store.add(_Item(id=f"t{i}", name=f"Thread {i}"))
|
||||
|
||||
results = []
|
||||
|
||||
def _read():
|
||||
return store.count()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [pool.submit(_read) for _ in range(50)]
|
||||
results = [f.result() for f in as_completed(futures)]
|
||||
|
||||
assert all(r == 20 for r in results)
|
||||
|
||||
def test_concurrent_add_and_read(self, tmp_path):
|
||||
"""Concurrent adds should not lose items or corrupt state."""
|
||||
s = _TestStore(str(tmp_path / "concurrent.json"))
|
||||
errors = []
|
||||
|
||||
def _add(index):
|
||||
try:
|
||||
s.add(_Item(id=f"c{index}", name=f"Conc {index}"))
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||
futures = [pool.submit(_add, i) for i in range(30)]
|
||||
for f in as_completed(futures):
|
||||
f.result()
|
||||
|
||||
assert len(errors) == 0
|
||||
assert s.count() == 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy key migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLegacyKeyMigration:
|
||||
def test_loads_from_legacy_key(self, store_file):
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"items_v1": {
|
||||
"old1": {"id": "old1", "name": "Legacy"},
|
||||
},
|
||||
}
|
||||
store_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
s = _LegacyStore(str(store_file))
|
||||
assert s.count() == 1
|
||||
assert s.get("old1").name == "Legacy"
|
||||
|
||||
def test_primary_key_takes_precedence(self, store_file):
|
||||
data = {
|
||||
"version": "1.0.0",
|
||||
"items_v2": {"new": {"id": "new", "name": "Primary"}},
|
||||
"items_v1": {"old": {"id": "old", "name": "Legacy"}},
|
||||
}
|
||||
store_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
s = _LegacyStore(str(store_file))
|
||||
assert s.count() == 1
|
||||
assert s.get("new").name == "Primary"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncDelete:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_delete(self, store):
|
||||
store.add(_Item(id="ad", name="AsyncDel"))
|
||||
assert store.count() == 1
|
||||
await store.async_delete("ad")
|
||||
assert store.count() == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_delete_not_found(self, store):
|
||||
with pytest.raises(EntityNotFoundError, match="not found"):
|
||||
await store.async_delete("nope")
|
||||
Reference in New Issue
Block a user