feat: roadmap round two (2026-06-23) — per-pixel smart-lights + integrations
A) Per-pixel smart-lights - LIFX multizone (SetExtendedColorZones msg 510, <=82 zones) + Tile (SetTileState64 715), auto-detected on connect with single-colour fallback; lifx_per_zone threaded like nanoleaf_per_panel - Hue gradient-lightstrip mapping: Entertainment v2 frame now keyed by channel id (was 1 light=1 LED), channels discovered on connect; hue_gradient_mode toggle (default on) B) Integrations bundle - Outbound webhook automation action (Discord/IFTTT/Zapier/Node-RED), SSRF-gated via validate_polling_url at both save and fire time; fires on activate/deactivate, best-effort, audited - Home Assistant MQTT auto-discovery: read-only binary_sensors per automation + connectivity, availability via birth/will, cleanup on disable/delete, live state from the engine Shared: pixel_reduce.resample_to_n nearest-neighbour helper. 57 new tests (lifx_multizone, hue_segment, webhook_action, ha_discovery). Gate: ruff + tsc + build clean, pytest 2719 passed / 2 skipped.
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
"""Tests for the outbound webhook automation action.
|
||||
|
||||
Covers the pure logic (template rendering, fire_on filtering, model
|
||||
round-trip) and the fire path with a mocked transport (success, non-2xx,
|
||||
SSRF-blocked). Live delivery to a real Discord/Zapier endpoint is out of
|
||||
scope for CI and is verified manually.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ledgrab.core.automations.webhook_action import (
|
||||
fire_webhook_action,
|
||||
render_template,
|
||||
should_fire,
|
||||
)
|
||||
from ledgrab.storage.automation import Action, Automation, WebhookAction
|
||||
|
||||
|
||||
def _automation(actions=None) -> Automation:
|
||||
now = datetime.now(timezone.utc)
|
||||
return Automation(
|
||||
id="auto_1",
|
||||
name="Movie Night",
|
||||
enabled=True,
|
||||
rule_logic="or",
|
||||
rules=[],
|
||||
scene_preset_id=None,
|
||||
deactivation_mode="none",
|
||||
deactivation_scene_preset_id=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
actions=actions or [],
|
||||
)
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
def test_substitutes_all_tokens(self):
|
||||
out = render_template(
|
||||
'{"name":"{{automation_name}}","id":"{{automation_id}}","ev":"{{event}}"}',
|
||||
_automation(),
|
||||
"activate",
|
||||
)
|
||||
assert '"name":"Movie Night"' in out
|
||||
assert '"id":"auto_1"' in out
|
||||
assert '"ev":"activate"' in out
|
||||
|
||||
def test_leaves_unknown_tokens(self):
|
||||
assert render_template("{{unknown}}", _automation(), "activate") == "{{unknown}}"
|
||||
|
||||
|
||||
class TestShouldFire:
|
||||
def test_matches_event_or_both(self):
|
||||
assert should_fire(WebhookAction(fire_on="activate"), "activate")
|
||||
assert not should_fire(WebhookAction(fire_on="activate"), "deactivate")
|
||||
assert should_fire(WebhookAction(fire_on="both"), "activate")
|
||||
assert should_fire(WebhookAction(fire_on="both"), "deactivate")
|
||||
|
||||
|
||||
class TestModelRoundTrip:
|
||||
def test_webhook_action_round_trips(self):
|
||||
a = WebhookAction(
|
||||
webhook_url="https://example.com/hook",
|
||||
method="PUT",
|
||||
body_template="hi {{event}}",
|
||||
content_type="text/plain",
|
||||
fire_on="both",
|
||||
)
|
||||
back = Action.from_dict(a.to_dict())
|
||||
assert isinstance(back, WebhookAction)
|
||||
assert back.webhook_url == "https://example.com/hook"
|
||||
assert back.method == "PUT"
|
||||
assert back.fire_on == "both"
|
||||
|
||||
def test_unknown_action_type_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
Action.from_dict({"action_type": "nope"})
|
||||
|
||||
def test_from_dict_normalises_bad_method_and_fire_on(self):
|
||||
a = WebhookAction.from_dict({"method": "delete", "fire_on": "whenever"})
|
||||
assert a.method == "POST"
|
||||
assert a.fire_on == "activate"
|
||||
|
||||
def test_automation_actions_survive_serialization(self):
|
||||
auto = _automation([WebhookAction(webhook_url="https://x.test/h", fire_on="both")])
|
||||
back = Automation.from_dict(auto.to_dict())
|
||||
assert len(back.actions) == 1
|
||||
assert isinstance(back.actions[0], WebhookAction)
|
||||
assert back.actions[0].webhook_url == "https://x.test/h"
|
||||
|
||||
def test_no_actions_omitted_from_dict(self):
|
||||
assert "actions" not in _automation().to_dict()
|
||||
|
||||
|
||||
class TestFire:
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_returns_true(self):
|
||||
route = respx.post("http://93.184.216.34/hook").mock(return_value=httpx.Response(204))
|
||||
action = WebhookAction(webhook_url="http://93.184.216.34/hook", body_template="{{event}}")
|
||||
ok, err = await fire_webhook_action(action, _automation(), "activate")
|
||||
assert ok is True and err is None
|
||||
assert route.called
|
||||
# Body template was rendered and sent.
|
||||
assert route.calls.last.request.content == b"activate"
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_returns_error(self):
|
||||
respx.post("http://93.184.216.34/hook").mock(return_value=httpx.Response(500))
|
||||
action = WebhookAction(webhook_url="http://93.184.216.34/hook")
|
||||
ok, err = await fire_webhook_action(action, _automation(), "activate")
|
||||
assert ok is False and err == "HTTP 500"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssrf_blocked_loopback(self):
|
||||
# validate_polling_url must reject loopback — no HTTP call is made.
|
||||
action = WebhookAction(webhook_url="http://127.0.0.1:8080/admin")
|
||||
ok, err = await fire_webhook_action(action, _automation(), "activate")
|
||||
assert ok is False and "SSRF" in (err or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_url_returns_error(self):
|
||||
ok, err = await fire_webhook_action(
|
||||
WebhookAction(webhook_url=""), _automation(), "activate"
|
||||
)
|
||||
assert ok is False and err == "no URL configured"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user