homelab-codex-ws/scripts/ha/tests/test_import_api_offline.sh
oskar 5b9111afa7 feat(ha): api adapter for import.sh
ken is HAOS (no SSH/docker exec path), so it needs a REST/WebSocket-only
adapter: automations/scripts/scenes via one GET per
/api/config/<domain>/config/<id>, dashboards/area+entity registries/
input_* helpers via the HA WebSocket API (read-only commands only).

scripts/ha/lib/ha_api.py and ha_ws.py never take a token as a value —
only a token_path, read from disk in-process — so the bearer token never
touches a subprocess argv or a log line. ha_ws.py depends on the optional
websocket-client package and raises a clear, actionable ImportError if
it's missing rather than a raw traceback; import.sh still completes the
REST-only part of the import in that case.

Automations/scripts/scenes reuse split.write_split() so both adapters
produce byte-identical config/<instance>/ layouts and the same
idempotent stale-file cleanup on re-run.

docker-exec adapter logic is untouched.
2026-07-22 18:07:26 +02:00

218 lines
8.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# Offline tests for the "api" adapter (scripts/ha/lib/import_api.py, ha_api.py,
# ha_ws.py): normalization of real-shaped HA API responses saved as fixtures,
# idempotency, stale-file cleanup, and the "websocket-client missing" error
# path. No network access, no HA instance needed — REST calls are replaced
# with a fake in-memory client, and the WebSocket client is replaced via
# sys.modules injection (see PYEOF below).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HA_LIB_DIR="$SCRIPT_DIR/../lib"
FIXTURES_DIR="$SCRIPT_DIR/fixtures"
python3 - "$HA_LIB_DIR" "$FIXTURES_DIR" <<'PYEOF'
import json
import os
import sys
import tempfile
lib_dir, fixtures_dir = sys.argv[1:3]
sys.path.insert(0, lib_dir)
import import_api # noqa: E402
from normalize import parse_yaml_text # noqa: E402
fail = False
def check(condition, message):
global fail
if condition:
print(f"PASS: {message}")
else:
print(f"FAIL: {message}", file=sys.stderr)
fail = True
def load_fixture(name):
with open(os.path.join(fixtures_dir, name), encoding="utf-8") as f:
return json.load(f)
class FakeClient:
"""Stands in for ha_api.Client: canned responses keyed by exact path."""
def __init__(self, responses):
self._responses = responses
def get(self, path):
return self._responses.get(path)
# --- automations / scripts / scenes: REST-shaped fixtures, no network ---
states = load_fixture("api_states.json")
responses = {
"/api/config/automation/config/111": load_fixture("api_automation_config_111.json"),
"/api/config/script/config/demo_script": load_fixture("api_script_config_demo_script.json"),
"/api/config/scene/config/222": load_fixture("api_scene_config_222.json"),
}
client = FakeClient(responses)
with tempfile.TemporaryDirectory() as config_out_dir:
report = import_api.new_report()
import_api.import_automations(client, states, config_out_dir, report)
import_api.import_scripts(client, states, config_out_dir, report)
import_api.import_scenes(client, states, config_out_dir, report)
check(report["automations_imported"] == 1, "one automation imported (the one with attributes.id)")
check(
report["automations_skipped_yaml"] == ["automation.legacy_yaml_automation"],
"id-less automation entity reported as yaml-skipped, not silently dropped",
)
check(report["scripts_imported"] == 1, "one script imported")
check(report["scenes_imported"] == 1, "one scene imported")
auto_path = os.path.join(config_out_dir, "automations", "111.yaml")
script_path = os.path.join(config_out_dir, "scripts", "demo_script.yaml")
scene_path = os.path.join(config_out_dir, "scenes", "222.yaml")
check(os.path.isfile(auto_path), "automations/111.yaml written")
check(os.path.isfile(script_path), "scripts/demo_script.yaml written")
check(os.path.isfile(scene_path), "scenes/222.yaml written")
with open(auto_path, encoding="utf-8") as f:
auto_text = f.read()
check("alias: Morning lights" in auto_text, "automation config normalized to canonical (sorted-key) YAML")
parsed_back = parse_yaml_text(auto_text)
check(
parsed_back == load_fixture("api_automation_config_111.json"),
"normalized automation YAML round-trips to the original API response",
)
# stale-file cleanup: pre-seed an id that no longer exists, re-run, must be gone
stale_path = os.path.join(config_out_dir, "automations", "999.yaml")
with open(stale_path, "w", encoding="utf-8") as f:
f.write("alias: stale\n")
report2 = import_api.new_report()
import_api.import_automations(client, states, config_out_dir, report2)
check(not os.path.isfile(stale_path), "stale automation export removed on re-run (idempotent cleanup)")
# idempotency: second run over the same fixture data is byte-identical
with open(auto_path, encoding="utf-8") as f:
pass1 = f.read()
import_api.import_automations(client, states, config_out_dir, report2)
with open(auto_path, encoding="utf-8") as f:
pass2 = f.read()
check(pass1 == pass2, "automations/111.yaml byte-identical across two runs (idempotent)")
# --- dashboards / registries / helpers: WebSocket-shaped fixtures ---
FAKE_DASHBOARD_LIST = [
{"id": "map", "title": "Map", "url_path": "map", "mode": "storage"},
{"id": "kiosk", "title": "Kiosk", "url_path": "kiosk", "mode": "yaml"},
]
FAKE_WS_RESULTS = {
("lovelace/config", ()): {"title": "Overview", "views": []},
("lovelace/config", (("url_path", "map"),)): {"title": "Map", "views": []},
("lovelace/dashboards/list", ()): FAKE_DASHBOARD_LIST,
("lovelace/resources", ()): [{"id": "r1", "type": "module", "url": "/local/x.js"}],
("config/area_registry/list", ()): [{"id": "hall", "name": "Hall"}],
("config/entity_registry/list", ()): [{"entity_id": "light.living_room"}],
("input_boolean/list", ()): [{"id": "on_leave", "name": "On leave"}],
}
class FakeHaWsError(RuntimeError):
pass
class FakeHaWsClient:
def __init__(self, base_url, token):
self.base_url = base_url
self.token = token
def __enter__(self):
return self
def __exit__(self, *exc_info):
return False
def command(self, command_type, **kwargs):
key = (command_type, tuple(sorted(kwargs.items())))
if key not in FAKE_WS_RESULTS:
raise FakeHaWsError(f"unexpected command in test double: {key!r}")
return FAKE_WS_RESULTS[key]
fake_ha_ws = type(sys)("ha_ws")
fake_ha_ws.HaWsClient = FakeHaWsClient
fake_ha_ws.HaWsError = FakeHaWsError
with tempfile.TemporaryDirectory() as storage_out_dir:
sys.modules["ha_ws"] = fake_ha_ws
report = import_api.new_report()
import_api.import_dashboards_and_registries(
"http://ha.example", "test-token", states, storage_out_dir, report
)
del sys.modules["ha_ws"]
check(report["websocket_error"] is None, "no websocket error with the fake client installed")
check(report["dashboards_imported"] == 2, "default dashboard + one storage-mode dashboard imported")
check(
report["dashboards_skipped"] == [("kiosk", "mode='yaml' is file-based, not reachable via api")],
"yaml-mode dashboard reported as skipped, not silently dropped",
)
check(report["helpers_imported"] == {"input_boolean": 1}, "only the present input_* domain was queried")
expected_files = {
"lovelace.yaml",
"lovelace.map.yaml",
"lovelace_dashboards.yaml",
"lovelace_resources.yaml",
"core.area_registry.yaml",
"core.entity_registry.yaml",
"input_boolean.yaml",
}
actual_files = set(os.listdir(storage_out_dir))
check(actual_files == expected_files, f"expected storage-export files present, got {sorted(actual_files)}")
# stale-file cleanup: a leftover domain export must be removed on re-run
stale_path = os.path.join(storage_out_dir, "input_number.yaml")
with open(stale_path, "w", encoding="utf-8") as f:
f.write("key: input_number\n")
sys.modules["ha_ws"] = fake_ha_ws
report2 = import_api.new_report()
import_api.import_dashboards_and_registries(
"http://ha.example", "test-token", states, storage_out_dir, report2
)
del sys.modules["ha_ws"]
check(not os.path.isfile(stale_path), "stale storage-export file removed on re-run (idempotent cleanup)")
# --- missing dependency path: no traceback, clear actionable message ---
with tempfile.TemporaryDirectory() as storage_out_dir:
# Force the *real* ha_ws.py to execute (not a fake) and hit its own
# `import websocket as _ws` line, so we exercise the actual actionable
# ImportError message it raises rather than Python's generic one.
sys.modules["websocket"] = None
sys.modules.pop("ha_ws", None)
report = import_api.new_report()
import_api.import_dashboards_and_registries(
"http://ha.example", "test-token", states, storage_out_dir, report
)
sys.modules.pop("ha_ws", None)
del sys.modules["websocket"]
check(report["websocket_error"] is not None, "missing websocket-client dependency is reported, not raised")
check(
"websocket-client" in (report["websocket_error"] or ""),
"the reported error names the missing package so an operator knows what to install",
)
check(os.listdir(storage_out_dir) == [], "no storage-export files written when the dependency is missing")
sys.exit(1 if fail else 0)
PYEOF