9d4a534ec6
Release Notes overlay redesign (scoped via .release-notes-shell)
- Backend exposes release.assets (name/size/download_url) through
UpdateReleaseInfo so the frontend can render real download links.
- New masthead: eyebrow + display-font title + tag/published/pre-release
chip strip + close/external action buttons; opts out of layout.css's
global `header { height: 60px }` and `header::before` accent bar that
were leaking into the overlay's <header>.
- Markdown body: <code> filenames are wrapped in clickable <a> via fuzzy
asset match (exact basename, then same-extension token-overlap), with
per-asset description tooltip and a small download glyph.
- Per-asset description derived from filename pattern (Windows installer
/portable/msi, Linux tarball/AppImage/deb/rpm, macOS dmg/pkg, Android
apk/aab, iOS ipa, generic archives) with i18n keys in en/ru/zh.
- Hide checksum / signature side-files (.sha256/.sha512/.sig/.asc/...).
Settings modal & dashboard polish
- ds-section refresh, rail-channel routing, notif matrix updates.
- Dashboard customize panel + per-account layout updates.
- New docs/settings-modal-redesign.html design reference.
Streams / targets / color-strip
- Stream cards rewrite (cards.css, streams.css, streams.ts).
- Composite stream + metrics history adjustments.
- WLED target processor + color-strip pipeline refinements.
- Color-strip WS source streamer touch-ups.
Misc
- Perf charts overhaul; tabular game-integration / HA / MQTT / weather
source cards; donation/sync-clocks/scene-presets minor polish.
- New i18n keys across en/ru/zh.
Test infrastructure
- conftest pre-creates the test DB so main.py's legacy-data migration
doesn't shovel the user's production DB into the test temp dir.
- test_preferences_notifications wipes its own setting at the start of
the defaults test (was relying on isolation it never enforced).
Pre-commit gates: ruff clean, tsc clean, npm run build clean,
pytest 899/899 passing.
128 lines
4.5 KiB
Python
128 lines
4.5 KiB
Python
"""Tests for /api/v1/preferences/notifications endpoints."""
|
|
|
|
import pytest
|
|
|
|
from ledgrab.config import get_config
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
"""TestClient with auth header — same pattern as test_preferences_api.py."""
|
|
from fastapi.testclient import TestClient
|
|
|
|
from ledgrab.main import app
|
|
|
|
api_key = next(iter(get_config().auth.api_keys.values()), "")
|
|
with TestClient(app, raise_server_exceptions=False) as c:
|
|
if api_key:
|
|
c.headers["Authorization"] = f"Bearer {api_key}"
|
|
yield c
|
|
|
|
|
|
def _full_prefs() -> dict:
|
|
return {
|
|
"channels": {
|
|
"device_online": "snack",
|
|
"device_offline": "both",
|
|
"device_discovered": "os",
|
|
"device_lost": "none",
|
|
},
|
|
"background_discovery_enabled": True,
|
|
"startup_grace_sec": 15,
|
|
"flap_debounce_sec": 7,
|
|
}
|
|
|
|
|
|
def test_get_returns_defaults_when_unset(client):
|
|
"""When no prefs have been saved, GET returns the documented defaults."""
|
|
# Wipe the stored row to a falsy value so this test is independent of
|
|
# any prior test in the suite that may have PUT a customised matrix.
|
|
# `load_notification_preferences` falls back to schema defaults when
|
|
# the stored value is empty / falsy.
|
|
from ledgrab.api.dependencies import get_database
|
|
|
|
db = get_database()
|
|
db.set_setting("notification_preferences", {})
|
|
|
|
resp = client.get("/api/v1/preferences/notifications")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["background_discovery_enabled"] is True
|
|
assert body["startup_grace_sec"] == 10
|
|
assert body["flap_debounce_sec"] == 5
|
|
# Default channel matrix
|
|
assert body["channels"]["device_online"] == "snack"
|
|
assert body["channels"]["device_offline"] == "both"
|
|
assert body["channels"]["device_discovered"] == "snack"
|
|
assert body["channels"]["device_lost"] == "none"
|
|
|
|
|
|
def test_put_then_get_round_trips(client):
|
|
"""PUT a payload, GET it back unchanged."""
|
|
payload = _full_prefs()
|
|
put = client.put("/api/v1/preferences/notifications", json=payload)
|
|
assert put.status_code == 200
|
|
assert put.json()["startup_grace_sec"] == 15
|
|
|
|
got = client.get("/api/v1/preferences/notifications")
|
|
assert got.status_code == 200
|
|
assert got.json() == payload
|
|
|
|
|
|
def test_put_rejects_invalid_channel(client):
|
|
"""A bogus channel value (e.g. 'siren') is rejected by Pydantic."""
|
|
bad = _full_prefs()
|
|
bad["channels"]["device_offline"] = "siren"
|
|
resp = client.put("/api/v1/preferences/notifications", json=bad)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_put_rejects_grace_out_of_range(client):
|
|
"""startup_grace_sec is clamped to [0, 300]."""
|
|
bad = _full_prefs()
|
|
bad["startup_grace_sec"] = -5
|
|
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
|
|
|
bad["startup_grace_sec"] = 9999
|
|
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
|
|
|
|
|
def test_put_rejects_debounce_out_of_range(client):
|
|
"""flap_debounce_sec is clamped to [0, 60]."""
|
|
bad = _full_prefs()
|
|
bad["flap_debounce_sec"] = 999
|
|
assert client.put("/api/v1/preferences/notifications", json=bad).status_code == 422
|
|
|
|
|
|
def test_partial_payload_uses_defaults_for_omitted_channels(client):
|
|
"""Pydantic fills in default channels when the matrix is partial.
|
|
|
|
The frontend may want to PUT only what changed; the backend should
|
|
fill in the default channel matrix for omitted rows so we don't
|
|
silently lose user preferences via partial-write.
|
|
"""
|
|
partial = {"background_discovery_enabled": False}
|
|
resp = client.put("/api/v1/preferences/notifications", json=partial)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["background_discovery_enabled"] is False
|
|
# Defaulted matrix is present
|
|
assert body["channels"]["device_offline"] == "both"
|
|
|
|
|
|
def test_corrupt_stored_value_falls_back_to_defaults(client):
|
|
"""If something stomps on the stored row, the GET handler must
|
|
return defaults instead of 500. Mirrors how load_shutdown_action
|
|
treats corrupt input."""
|
|
# Stuff garbage into the underlying setting via the same Database
|
|
# the route uses, then verify GET still works.
|
|
from ledgrab.api.dependencies import get_database
|
|
|
|
db = get_database()
|
|
db.set_setting("notification_preferences", {"channels": "totally-wrong"})
|
|
|
|
resp = client.get("/api/v1/preferences/notifications")
|
|
assert resp.status_code == 200
|
|
# Defaults restored
|
|
assert resp.json()["channels"]["device_offline"] == "both"
|