refactor(output-targets): registry + coverage assertion for response builders

``_target_to_response`` in ``api/routes/output_targets.py`` used to be
an isinstance ladder over the three OutputTarget subclasses with a
silent fallback that fabricated a ``LedOutputTargetResponse`` for
unknown types (audit finding H3). The fallback masked exactly the
kind of bug we hit on the CSS side in Phase 1.1: a new target subclass
slipped past the ladder and got mis-shaped on the wire.

Replace the ladder with a ``_TARGET_RESPONSE_BUILDERS`` dict keyed by
the concrete subclass plus an import-time
``_assert_target_response_coverage()`` that requires the registry to
exactly match ``{WledOutputTarget, HALightOutputTarget,
Z2MLightOutputTarget}``. ``_target_to_response`` now raises
``RuntimeError`` instead of silently fabricating a LED response for an
unknown subclass — coverage is asserted at import so this branch is
unreachable in normal operation.

Tests: 5 new regression tests cover bijection between expected classes
and registered builders, callable shape, the rogue-target-raises
contract, and missing/extra entry rejection in the assertion. 24
existing output-target tests stay green; ruff clean.
This commit is contained in:
2026-05-23 00:03:01 +03:00
parent c1aa2ebec5
commit 2f15fbb752
2 changed files with 117 additions and 17 deletions
+48 -17
View File
@@ -175,26 +175,57 @@ def _validate_color_value_source(
)
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response."""
if isinstance(target, WledOutputTarget):
return _led_target_to_response(target)
elif isinstance(target, HALightOutputTarget):
return _ha_light_target_to_response(target)
elif isinstance(target, Z2MLightOutputTarget):
return _z2m_light_target_to_response(target)
else:
# Fallback for unknown types — use LED response with defaults
return LedOutputTargetResponse(
id=target.id,
name=target.name,
description=target.description,
tags=target.tags,
created_at=target.created_at,
updated_at=target.updated_at,
_TARGET_RESPONSE_BUILDERS: dict = {
WledOutputTarget: _led_target_to_response,
HALightOutputTarget: _ha_light_target_to_response,
Z2MLightOutputTarget: _z2m_light_target_to_response,
}
def _assert_target_response_coverage() -> None:
"""Verify the response registry covers every concrete OutputTarget subclass.
Runs at module import. Surfaces a missing builder eagerly instead of
letting a request fall through to the previous silent fallback (which
used to return a defaults-filled LedOutputTargetResponse and quietly
misshape the payload for unknown target types).
"""
expected = {WledOutputTarget, HALightOutputTarget, Z2MLightOutputTarget}
registered = set(_TARGET_RESPONSE_BUILDERS.keys())
missing = expected - registered
extra = registered - expected
if missing or extra:
problems = []
if missing:
problems.append(f"missing builders: {sorted(c.__name__ for c in missing)}")
if extra:
problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
raise RuntimeError(
"_TARGET_RESPONSE_BUILDERS is out of sync with the OutputTarget "
"subclass set: " + "; ".join(problems)
)
_assert_target_response_coverage()
def _target_to_response(target) -> OutputTargetResponse:
"""Convert any OutputTarget to the appropriate typed response.
Dispatches via :data:`_TARGET_RESPONSE_BUILDERS` keyed by concrete
subclass. Raises ``RuntimeError`` for an unregistered subclass —
coverage is asserted at import, so this should never fire in
practice; if it does, the storage layer added a new OutputTarget
subclass without a matching response builder here.
"""
builder = _TARGET_RESPONSE_BUILDERS.get(type(target))
if builder is None:
raise RuntimeError(
f"No response builder registered for OutputTarget subclass " f"{type(target).__name__}"
)
return builder(target)
# ===== CRUD ENDPOINTS =====