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.
616 lines
23 KiB
Python
616 lines
23 KiB
Python
"""Integration tests for Phase 3: Event instrumentation.
|
|
|
|
Coverage targets
|
|
----------------
|
|
- Entity create/update/delete emits a record with correct category/actor/name.
|
|
- An entity DELETE carries the entity name (not None).
|
|
- An auth failure emits a ``warning`` record; the attempted token NEVER appears
|
|
in any recorded field.
|
|
- A device health transition emits a record.
|
|
- A device discovery event emits a record.
|
|
- A capture start and a backup-create emit records.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from ledgrab.core.activity_log.context import current_actor
|
|
from ledgrab.core.activity_log.recorder import ActivityRecorder
|
|
from ledgrab.storage.activity_log import ActivityCategory, ActivitySeverity
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_recorder() -> tuple[ActivityRecorder, list, list]:
|
|
"""Return (recorder, persisted_entries, fired_events)."""
|
|
repo = MagicMock()
|
|
persisted: list = []
|
|
repo.record.side_effect = lambda entry: persisted.append(entry)
|
|
|
|
pm = MagicMock()
|
|
fired: list[dict] = []
|
|
pm.fire_event.side_effect = lambda evt: fired.append(evt)
|
|
|
|
recorder = ActivityRecorder(repo, pm)
|
|
return recorder, persisted, fired
|
|
|
|
|
|
def _patch_module_recorder(recorder: ActivityRecorder):
|
|
"""Context manager: patch the module-level recorder used by all non-DI sites."""
|
|
return patch(
|
|
"ledgrab.core.activity_log.recorder._recorder",
|
|
recorder,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Category A: Entity CRUD via fire_entity_event choke-point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEntityCrud:
|
|
"""fire_entity_event records entity create/update/delete with correct fields."""
|
|
|
|
def test_entity_created_emits_record(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
# Minimal _deps so the store lookup returns None (name resolved as None)
|
|
original_deps = dict(_deps)
|
|
try:
|
|
# Clear deps so store lookup path returns None
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
|
|
fire_entity_event("output_target", "created", "ot_test123")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
|
|
assert len(persisted) == 1
|
|
entry = persisted[0]
|
|
assert entry.category == ActivityCategory.ENTITY
|
|
assert entry.action == "entity.created"
|
|
assert entry.severity == ActivitySeverity.INFO
|
|
assert entry.entity_type == "output_target"
|
|
assert entry.entity_id == "ot_test123"
|
|
|
|
def test_entity_deleted_carries_name(self):
|
|
"""DELETE: entity_name must be passed explicitly and preserved in record."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original_deps = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
|
|
fire_entity_event(
|
|
"output_target",
|
|
"deleted",
|
|
"ot_abc",
|
|
entity_name="My LED Strip",
|
|
)
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
|
|
assert len(persisted) == 1
|
|
entry = persisted[0]
|
|
assert entry.action == "entity.deleted"
|
|
assert entry.entity_name == "My LED Strip"
|
|
# Name should also appear in the human message.
|
|
assert "My LED Strip" in entry.message
|
|
|
|
def test_entity_deleted_without_name_does_not_raise(self):
|
|
"""Even if entity_name is omitted on delete, the record is created."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original_deps = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
# No entity_name passed — should not crash
|
|
fire_entity_event("device", "deleted", "dev_xyz")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0].action == "entity.deleted"
|
|
|
|
def test_entity_updated_emits_record(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original_deps = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
|
|
fire_entity_event("scene_preset", "updated", "scene_001")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0].action == "entity.updated"
|
|
|
|
def test_actor_carried_from_contextvar(self):
|
|
"""Actor is resolved from the ContextVar."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
token = current_actor.set("dev")
|
|
try:
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original_deps = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("gradient", "created", "gr_001")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
finally:
|
|
current_actor.reset(token)
|
|
|
|
assert persisted[0].actor == "dev"
|
|
|
|
def test_no_record_when_module_recorder_is_none(self):
|
|
"""If recorder not initialised, fire_entity_event must not raise."""
|
|
with patch("ledgrab.core.activity_log.recorder._recorder", None):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original_deps = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
fire_entity_event("device", "created", "dev_001") # must not raise
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
|
|
def test_entity_name_resolved_from_store_for_create(self):
|
|
"""For 'created', entity_name is resolved from the matching store."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
mock_store = MagicMock()
|
|
mock_obj = MagicMock()
|
|
mock_obj.name = "Target Alpha"
|
|
mock_store.get_target.return_value = mock_obj
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.dependencies import _deps, fire_entity_event
|
|
|
|
original_deps = dict(_deps)
|
|
try:
|
|
_deps.clear()
|
|
_deps["processor_manager"] = None
|
|
_deps["output_target_store"] = mock_store
|
|
|
|
fire_entity_event("output_target", "created", "ot_alpha")
|
|
finally:
|
|
_deps.clear()
|
|
_deps.update(original_deps)
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0].entity_name == "Target Alpha"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Category B: Authentication audit records
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAuthInstrumentation:
|
|
"""Auth failures emit warning records; no token ever recorded."""
|
|
|
|
_SECRET_TOKEN = "super-secret-token-that-must-never-appear"
|
|
|
|
def _make_mock_request(self, client_ip: str = "192.168.1.50") -> MagicMock:
|
|
req = MagicMock()
|
|
req.client = MagicMock()
|
|
req.client.host = client_ip
|
|
req.state = MagicMock()
|
|
return req
|
|
|
|
def test_missing_bearer_emits_auth_failure_warning(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = self._make_mock_request()
|
|
with pytest.raises(Exception): # HTTPException 401
|
|
asyncio.run(verify_api_key(req, None))
|
|
|
|
# At least one warning record about auth
|
|
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
|
assert len(warnings) >= 1
|
|
assert all(e.category == ActivityCategory.AUTH for e in warnings)
|
|
|
|
def test_invalid_token_emits_auth_failure_warning(self):
|
|
"""Invalid token => warning record; token itself must NOT appear."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = self._SECRET_TOKEN # the "attempted" token
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = self._make_mock_request(client_ip="127.0.0.1")
|
|
with pytest.raises(Exception): # HTTPException 401
|
|
asyncio.run(verify_api_key(req, creds))
|
|
|
|
# At least one warning-level auth record
|
|
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
|
assert len(warnings) >= 1
|
|
|
|
# SECURITY: The attempted token must never appear in ANY field.
|
|
for entry in persisted:
|
|
assert (
|
|
self._SECRET_TOKEN not in entry.message
|
|
), "Attempted token found in message field!"
|
|
for v in (entry.entity_id, entry.entity_name, entry.actor):
|
|
assert v is None or self._SECRET_TOKEN not in str(
|
|
v
|
|
), f"Attempted token found in field: {v!r}"
|
|
for meta_v in entry.metadata.values():
|
|
assert self._SECRET_TOKEN not in str(
|
|
meta_v
|
|
), f"Attempted token found in metadata: {meta_v!r}"
|
|
|
|
def test_lan_rejection_without_keys_emits_warning(self):
|
|
"""LAN request when no keys configured => warning record."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
# Override config to have no API keys
|
|
with patch("ledgrab.api.auth.get_config") as mock_cfg:
|
|
cfg = MagicMock()
|
|
cfg.auth.api_keys = {}
|
|
mock_cfg.return_value = cfg
|
|
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = self._make_mock_request(client_ip="192.168.1.100")
|
|
with pytest.raises(Exception): # HTTPException 401
|
|
asyncio.run(verify_api_key(req, None))
|
|
|
|
warnings = [e for e in persisted if e.severity == ActivitySeverity.WARNING]
|
|
assert len(warnings) >= 1
|
|
assert any("LAN" in e.message for e in warnings)
|
|
|
|
def test_auth_failure_record_has_client_ip_in_metadata(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
creds = MagicMock()
|
|
creds.credentials = self._SECRET_TOKEN
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.auth import verify_api_key
|
|
|
|
req = self._make_mock_request(client_ip="10.0.0.5")
|
|
with pytest.raises(Exception):
|
|
asyncio.run(verify_api_key(req, creds))
|
|
|
|
auth_records = [e for e in persisted if e.category == ActivityCategory.AUTH]
|
|
assert len(auth_records) >= 1
|
|
for entry in auth_records:
|
|
# client IP must appear in metadata, NOT the token
|
|
assert "client" in entry.metadata
|
|
assert self._SECRET_TOKEN not in str(entry.metadata)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Category C: Device connect/disconnect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDeviceInstrumentation:
|
|
"""Device health transitions and discovery events emit records."""
|
|
|
|
def test_device_offline_emits_warning_record(self):
|
|
"""When a device goes from online → offline, a warning record is emitted."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
# Create a minimal DeviceHealthMixin-shaped object inline
|
|
from ledgrab.core.devices.led_client import DeviceHealth
|
|
from ledgrab.core.processing.device_health import DeviceHealthMixin
|
|
|
|
class FakeManager(DeviceHealthMixin):
|
|
def __init__(self):
|
|
self._devices = {}
|
|
self._device_store = None
|
|
|
|
def fire_event(self, evt):
|
|
pass
|
|
|
|
mgr = FakeManager()
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
@dataclass
|
|
class FakeState:
|
|
device_id: str
|
|
device_url: str = "http://192.168.1.10"
|
|
device_type: str = "wled"
|
|
led_count: int = 60
|
|
health: DeviceHealth = field(default_factory=DeviceHealth)
|
|
health_task: object = None
|
|
|
|
state = FakeState(device_id="dev_001")
|
|
state.health = DeviceHealth(online=True)
|
|
mgr._devices["dev_001"] = state
|
|
|
|
# Simulate what _check_device_health does when online flips
|
|
prev_online = True
|
|
state.health = DeviceHealth(online=False, latency_ms=0.0)
|
|
|
|
if state.health.online != prev_online:
|
|
mgr.fire_event(
|
|
{
|
|
"type": "device_health_changed",
|
|
"device_id": "dev_001",
|
|
"online": state.health.online,
|
|
"latency_ms": state.health.latency_ms,
|
|
}
|
|
)
|
|
# Reproduce the audit block from device_health.py
|
|
is_online = state.health.online
|
|
device_name = None
|
|
display = device_name or "dev_001"
|
|
action = "device.online" if is_online else "device.offline"
|
|
severity = ActivitySeverity.INFO if is_online else ActivitySeverity.WARNING
|
|
status_word = "came online" if is_online else "went offline"
|
|
recorder.record(
|
|
category=ActivityCategory.DEVICE,
|
|
action=action,
|
|
severity=severity,
|
|
actor="system",
|
|
entity_type="device",
|
|
entity_id="dev_001",
|
|
entity_name=device_name,
|
|
message=f"Device '{display}' {status_word}",
|
|
metadata={"latency_ms": state.health.latency_ms},
|
|
)
|
|
|
|
offline_records = [
|
|
e
|
|
for e in persisted
|
|
if e.category == ActivityCategory.DEVICE and e.action == "device.offline"
|
|
]
|
|
assert len(offline_records) == 1
|
|
r = offline_records[0]
|
|
assert r.severity == ActivitySeverity.WARNING
|
|
assert r.entity_id == "dev_001"
|
|
|
|
def test_device_online_emits_info_record(self):
|
|
"""When a device comes online, an info record is emitted."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
recorder.record(
|
|
category=ActivityCategory.DEVICE,
|
|
action="device.online",
|
|
severity=ActivitySeverity.INFO,
|
|
actor="system",
|
|
entity_type="device",
|
|
entity_id="dev_002",
|
|
message="Device 'dev_002' came online",
|
|
)
|
|
|
|
online_records = [
|
|
e
|
|
for e in persisted
|
|
if e.category == ActivityCategory.DEVICE and e.action == "device.online"
|
|
]
|
|
assert len(online_records) == 1
|
|
assert online_records[0].severity == ActivitySeverity.INFO
|
|
|
|
def test_device_discovered_emits_record(self):
|
|
"""DiscoveryWatcher._emit produces an audit record."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
mock_device_store = MagicMock()
|
|
mock_device_store.get_all_devices.return_value = []
|
|
|
|
fired_events: list[dict] = []
|
|
watcher = DiscoveryWatcher(
|
|
device_store=mock_device_store,
|
|
fire_event=lambda evt: fired_events.append(evt),
|
|
)
|
|
|
|
entry = _DiscoveredEntry(
|
|
key="wled-test._wled._tcp.local.",
|
|
url="http://192.168.1.55",
|
|
name="WLED-Test",
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_discovered", entry)
|
|
|
|
disc_records = [
|
|
e
|
|
for e in persisted
|
|
if e.category == ActivityCategory.DEVICE and e.action == "device.discovered"
|
|
]
|
|
assert len(disc_records) == 1
|
|
r = disc_records[0]
|
|
assert r.severity == ActivitySeverity.INFO
|
|
assert r.entity_name == "WLED-Test"
|
|
assert "192.168.1.55" in r.metadata.get("url", "")
|
|
|
|
def test_device_lost_emits_warning_record(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.core.devices.discovery_watcher import DiscoveryWatcher, _DiscoveredEntry
|
|
|
|
mock_device_store = MagicMock()
|
|
mock_device_store.get_all_devices.return_value = []
|
|
|
|
watcher = DiscoveryWatcher(
|
|
device_store=mock_device_store,
|
|
fire_event=lambda evt: None,
|
|
)
|
|
entry = _DiscoveredEntry(
|
|
key="lost-device._wled._tcp.local.",
|
|
url="http://192.168.1.77",
|
|
name="Lost-WLED",
|
|
device_type="wled",
|
|
)
|
|
watcher._emit("device_lost", entry)
|
|
|
|
lost_records = [
|
|
e
|
|
for e in persisted
|
|
if e.category == ActivityCategory.DEVICE and e.action == "device.lost"
|
|
]
|
|
assert len(lost_records) == 1
|
|
assert lost_records[0].severity == ActivitySeverity.WARNING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Category D: Capture & system events
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCaptureAndSystemInstrumentation:
|
|
"""Capture start and backup-create emit records."""
|
|
|
|
def test_capture_started_record(self):
|
|
"""capture.started record is emitted with correct category."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.output_targets_control import _record_capture
|
|
|
|
_record_capture(
|
|
"capture.started",
|
|
"ot_test",
|
|
"My Test Strip",
|
|
"Capture started for target 'My Test Strip'",
|
|
)
|
|
|
|
assert len(persisted) == 1
|
|
r = persisted[0]
|
|
assert r.category == ActivityCategory.CAPTURE
|
|
assert r.action == "capture.started"
|
|
assert r.entity_id == "ot_test"
|
|
assert r.entity_name == "My Test Strip"
|
|
|
|
def test_capture_stopped_record(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.output_targets_control import _record_capture
|
|
|
|
_record_capture(
|
|
"capture.stopped",
|
|
"ot_test",
|
|
"Strip",
|
|
"Capture stopped for target 'Strip'",
|
|
)
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0].action == "capture.stopped"
|
|
|
|
def test_backup_created_record(self):
|
|
"""backup.created system record emitted on backup download."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.backup import _record_system
|
|
|
|
_record_system(
|
|
"backup.created",
|
|
"Backup downloaded: ledgrab-backup-20260101T000000.zip",
|
|
{"filename": "ledgrab-backup-20260101T000000.zip"},
|
|
)
|
|
|
|
assert len(persisted) == 1
|
|
r = persisted[0]
|
|
assert r.category == ActivityCategory.SYSTEM
|
|
assert r.action == "backup.created"
|
|
assert "backup" in r.message.lower()
|
|
|
|
def test_backup_restored_record(self):
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.backup import _record_system
|
|
|
|
_record_system("backup.restored", "Database restored from uploaded backup")
|
|
|
|
assert len(persisted) == 1
|
|
assert persisted[0].action == "backup.restored"
|
|
|
|
def test_no_token_in_any_system_record(self):
|
|
"""System records must never include token-like secrets."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
_SECRET = "my-api-token-12345" # noqa: S105
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.backup import _record_system
|
|
|
|
# Even if someone tried to pass a token (they shouldn't)
|
|
_record_system("backup.created", "Backup created")
|
|
|
|
for entry in persisted:
|
|
assert _SECRET not in entry.message
|
|
for v in entry.metadata.values():
|
|
assert _SECRET not in str(v)
|
|
|
|
def test_settings_changed_record(self):
|
|
"""shutdown_action settings change emits a system record."""
|
|
recorder, persisted, _ = _make_recorder()
|
|
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.system_settings import _record_setting
|
|
|
|
_record_setting(
|
|
"settings.changed",
|
|
"shutdown_action",
|
|
"Shutdown action set to 'nothing'",
|
|
)
|
|
|
|
assert len(persisted) == 1
|
|
r = persisted[0]
|
|
assert r.category == ActivityCategory.SYSTEM
|
|
assert r.action == "settings.changed"
|
|
assert r.metadata.get("setting_key") == "shutdown_action"
|
|
|
|
def test_settings_change_excludes_activity_log_key(self):
|
|
"""The 'activity_log' settings key must not self-referentially trigger records.
|
|
|
|
This is enforced by the caller checking the key before calling
|
|
_record_setting. Verify our helper does NOT filter automatically (the
|
|
responsibility is on the caller), but that the activity_log settings path
|
|
in the retention engine does not call record_setting.
|
|
"""
|
|
# Verify that _record_setting itself doesn't filter — that's not its job.
|
|
recorder, persisted, _ = _make_recorder()
|
|
with _patch_module_recorder(recorder):
|
|
from ledgrab.api.routes.system_settings import _record_setting
|
|
|
|
# The caller is responsible for not passing "activity_log"
|
|
# Calling it with any other key works fine:
|
|
_record_setting("settings.changed", "auto_backup", "Auto-backup enabled")
|
|
|
|
assert persisted[0].metadata["setting_key"] == "auto_backup"
|