Files
app/backend/tests/flow/test_device_node.py
T
Melvin StroblandClaude Opus 5.5 1e920c5bad
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m29s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m21s
pre-commit / pre-commit (push) Failing after 2m53s
Test Backend / test-backend (3.12) (push) Successful in 4m31s
Test Backend / test-backend (3.14) (push) Successful in 4m19s
Compose Smoke Test / test-compose (push) Successful in 36s
Playwright Tests / merge-reports (push) Successful in 1m35s
Restart safety: transient credential checks are 1013/503, not a refusal
- A device credential that cannot be checked now (device store not running,
  nonces unreadable) raises DeviceUnavailable: 503 over HTTP, and the
  /flows/ws handshake and the tunnel stream accept and close with 1013.
  1008 (a 403 before accepting) stays for a revoked or invalid one
- Device files are written through a temporary file, so a read mid-save no
  longer looks like a deleted device
- The first report turns Touch friendly on only for a device paired since
  pairings are noted (DeviceState.paired_at), so devices paired before keep
  the switch they were given
- NodeDef reads `device`/`device_policy` from older clients as the worker
  label, for a release
- Tests: transient vs revoked socket, the touch default, the old worker keys,
  and a restart with a device node and a node_health rule sending no push

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
2026-10-16 18:18:42 +02:00

168 lines
5.9 KiB
Python

"""A device node: never run by the engine, degraded while its device is away."""
import asyncio
from pathlib import Path
from fluksio.flow.alerts import Alert, AlertManager, AlertsConfig, Channel, Rule
from fluksio.flow.controller import HEALTH_FLOOR, FlowController
from fluksio.flow.devices import presence
from fluksio.flow.events import EventBus
from fluksio.flow.messages import DType, MessageSpec
from fluksio.flow.schemas import FlowDef, FlowInput, NodeDef
from fluksio.flow.store import FlowStore
def _controller(tmp_path: Path) -> FlowController:
store = FlowStore(tmp_path / "flows")
store.write_flow(
FlowDef(
name="hall",
inputs=[
FlowInput(spec=MessageSpec(name="level", dtype=DType.INT), initial=50)
],
nodes=[
NodeDef(
id="panel",
type="device",
params={"device": "wall"},
provides=[
MessageSpec(name="bell", port="gpio.4", dtype=DType.BOOL)
],
requires=[
MessageSpec(
name="level",
port="backlight",
dtype=DType.INT,
trigger=False,
)
],
),
NodeDef(
id="ring",
type="change",
requires=[MessageSpec(name="bell", dtype=DType.BOOL)],
provides=[MessageSpec(name="rung", dtype=DType.BOOL)],
),
],
)
)
return FlowController(store)
def test_the_engine_never_runs_a_device_node(tmp_path: Path) -> None:
controller = _controller(tmp_path)
asyncio.run(controller.reload())
assert controller.issues == []
node = controller.get_node("hall.panel")
assert node is not None and node.passive
ran: list[object] = []
node.f = lambda **kwargs: ran.append(kwargs) # type: ignore[method-assign]
# The message its out port follows changes, and a manual run of the flow.
controller.publish_message("hall.level", 80)
controller.run_flow("hall")
assert ran == []
# What the device publishes on its in port runs what reads it.
controller.publish_message("hall.bell", True)
assert controller.values("hall")["hall.rung"]["value"] is True
asyncio.run(controller._teardown())
def test_a_device_node_is_degraded_while_its_device_is_away(tmp_path: Path) -> None:
controller = _controller(tmp_path)
async def scenario() -> list[tuple[str, str | None]]:
await controller.reload()
entry = controller.loaded["hall.panel"]
seen = [(entry.health, entry.health_detail)]
presence.opened("wall")
seen.append((entry.health, entry.health_detail))
presence.closed("wall")
seen.append((entry.health, entry.health_detail))
await controller._teardown()
# Stopped, it no longer follows the device.
presence.opened("wall")
seen.append((entry.health, entry.health_detail))
presence.closed("wall")
return seen
# Degraded, not down: a switched-off panel is a warning, never a fault.
assert asyncio.run(scenario()) == [
("degraded", "Offline"),
("ok", None),
("degraded", "Offline"),
("degraded", "Offline"),
]
presence.forget("wall")
def test_an_offline_device_is_a_warning_not_a_fault(tmp_path: Path) -> None:
controller = _controller(tmp_path)
async def scenario() -> list:
await controller.reload()
# Past the floor that holds back a device rebooting.
controller.loaded["hall.panel"].health_since -= 3600
found = (
controller.unhealthy_nodes(),
[e.id for e in controller.unhealthy_nodes(health="degraded")],
[(i.code, i.advisory) for i in controller.flow_issues("hall")],
)
await controller._teardown()
return list(found)
assert asyncio.run(scenario()) == [
[],
["hall.panel"],
[("node_degraded", True)],
]
def test_a_device_away_after_a_restart_alerts_nobody(tmp_path: Path) -> None:
"""No push, and degraded on screen only once the floor has passed.
A restart starts every device node without its device, which comes back
within seconds, and a rule on every node's health is what sends phones a
push.
"""
controller = _controller(tmp_path)
events: list[dict] = []
original = controller._publish
controller._publish = lambda event: (events.append(event), original(event)) # type: ignore[method-assign]
sent: list[Alert] = []
alerts = AlertManager(
EventBus(),
AlertsConfig(
channels=[Channel(name="phone", kind="ntfy", config={"topic": "t"})],
rules=[Rule(events=["node_health"], channels=["phone"])],
),
)
async def capture(channel: Channel, alert: Alert) -> None:
sent.append(alert)
alerts.send = capture # type: ignore[method-assign]
async def scenario() -> list[list[str]]:
await controller.reload() # the restart: the device is not back yet
surfaced = [[e.id for e in controller.unhealthy_nodes(health="degraded")]]
presence.opened("wall")
presence.closed("wall") # and it drops again
surfaced.append([e.id for e in controller.unhealthy_nodes(health="degraded")])
controller.loaded["hall.panel"].health_since -= HEALTH_FLOOR
surfaced.append([e.id for e in controller.unhealthy_nodes(health="degraded")])
for event in events:
await alerts.handle(event)
await controller._teardown()
return surfaced
assert asyncio.run(scenario()) == [[], [], ["hall.panel"]]
assert [e["health"] for e in events if e["type"] == "node_health"] == [
"degraded",
"ok",
"degraded",
]
assert sent == []
presence.forget("wall")