From 8d249ccd8d82f2565099c8875af82ff884b669e5 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 07:28:57 -0700 Subject: [PATCH 01/17] Add a Django test harness and initial REST API tests Adds web/tests/ with a shared NSPMTestCase harness and 97 tests covering rooms, entity create/edit (lights, switches, buttons, thermostats, scenes) and entity/page layout, exercised through the same REST endpoints and payloads the React UI uses. The harness: - runs each test against an isolated in-memory database - replaces the MQTTManager process-control hooks with a recorder, so tests can assert the manager was told to reload and can never signal a real nspm_mqttmanager process on the host - fakes Home Assistant (at the HTTP layer) and OpenHAB, and fails any other network request - provides assertLoadableByManager(), which checks saved rows have the shape MQTTManager parses when it reads the database Known bugs and validation gaps are pinned as @expectedFailure tests with a KNOWN BUG / KNOWN GAP comment, so the suite passes today and flags when a fix lands (remove the decorator then). settings.py now reads the data directory from NSPM_DATA_DIR (default /data, unchanged in the container) so the app can start outside Docker. Run with docker/web/run_tests.sh; CI runs it on PRs touching docker/web. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/django-tests.yml | 32 ++ .../nspanelmanager/nspanelmanager/settings.py | 7 +- .../nspanelmanager/test_settings.py | 24 ++ docker/web/nspanelmanager/web/tests.py | 3 - .../web/nspanelmanager/web/tests/__init__.py | 0 .../web/nspanelmanager/web/tests/harness.py | 231 ++++++++++++ .../nspanelmanager/web/tests/test_entities.py | 336 ++++++++++++++++++ .../nspanelmanager/web/tests/test_layout.py | 285 +++++++++++++++ .../nspanelmanager/web/tests/test_rooms.py | 174 +++++++++ docker/web/run_tests.sh | 6 + 10 files changed, 1093 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/django-tests.yml create mode 100644 docker/web/nspanelmanager/nspanelmanager/test_settings.py delete mode 100755 docker/web/nspanelmanager/web/tests.py create mode 100644 docker/web/nspanelmanager/web/tests/__init__.py create mode 100644 docker/web/nspanelmanager/web/tests/harness.py create mode 100644 docker/web/nspanelmanager/web/tests/test_entities.py create mode 100644 docker/web/nspanelmanager/web/tests/test_layout.py create mode 100644 docker/web/nspanelmanager/web/tests/test_rooms.py create mode 100755 docker/web/run_tests.sh diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml new file mode 100644 index 00000000..6dda8480 --- /dev/null +++ b/.github/workflows/django-tests.yml @@ -0,0 +1,32 @@ +name: "Django tests" + +on: + pull_request: + paths: + - "docker/web/**" + - ".github/workflows/django-tests.yml" + push: + branches: + - "main" + - "devel" + paths: + - "docker/web/**" + - ".github/workflows/django-tests.yml" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" # Matches the python:3.12 base image in docker/Dockerfile + cache: pip + cache-dependency-path: docker/web/requirements.txt + + - name: Install dependencies + run: pip install -r docker/web/requirements.txt + + - name: Run tests + run: docker/web/run_tests.sh -v 2 diff --git a/docker/web/nspanelmanager/nspanelmanager/settings.py b/docker/web/nspanelmanager/nspanelmanager/settings.py index 2044f3ec..cdf2c96b 100755 --- a/docker/web/nspanelmanager/nspanelmanager/settings.py +++ b/docker/web/nspanelmanager/nspanelmanager/settings.py @@ -28,9 +28,12 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ +# Persistent data (secret key, database). Overridable so that tests can run outside the container. +DATA_DIR = environment("NSPM_DATA_DIR", default="/data") + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "" -SECRET_KEY_PATH = "/data/secret.key" +SECRET_KEY_PATH = os.path.join(DATA_DIR, "secret.key") if os.path.exists(SECRET_KEY_PATH): with open(SECRET_KEY_PATH) as f: SECRET_KEY = f.read() @@ -107,7 +110,7 @@ # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases -DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "/data/nspanelmanager_db.sqlite3", "timeout": 20}} +DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.path.join(DATA_DIR, "nspanelmanager_db.sqlite3"), "timeout": 20}} LOGGING = { "version": 1, diff --git a/docker/web/nspanelmanager/nspanelmanager/test_settings.py b/docker/web/nspanelmanager/nspanelmanager/test_settings.py new file mode 100644 index 00000000..64377289 --- /dev/null +++ b/docker/web/nspanelmanager/nspanelmanager/test_settings.py @@ -0,0 +1,24 @@ +""" +Settings used when running the Django test suite (see docker/web/run_tests.sh). + +Identical to the production settings except that persistent data (the secret key) +is written to a throw-away directory instead of /data. The Django test runner +automatically uses an in-memory SQLite database, so the real database is never touched. +""" + +import os +import tempfile + +os.environ.setdefault("NSPM_DATA_DIR", tempfile.mkdtemp(prefix="nspm-test-")) + +from .settings import * + +MEDIA_ROOT = os.path.join(DATA_DIR, "media") + +# Hashing speed is irrelevant for tests. +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +# Many views log full tracebacks when rejecting bad input, which the tests do on purpose. +# Set NSPM_TEST_LOG_LEVEL=INFO (or DEBUG) to see them when debugging a failing test. +LOGGING["root"]["level"] = os.environ.get("NSPM_TEST_LOG_LEVEL", "CRITICAL") +LOGGING["loggers"] = {"django.request": {"level": os.environ.get("NSPM_TEST_LOG_LEVEL", "CRITICAL")}} diff --git a/docker/web/nspanelmanager/web/tests.py b/docker/web/nspanelmanager/web/tests.py deleted file mode 100755 index 7ce503c2..00000000 --- a/docker/web/nspanelmanager/web/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/docker/web/nspanelmanager/web/tests/__init__.py b/docker/web/nspanelmanager/web/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docker/web/nspanelmanager/web/tests/harness.py b/docker/web/nspanelmanager/web/tests/harness.py new file mode 100644 index 00000000..c31f3f88 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/harness.py @@ -0,0 +1,231 @@ +""" +Shared test harness for the NSPanelManager Django app. + +Every test should inherit from NSPMTestCase. It gives you: + + * An isolated in-memory database per test (via django.test.TestCase). + * self.mqttmanager -- a stand-in for the MQTTManager process. Views that change + config call send_mqttmanager_reload_command(); here that is recorded instead of + SIGUSR1-ing whatever nspm_mqttmanager happens to be running on the host. Use + self.assertManagerReloaded() / self.assertManagerNotReloaded() to verify the + manager would have been told to pick up the change. + * self.home_assistant -- a fake Home Assistant /api/states endpoint. Add states with + self.home_assistant.add("light.kitchen", friendly_name="Kitchen"). + self.openhab_items -- the item list returned by the (faked) OpenHAB API. + * assertLoadableByManager(obj) -- checks a saved Entity/Scene has the shape MQTTManager + parses when it reads the database, e.g. entity_data["controller"] is a known string. + * self.client -- a Django test client that returns 500 responses instead of raising, + so tests can assert that bad input is rejected the same way a browser would see it. + * Builders for common fixtures (make_room, make_light, make_scene, ...) and put_json / + post_json helpers that send requests the way the React UI does. +""" + +import json +import sys +from typing import ClassVar +from unittest import mock + +from django.test import Client, TestCase + +import web.apps +from web.models import Entity, NSPanel, Room, RoomEntitiesPage, Scene +from web.settings_helper import get_setting_with_default, set_setting_value + +# MQTTManager process-control helpers in web.apps. Modules import these by name +# (`from .apps import x`), which binds a separate reference, so every web.* module holding +# one is patched -- found at runtime so that adding or removing an import needs no change here. +_MANAGER_HOOKS = ("send_mqttmanager_reload_command", "restart_mqtt_manager_process", "start_mqtt_manager") + + +def _modules_holding(name): + original = getattr(web.apps, name) + return [module_name for module_name, module in list(sys.modules.items()) if (module_name == "web" or module_name.startswith("web.")) and getattr(module, name, None) is original] + + +class FakeMQTTManager: + """Records the process-control calls the web app makes towards MQTTManager.""" + + def __init__(self): + self.reloads = 0 + self.restarts = 0 + + def _reload(self): + self.reloads += 1 + + def _restart(self): + self.restarts += 1 + + def reset(self): + self.reloads = 0 + self.restarts = 0 + + +class FakeHomeAssistant: + """Serves a canned /api/states response to web.home_assistant_api.""" + + def __init__(self): + self.states = [] + self.fail = False + + def add(self, entity_id, friendly_name=None, state="off", **attributes): + if friendly_name is not None: + attributes["friendly_name"] = friendly_name + self.states.append({"entity_id": entity_id, "state": state, "attributes": attributes}) + + def _get(self, url, *args, **kwargs): + response = mock.Mock() + if self.fail or not url.endswith("/api/states"): + response.status_code = 500 + response.json.return_value = {} + else: + response.status_code = 200 + response.json.return_value = self.states + return response + + +class NSPMTestCase(TestCase): + def setUp(self): + super().setUp() + self.client = Client(raise_request_exception=False) + + # Data migrations seed a "Dummy room" (and pages for it). Start every test from an + # empty configuration so tests only see what they create themselves. + Room.objects.all().delete() + RoomEntitiesPage.objects.all().delete() + + self.mqttmanager = FakeMQTTManager() + hooks = { + "send_mqttmanager_reload_command": self.mqttmanager._reload, + "restart_mqtt_manager_process": self.mqttmanager._restart, + "start_mqtt_manager": self.mqttmanager._restart, + } + for name in _MANAGER_HOOKS: + for module in _modules_holding(name): + self._patch(f"{module}.{name}", side_effect=hooks[name]) + # Safety net: even if a new code path finds another way to reach these, never + # let a test signal or spawn a real MQTTManager on the host. + self._patch("web.apps.psutil.process_iter", return_value=[]) + self._patch("web.apps.subprocess.Popen", side_effect=AssertionError("Tests must not start MQTTManager")) + + self.home_assistant = FakeHomeAssistant() + set_setting_value("home_assistant_address", "http://home-assistant.test") + set_setting_value("home_assistant_token", "test-token") + + # OpenHAB is faked one level higher, at its item-listing functions. + self.openhab_items = [] + self._patch("web.openhab_api.get_all_openhab_items", side_effect=lambda *args, **kwargs: {"items": self.openhab_items, "errors": []}) + self._patch("web.openhab_api.get_all_openhab_scenes", side_effect=lambda *args, **kwargs: {"items": self.openhab_items, "errors": []}) + + # Every module shares the one `requests` module, so route all HTTP through here: + # Home Assistant goes to the fake, anything else fails the test. + self._patch("requests.get", side_effect=self._fake_http_get) + + def _fake_http_get(self, url, *args, **kwargs): + if url.startswith(get_setting_with_default("home_assistant_address")): + return self.home_assistant._get(url, *args, **kwargs) + raise AssertionError(f"Unexpected network request from test: GET {url}") + + def _patch(self, target, **kwargs): + patcher = mock.patch(target, **kwargs) + patcher.start() + self.addCleanup(patcher.stop) + + # ---------------------------------------------------------------- assertions + + def assertManagerReloaded(self, times=None): + if times is None: + self.assertGreater(self.mqttmanager.reloads, 0, "Expected MQTTManager to be told to reload its config") + else: + self.assertEqual(self.mqttmanager.reloads, times, f"Expected MQTTManager to be told to reload {times} time(s)") + + def assertManagerNotReloaded(self): + self.assertEqual(self.mqttmanager.reloads, 0, "MQTTManager was told to reload although nothing should have changed") + + # MQTTManager reads entities straight from the SQLite database. These are the values its + # entity classes (docker/MQTTManager/include/{light,switch,button,thermostat}/) accept for + # entity_data["controller"]; anything else is logged as an error, and a non-string throws. + MANAGER_CONTROLLERS: ClassVar = { + Entity.EntityType.LIGHT: {"home_assistant", "openhab"}, + Entity.EntityType.SWITCH: {"home_assistant", "openhab"}, + Entity.EntityType.BUTTON: {"home_assistant", "nspm"}, + Entity.EntityType.THERMOSTAT: {"home_assistant", "openhab"}, + } + MANAGER_SCENE_TYPES: ClassVar = {"home_assistant", "openhab", "nspm_scene"} + + def assertLoadableByManager(self, obj): + """Check a saved Entity/Scene row has the shape MQTTManager expects to parse.""" + obj.refresh_from_db() + if isinstance(obj, Scene): + self.assertIn(obj.scene_type, self.MANAGER_SCENE_TYPES) + if obj.scene_type != "nspm_scene": + self.assertTrue(obj.backend_name, "Home Assistant/OpenHAB scenes need a backend_name to activate") + else: + controller = obj.entity_data.get("controller") + self.assertIsInstance(controller, str, f"entity_data['controller'] must be a string, got {controller!r}") + self.assertIn(controller, self.MANAGER_CONTROLLERS[obj.entity_type]) + self.assertIsNotNone(obj.entities_page_id) + self.assertLess(obj.room_view_position, obj.entities_page.page_type, "Entity is placed in a slot outside its page") + + def assertRejected(self, response): + """The request was refused, however the view chose to signal it.""" + if response.status_code >= 400: + return + if response.get("Content-Type", "").startswith("application/json"): + self.assertEqual(response.json().get("status"), "error", f"Expected an error response, got: {response.content!r}") + return + self.fail(f"Expected request to be rejected, got HTTP {response.status_code}") + + # ---------------------------------------------------------------- builders + + def make_room(self, name="Room"): + """Create a room the way the UI does: with one entities page and one scenes page.""" + room = Room.objects.create(friendly_name=name) + RoomEntitiesPage.objects.create(room=room, page_type=4, is_scenes_page=True, display_order=0) + RoomEntitiesPage.objects.create(room=room, page_type=4, is_scenes_page=False, display_order=0) + return room + + def entities_page(self, room, index=0): + return RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False).order_by("display_order")[index] + + def scenes_page(self, room, index=0): + return RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True).order_by("display_order")[index] + + def global_scenes_page(self): + page, _ = RoomEntitiesPage.objects.get_or_create(room=None, is_scenes_page=True, defaults={"page_type": 4, "display_order": 0}) + return page + + def make_entity(self, room, entity_type=Entity.EntityType.LIGHT, name="Entity", page=None, slot=0, **entity_data): + entity_data.setdefault("controller", "home_assistant") + return Entity.objects.create( + room=room, + friendly_name=name, + entity_type=entity_type, + entities_page=page or self.entities_page(room), + room_view_position=slot, + entity_data=entity_data, + ) + + def make_light(self, room, name="Light", **kwargs): + return self.make_entity(room, Entity.EntityType.LIGHT, name, **kwargs) + + def make_scene(self, room, name="Scene", page=None, slot=0, scene_type="home_assistant", backend_name="scene.test"): + return Scene.objects.create( + room=room, + friendly_name=name, + scene_type=scene_type, + backend_name=backend_name, + entities_page=page or (self.scenes_page(room) if room else self.global_scenes_page()), + room_view_position=slot, + ) + + def make_panel(self, room, name="Panel", mac="AA:BB:CC:DD:EE:FF", **fields): + return NSPanel.objects.create(room=room, friendly_name=name, mac_address=mac, model=NSPanel.NSPanelModel.SONOFF, **fields) + + # ---------------------------------------------------------------- REST helpers + + def put_json(self, url, data): + """Send JSON the way the React UI does (fetch with a JSON body).""" + return self.client.put(url, json.dumps(data) if not isinstance(data, str) else data, content_type="application/json") + + def post_json(self, url, data): + return self.client.post(url, json.dumps(data) if not isinstance(data, str) else data, content_type="application/json") diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py new file mode 100644 index 00000000..ec0ece24 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -0,0 +1,336 @@ +""" +Creating and editing entities through the /rest/entities/* endpoints, using the same +payloads the React "add/edit entity" modal sends. +""" + +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import Entity, Scene + +from .harness import NSPMTestCase + + +def placement(page, slot): + return {"room_id": page.room_id, "entities_page_id": page.id, "room_view_position": slot} + + +class EntityRESTTestCase(NSPMTestCase): + """Base for the per-type entity endpoints. Subclasses define url and payload().""" + + url = None + model = Entity + + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.page = self.entities_page(self.room) + + def payload(self, slot=0, **overrides): + raise NotImplementedError + + def fields(self, payload): + """The dict holding the entity fields (lights wrap them in "values").""" + return payload + + def save(self, payload): + return self.put_json(reverse(self.url), payload) + + def assertCreatesNothing(self, response): + self.assertRejected(response) + self.assertFalse(self.model.objects.exists()) + self.assertManagerNotReloaded() + + +class EntityRESTCommonTests: + """Checks shared by every entity type. A mixin so it is not collected and run on its own.""" + + def test_every_required_field_is_enforced(self): + for field in list(self.fields(self.payload())): + with self.subTest(missing=field): + payload = self.payload() + del self.fields(payload)[field] + response = self.save(payload) + self.assertEqual(response.status_code, 400) + self.assertIn(field, response.json()["message"]) + self.assertFalse(self.model.objects.exists()) + self.assertManagerNotReloaded() + + def test_unknown_room_or_page_is_rejected(self): + for field in ("room_id", "entities_page_id"): + with self.subTest(field=field): + self.assertRejected(self.save(self.payload(**{field: 999}))) + self.assertFalse(self.model.objects.exists()) + self.assertManagerNotReloaded() + + def test_malformed_body_is_rejected(self): + self.assertCreatesNothing(self.put_json(reverse(self.url), "not json")) + + def test_edit_updates_in_place(self): + self.save(self.payload(slot=1)) + obj = self.model.objects.get() + self.mqttmanager.reset() + + response = self.save(self.payload(slot=1, id=obj.id, friendly_name="Renamed")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.model.objects.count(), 1) + obj.refresh_from_db() + self.assertEqual(obj.friendly_name, "Renamed") + self.assertEqual(obj.room_view_position, 1) + self.assertManagerReloaded(times=1) + + def test_edit_unknown_id_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(id=999))) + + @expectedFailure + def test_add_to_occupied_slot_is_rejected(self): + # KNOWN GAP: nothing checks the slot is free, so two entities end up in one slot. + self.save(self.payload(slot=0)) + + self.assertRejected(self.save(self.payload(slot=0))) + self.assertEqual(self.model.objects.count(), 1) + + @expectedFailure + def test_add_to_slot_outside_page_is_rejected(self): + # KNOWN GAP: room_view_position is not checked against the page size (4 here). + self.assertCreatesNothing(self.save(self.payload(slot=7))) + + +class LightTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_lights" + + def payload(self, slot=0, **overrides): + values = { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "light", + "friendly_name": "Ceiling", + "can_color_temperature": True, + "can_dim": True, + "can_rgb": False, + "controlled_by_nspanel_main_page": True, + "home_assistant_name": "light.kitchen_ceiling", + "is_ceiling_light": True, + "openhab_item_color_temp": "", + "openhab_item_dimmer": "", + "openhab_item_rgb": "", + } + return {"values": values | overrides} + + def fields(self, payload): + return payload["values"] + + def test_add_home_assistant_light(self): + response = self.save(self.payload(slot=2)) + + self.assertEqual(response.status_code, 200) + light = Entity.objects.get() + self.assertEqual(light.entity_type, Entity.EntityType.LIGHT) + self.assertEqual(light.friendly_name, "Ceiling") + self.assertEqual((light.room, light.entities_page, light.room_view_position), (self.room, self.page, 2)) + self.assertEqual(light.entity_data["home_assistant_name"], "light.kitchen_ceiling") + self.assertTrue(light.entity_data["can_dim"]) + self.assertTrue(light.entity_data["can_color_temperature"]) + self.assertFalse(light.entity_data["can_rgb"]) + self.assertTrue(light.entity_data["is_ceiling_light"]) + self.assertLoadableByManager(light) + self.assertManagerReloaded(times=1) + + def test_add_openhab_light(self): + payload = self.payload(controller="openhab", home_assistant_name="", can_dim=False, openhab_item_dimmer="Lamp_Switch") + + self.assertEqual(self.save(payload).status_code, 200) + + light = Entity.objects.get() + self.assertEqual(light.entity_data["openhab_control_mode"], "switch") + self.assertEqual(light.entity_data["openhab_item_dimmer"], "Lamp_Switch") + self.assertLoadableByManager(light) + + def test_boolean_flags_accept_strings(self): + self.save(self.payload(can_dim="false", can_rgb="true", is_ceiling_light="False")) + + data = Entity.objects.get().entity_data + self.assertEqual((data["can_dim"], data["can_rgb"], data["is_ceiling_light"]), (False, True, False)) + + def test_other_methods_are_rejected(self): + self.assertRejected(self.client.get(reverse(self.url))) + self.assertRejected(self.client.post(reverse(self.url), {})) + self.assertFalse(Entity.objects.exists()) + + @expectedFailure + def test_unknown_controller_is_rejected(self): + # KNOWN GAP: controller is stored as given. MQTTManager logs an error and silently + # treats an unknown controller as Home Assistant. + self.assertCreatesNothing(self.save(self.payload(controller="zigbee"))) + + @expectedFailure + def test_page_from_another_room_is_rejected(self): + # KNOWN GAP: room_id and entities_page_id are not checked against each other, so an + # entity can belong to one room but be displayed on another room's page. + other_page = self.entities_page(self.make_room("Lounge")) + + self.assertCreatesNothing(self.save(self.payload(entities_page_id=other_page.id))) + + +class SwitchTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_switches" + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "switch", + "friendly_name": "Fan", + "home_assistant_name": "switch.kitchen_fan", + "openhab_item_switch": "", + } | overrides + + def test_add_switch(self): + self.assertEqual(self.save(self.payload()).status_code, 200) + + switch = Entity.objects.get() + self.assertEqual(switch.entity_type, Entity.EntityType.SWITCH) + self.assertEqual(switch.entity_data["home_assistant_name"], "switch.kitchen_fan") + self.assertLoadableByManager(switch) + self.assertManagerReloaded(times=1) + + def test_add_openhab_switch(self): + self.save(self.payload(controller="openhab", home_assistant_name="", openhab_item_switch="Kitchen_Fan")) + + switch = Entity.objects.get() + self.assertEqual(switch.entity_data["openhab_item_switch"], "Kitchen_Fan") + self.assertLoadableByManager(switch) + + +class ButtonTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_buttons" + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "nspm", + "type": "button", + "friendly_name": "Gate", + "home_assistant_name": "", + "mqtt_topic": "gate/cmd", + "mqtt_payload": "open", + } | overrides + + def test_add_mqtt_button(self): + self.assertEqual(self.save(self.payload()).status_code, 200) + + button = Entity.objects.get() + self.assertEqual(button.entity_type, Entity.EntityType.BUTTON) + self.assertEqual((button.entity_data["mqtt_topic"], button.entity_data["mqtt_payload"]), ("gate/cmd", "open")) + self.assertLoadableByManager(button) + self.assertManagerReloaded(times=1) + + def test_add_home_assistant_button(self): + self.save(self.payload(controller="home_assistant", home_assistant_name="input_button.doorbell", mqtt_topic="", mqtt_payload="")) + + button = Entity.objects.get() + self.assertEqual(button.entity_data["home_assistant_name"], "input_button.doorbell") + self.assertLoadableByManager(button) + + +class ThermostatTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_thermostats" + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "thermostat", + "friendly_name": "Heat pump", + "step_size": "0.5", + "home_assistant_name": "climate.kitchen", + "openhab_fan_mode_item": "", + "openhab_hvac_mode_item": "", + "openhab_preset_mode_item": "", + "openhab_swing_mode_item": "", + "openhab_swingh_mode_item": "", + "openhab_temperature_item": "", + "fan_modes": [], + "hvac_modes": [{"value": "heat", "icon": "fire", "label": "Heat"}, {"value": "cool", "icon": "snow", "label": "Cool"}], + "preset_modes": [], + "swing_modes": [], + "swingh_modes": [], + } | overrides + + def test_add_thermostat(self): + self.assertEqual(self.save(self.payload()).status_code, 200) + + thermostat = Entity.objects.get() + self.assertEqual(thermostat.entity_type, Entity.EntityType.THERMOSTAT) + self.assertEqual(thermostat.entity_data["step_size"], 0.5) + self.assertEqual([m["value"] for m in thermostat.entity_data["hvac_modes"]], ["heat", "cool"]) + self.assertEqual(thermostat.entity_data["home_assistant_name"], "climate.kitchen") + self.assertLoadableByManager(thermostat) + self.assertManagerReloaded(times=1) + + def test_mode_missing_a_field_is_rejected(self): + for field in ("value", "icon", "label"): + with self.subTest(missing=field): + mode = {"value": "heat", "icon": "fire", "label": "Heat"} + del mode[field] + self.assertEqual(self.save(self.payload(hvac_modes=[mode])).status_code, 400) + self.assertFalse(Entity.objects.exists()) + + def test_non_numeric_step_size_is_rejected(self): + self.assertCreatesNothing(self.save(self.payload(step_size="lots"))) + + @expectedFailure + def test_other_methods_are_rejected(self): + # KNOWN GAP: the method check in entities_thermostats() is commented out, so any + # method is handled as a save. A GET only fails because it has no JSON body. + self.assertEqual(self.client.get(reverse(self.url)).status_code, 405) + + +class SceneTests(EntityRESTCommonTests, EntityRESTTestCase): + url = "rest_entities_scenes" + model = Scene + + def setUp(self): + super().setUp() + self.page = self.scenes_page(self.room) + + def payload(self, slot=0, **overrides): + return { + **placement(self.page, slot), + "controller": "home_assistant", + "type": "scene", + "friendly_name": "Movie", + "scene_type": "home_assistant", + "backend_name": "scene.movie_time", + } | overrides + + def test_add_room_scene(self): + self.assertEqual(self.save(self.payload(slot=1)).status_code, 200) + + scene = Scene.objects.get() + self.assertEqual((scene.room, scene.entities_page, scene.room_view_position), (self.room, self.page, 1)) + self.assertEqual(scene.backend_name, "scene.movie_time") + self.assertLoadableByManager(scene) + self.assertManagerReloaded(times=1) + + def test_add_global_nspm_scene(self): + page = self.global_scenes_page() + + response = self.save(self.payload(room_id=None, entities_page_id=page.id, controller="nspm", scene_type="nspm_scene", backend_name="")) + + self.assertEqual(response.status_code, 200) + scene = Scene.objects.get() + self.assertIsNone(scene.room) + self.assertLoadableByManager(scene) + + @expectedFailure + def test_home_assistant_scene_without_backend_name_is_rejected(self): + # KNOWN GAP: an empty backend_name is accepted; the scene can never be activated. + self.assertCreatesNothing(self.save(self.payload(backend_name=""))) + + @expectedFailure + def test_unknown_scene_type_is_rejected(self): + # KNOWN GAP: MQTTManager ignores scenes whose scene_type it does not recognise. + self.assertCreatesNothing(self.save(self.payload(scene_type="zigbee"))) diff --git a/docker/web/nspanelmanager/web/tests/test_layout.py b/docker/web/nspanelmanager/web/tests/test_layout.py new file mode 100644 index 00000000..4b17a7e8 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_layout.py @@ -0,0 +1,285 @@ +""" +Moving and deleting entities and managing entities pages, through the REST endpoints the +React entities-page view calls. +""" + +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import Entity, RoomEntitiesPage, Scene + +from .harness import NSPMTestCase + + +def position(obj, page, slot): + return {"id": obj.id, "entities_page_id": page.id, "room_view_position": slot} + + +class ReadEntityTests(NSPMTestCase): + def test_get_entity(self): + room = self.make_room("Kitchen") + light = self.make_light(room, "Ceiling", slot=2, home_assistant_name="light.ceiling") + + response = self.client.get(reverse("rest_get_entity", kwargs={"entity_id": light.id})) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual((body["id"], body["friendly_name"], body["room_view_position"]), (light.id, "Ceiling", 2)) + self.assertEqual((body["controller"], body["home_assistant_name"]), ("home_assistant", "light.ceiling")) + + def test_get_room_pages_lists_entities_and_scenes(self): + room = self.make_room("Kitchen") + light = self.make_light(room) + scene = self.make_scene(room) + + response = self.client.get(reverse("rest_room_entities_pages", kwargs={"room_id": room.id})) + + self.assertEqual(response.status_code, 200) + pages = {page["type"]: page for page in response.json()["entities_pages"]} + self.assertEqual([e["id"] for e in pages["entity"]["entities"]], [light.id]) + self.assertEqual([s["id"] for s in pages["scene"]["scenes"]], [scene.id]) + + +class MoveEntityTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.page = self.entities_page(self.room) + self.second_page = RoomEntitiesPage.objects.create(room=self.room, page_type=8, is_scenes_page=False, display_order=1) + + def save_order(self, entities=(), scenes=()): + return self.put_json(reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id}), {"entities": list(entities), "scenes": list(scenes)}) + + def assertAt(self, obj, page, slot): + obj.refresh_from_db() + self.assertEqual((obj.entities_page_id, obj.room_view_position), (page.id, slot)) + + def test_move_entity(self): + light = self.make_light(self.room, slot=0) + + response = self.save_order([position(light, self.page, 3)]) + + self.assertEqual(response.status_code, 200) + self.assertAt(light, self.page, 3) + self.assertManagerReloaded(times=1) + + def test_swap_entities_across_pages(self): + ceiling = self.make_light(self.room, "Ceiling", slot=0) + lamp = self.make_light(self.room, "Lamp", page=self.second_page, slot=5) + + response = self.save_order([position(ceiling, self.second_page, 5), position(lamp, self.page, 0)]) + + self.assertEqual(response.status_code, 200) + self.assertAt(ceiling, self.second_page, 5) + self.assertAt(lamp, self.page, 0) + + def test_move_scenes(self): + scenes_page = self.scenes_page(self.room) + movie = self.make_scene(self.room, "Movie", slot=0) + dinner = self.make_scene(self.room, "Dinner", slot=1) + + response = self.save_order(scenes=[position(movie, scenes_page, 1), position(dinner, scenes_page, 0)]) + + self.assertEqual(response.status_code, 200) + self.assertAt(movie, scenes_page, 1) + self.assertAt(dinner, scenes_page, 0) + self.assertManagerReloaded(times=1) + + def test_bad_payloads_are_rejected(self): + light = self.make_light(self.room, slot=0) + url = reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id}) + for body in ({"entities": []}, {"scenes": []}, {"entities": [{"id": light.id}], "scenes": []}, "not json"): + with self.subTest(body=body): + self.assertRejected(self.put_json(url, body)) + self.assertAt(light, self.page, 0) + self.assertManagerNotReloaded() + + def test_unknown_entity_is_rejected(self): + self.assertRejected(self.save_order([{"id": 999, "entities_page_id": self.page.id, "room_view_position": 0}])) + self.assertManagerNotReloaded() + + def test_other_methods_are_rejected(self): + self.assertEqual(self.client.get(reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id})).status_code, 405) + + @expectedFailure + def test_failed_reorder_changes_nothing(self): + # KNOWN GAP: the reorder is not atomic. Entities before the bad entry in the list are + # already saved when it fails, so the DB is left half-reordered, and MQTTManager is + # not told to reload. + light = self.make_light(self.room, slot=0) + + response = self.save_order([position(light, self.page, 3), {"id": 999, "entities_page_id": self.page.id, "room_view_position": 0}]) + + self.assertRejected(response) + self.assertAt(light, self.page, 0) + + @expectedFailure + def test_move_to_slot_outside_page_is_rejected(self): + # KNOWN GAP: room_view_position is not checked against the page size. + light = self.make_light(self.room, slot=0) + + self.assertRejected(self.save_order([position(light, self.page, 11)])) + self.assertAt(light, self.page, 0) + + +class DeleteEntityTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + + def test_delete_entity(self): + doomed = self.make_light(self.room, "Ceiling", slot=1) + kept = self.make_light(self.room, "Lamp", slot=2) + + response = self.client.delete(reverse("rest_get_entity", kwargs={"entity_id": doomed.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(list(Entity.objects.values_list("id", flat=True)), [kept.id]) + self.assertManagerReloaded(times=1) + + def test_delete_unknown_entity_is_rejected(self): + self.make_light(self.room) + + self.assertRejected(self.client.delete(reverse("rest_get_entity", kwargs={"entity_id": 999}))) + self.assertEqual(Entity.objects.count(), 1) + self.assertManagerNotReloaded() + + def test_delete_scene(self): + scene = self.make_scene(self.room) + + response = self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": scene.id})) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Scene.objects.exists()) + + @expectedFailure + def test_delete_scene_reloads_manager(self): + # KNOWN BUG: DELETE /rest/scenes/ removes the row but never calls + # send_mqttmanager_reload_command(), so the panel keeps showing the scene until + # something else triggers a reload. + scene = self.make_scene(self.room) + + self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": scene.id})) + + self.assertManagerReloaded(times=1) + + def test_delete_unknown_scene_is_rejected(self): + self.assertRejected(self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": 999}))) + + +class EntitiesPageTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + + def pages(self, room, is_scenes_page=False): + return list(RoomEntitiesPage.objects.filter(room=room, is_scenes_page=is_scenes_page).order_by("display_order")) + + def create_page(self, page_type, is_scenes_page=False, room=None): + room = room or self.room + return self.put_json(reverse("rest_room_entities_pages", kwargs={"room_id": room.id}), {"type": page_type, "is_scenes_page": is_scenes_page}) + + def test_create_pages(self): + for page_type in (8, 12): + self.assertEqual(self.create_page(page_type).status_code, 200) + + pages = self.pages(self.room) + self.assertEqual([p.page_type for p in pages], [4, 8, 12]) + self.assertManagerReloaded(times=2) + + def test_create_global_scenes_page(self): + self.global_scenes_page() + + response = self.put_json(reverse("rest_global_entities_pages"), {"type": 4, "is_scenes_page": True}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(self.pages(None, is_scenes_page=True)), 2) + + def test_create_page_missing_field_is_rejected(self): + before = RoomEntitiesPage.objects.count() + for body in ({"type": 4}, {"is_scenes_page": False}): + with self.subTest(body=body): + self.assertEqual(self.put_json(reverse("rest_room_entities_pages", kwargs={"room_id": self.room.id}), body).status_code, 400) + self.assertEqual(RoomEntitiesPage.objects.count(), before) + self.assertManagerNotReloaded() + + def test_create_page_in_unknown_room_is_rejected(self): + before = RoomEntitiesPage.objects.count() + + self.assertRejected(self.put_json(reverse("rest_room_entities_pages", kwargs={"room_id": 999}), {"type": 4, "is_scenes_page": False})) + + self.assertEqual(RoomEntitiesPage.objects.count(), before) + self.assertManagerNotReloaded() + + @expectedFailure + def test_create_page_with_unsupported_size_is_rejected(self): + # KNOWN GAP: panels only support 4, 8 or 12 entity pages but any number is stored. + before = RoomEntitiesPage.objects.count() + + self.assertRejected(self.create_page(6)) + self.assertEqual(RoomEntitiesPage.objects.count(), before) + + def test_delete_page_removes_its_entities(self): + second = RoomEntitiesPage.objects.create(room=self.room, page_type=8, is_scenes_page=False, display_order=2) + on_first = self.make_light(self.room, slot=0) + on_second = self.make_light(self.room, page=second, slot=0) + + response = self.client.delete(reverse("rest_room_entities_page", kwargs={"page_id": second.id})) + + self.assertEqual(response.status_code, 200) + self.assertFalse(RoomEntitiesPage.objects.filter(id=second.id).exists()) + self.assertEqual(list(Entity.objects.values_list("id", flat=True)), [on_first.id]) + self.assertNotEqual(on_second.id, on_first.id) + self.assertManagerReloaded(times=1) + + def test_delete_unknown_page_is_rejected(self): + self.assertRejected(self.client.delete(reverse("rest_room_entities_page", kwargs={"page_id": 999}))) + self.assertManagerNotReloaded() + + def test_reorder_pages(self): + first = self.entities_page(self.room) + second = RoomEntitiesPage.objects.create(room=self.room, page_type=8, is_scenes_page=False, display_order=2) + + response = self.put_json(reverse("rest_room_entities_pages_order"), {"order": [[second.id, 0], [first.id, 1]]}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(self.pages(self.room), [second, first]) + self.assertManagerReloaded(times=1) + + def test_reorder_pages_bad_payload_is_rejected(self): + url = reverse("rest_room_entities_pages_order") + self.assertEqual(self.put_json(url, {}).status_code, 400) + self.assertRejected(self.put_json(url, {"order": [[999, 0]]})) + self.assertRejected(self.put_json(url, "not json")) + self.assertManagerNotReloaded() + + def test_resize_page(self): + page = self.entities_page(self.room) + + response = self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"id": page.id, "number_of_entities": 12}) + + self.assertEqual(response.status_code, 200) + page.refresh_from_db() + self.assertEqual(page.page_type, 12) + + @expectedFailure + def test_resize_page_reloads_manager(self): + # KNOWN BUG: PUT /rest/entities_pages/ saves the new size but never calls + # send_mqttmanager_reload_command(), so panels keep the old layout. + page = self.entities_page(self.room) + + self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 12}) + + self.assertManagerReloaded(times=1) + + @expectedFailure + def test_shrinking_page_does_not_strand_entities(self): + # KNOWN GAP: shrinking a 12-slot page to 4 leaves entities in slots 4-11 attached to + # the page but never shown on the panel. Either reject the change or relocate them. + page = RoomEntitiesPage.objects.create(room=self.room, page_type=12, is_scenes_page=False, display_order=2) + light = self.make_light(self.room, page=page, slot=9) + + self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 4}) + + self.assertLoadableByManager(light) diff --git a/docker/web/nspanelmanager/web/tests/test_rooms.py b/docker/web/nspanelmanager/web/tests/test_rooms.py new file mode 100644 index 00000000..8bcb6326 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_rooms.py @@ -0,0 +1,174 @@ +import json +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import Entity, Room, RoomEntitiesPage, Scene + +from .harness import NSPMTestCase + + +class RoomUITests(NSPMTestCase): + """Rooms created/edited/deleted through the web UI views.""" + + def test_create_room(self): + response = self.client.post(reverse("save_new_room"), {"friendly_name": "Kitchen"}) + + room = Room.objects.get(friendly_name="Kitchen") + self.assertRedirects(response, reverse("edit_room", kwargs={"room_id": room.id}), fetch_redirect_response=False) + # A new room always gets one entities page and one scenes page. + self.assertEqual(RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False).count(), 1) + self.assertEqual(RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True).count(), 1) + self.assertManagerReloaded(times=1) + + def test_create_room_without_name_is_rejected(self): + response = self.client.post(reverse("save_new_room"), {}) + + self.assertRejected(response) + self.assertFalse(Room.objects.exists()) + self.assertManagerNotReloaded() + + def test_rename_room(self): + room = self.make_room("Kitchen") + + response = self.client.post( + reverse("update_room_form", kwargs={"room_id": room.id}), + {"friendly_name": "Galley", "room_temp_provider": "home_assistant", "room_temp_sensor": "sensor.galley_temp"}, + ) + + self.assertEqual(response.status_code, 302) + room.refresh_from_db() + self.assertEqual(room.friendly_name, "Galley") + self.assertEqual(room.room_temp_sensor, "sensor.galley_temp") + self.assertManagerReloaded(times=1) + + def test_delete_room_cascades_and_rehomes_panels(self): + kitchen = self.make_room("Kitchen") + lounge = self.make_room("Lounge") + panel = self.make_panel(kitchen) + self.make_light(kitchen) + self.make_scene(kitchen) + + self.client.get(reverse("delete_room", kwargs={"room_id": kitchen.id})) + + self.assertFalse(Room.objects.filter(id=kitchen.id).exists()) + self.assertFalse(Entity.objects.exists()) + self.assertFalse(Scene.objects.exists()) + self.assertFalse(RoomEntitiesPage.objects.filter(room_id=kitchen.id).exists()) + # The panel is not deleted with its room; it moves to another room. + panel.refresh_from_db() + self.assertEqual(panel.room, lounge) + self.assertManagerReloaded(times=1) + + def test_last_room_cannot_be_deleted(self): + room = self.make_room("Only room") + + self.client.get(reverse("delete_room", kwargs={"room_id": room.id})) + + self.assertTrue(Room.objects.filter(id=room.id).exists()) + self.assertManagerNotReloaded() + + def test_reorder_rooms(self): + a, b, c = self.make_room("A"), self.make_room("B"), self.make_room("C") + + response = self.client.post(reverse("htmx_partial_reorder_rooms"), {"htmx_form_save_rooms_order_field": json.dumps({"rooms": [c.id, a.id, b.id]})}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(list(Room.objects.order_by("displayOrder").values_list("friendly_name", flat=True)), ["C", "A", "B"]) + self.assertManagerReloaded(times=1) + + def test_reorder_rooms_with_missing_or_empty_payload_is_rejected(self): + self.make_room("A") + for payload in ({}, {"htmx_form_save_rooms_order_field": json.dumps({})}, {"htmx_form_save_rooms_order_field": json.dumps({"rooms": []})}): + with self.subTest(payload=payload): + self.assertRejected(self.client.post(reverse("htmx_partial_reorder_rooms"), payload)) + self.assertManagerNotReloaded() + + +class RoomRESTTests(NSPMTestCase): + """The /rest/rooms JSON API used by external tools.""" + + def test_create_room(self): + response = self.post_json(reverse("rest_rooms_create"), {"name": "Office"}) + + self.assertEqual(response.status_code, 200) + room_id = response.json()["room_id"] + self.assertEqual(Room.objects.get(id=room_id).friendly_name, "Office") + + def test_create_room_rejects_bad_payloads(self): + for body in ({}, {"friendly_name": "wrong key"}, "not json"): + with self.subTest(body=body): + response = self.post_json(reverse("rest_rooms_create"), body) + self.assertEqual(response.status_code, 500) + self.assertEqual(response.json()["status"], "error") + self.assertFalse(Room.objects.exists()) + + def test_rooms_endpoint_rejects_unsupported_methods(self): + self.assertEqual(self.client.put(reverse("rest_rooms_create")).status_code, 405) + room = self.make_room() + self.assertEqual(self.client.get(reverse("rest_room_delete", kwargs={"room_id": room.id})).status_code, 405) + + def test_delete_room(self): + room = self.make_room("Office") + self.make_light(room) + + response = self.client.delete(reverse("rest_room_delete", kwargs={"room_id": room.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"status": "ok", "room_id": room.id}) + self.assertFalse(Room.objects.exists()) + self.assertFalse(Entity.objects.exists()) + + def test_delete_unknown_room_is_rejected(self): + room = self.make_room("Office") + + response = self.client.delete(reverse("rest_room_delete", kwargs={"room_id": room.id + 100})) + + self.assertRejected(response) + self.assertTrue(Room.objects.filter(id=room.id).exists()) + + def test_list_rooms(self): + office = self.make_room("Office") + lounge = self.make_room("Lounge") + + response = self.client.get(reverse("rest_rooms_create")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["rooms"], [{"id": office.id, "name": "Office"}, {"id": lounge.id, "name": "Lounge"}]) + + def test_get_single_room(self): + self.make_room("Office") + lounge = self.make_room("Lounge") + + response = self.client.get(reverse("rest_rooms_create"), {"id": lounge.id}) + + self.assertEqual(response.json()["rooms"], [{"id": lounge.id, "name": "Lounge"}]) + + @expectedFailure + def test_delete_room_reloads_manager(self): + # KNOWN GAP: unlike the UI's delete_room view, DELETE /rest/rooms/ never calls + # send_mqttmanager_reload_command(), and does not move the room's panels elsewhere. + room = self.make_room("Office") + self.make_room("Lounge") + + self.client.delete(reverse("rest_room_delete", kwargs={"room_id": room.id})) + + self.assertManagerReloaded(times=1) + + @expectedFailure + def test_created_room_has_entity_pages(self): + # KNOWN GAP: unlike the UI, the REST API creates a room with no entities/scenes + # pages. They only appear after the next restart (create_entity_pages_for_all_rooms). + response = self.post_json(reverse("rest_rooms_create"), {"name": "Office"}) + + room_id = response.json()["room_id"] + self.assertEqual(RoomEntitiesPage.objects.filter(room_id=room_id).count(), 2) + + @expectedFailure + def test_create_room_rejects_overlong_name(self): + # KNOWN GAP: Room.friendly_name is max_length=30 but nothing validates it and + # SQLite does not enforce VARCHAR lengths, so any length is stored. + response = self.post_json(reverse("rest_rooms_create"), {"name": "x" * 100}) + + self.assertRejected(response) + self.assertFalse(Room.objects.exists()) diff --git a/docker/web/run_tests.sh b/docker/web/run_tests.sh new file mode 100755 index 00000000..b922c44f --- /dev/null +++ b/docker/web/run_tests.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Run the Django test suite. Extra arguments are passed to "manage.py test", e.g. +# ./run_tests.sh web.tests.test_entities +# ./run_tests.sh -v 2 +cd "$(dirname "$0")/nspanelmanager" || exit 1 +exec python manage.py test --settings=nspanelmanager.test_settings "$@" From 9d3f54f431ee4d87a73cbb32faa481c3267b1dac Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 07:48:32 -0700 Subject: [PATCH 02/17] Expand Django test coverage Adds 113 tests (210 total) covering the rest of the web app: - test_pages: every full page and htmx partial renders, including on a fresh install, and the temperature-sensor pickers route to the configured source - test_settings: settings form (secret handling, trailing slashes, defaults), weather & time, theme, the first-run wizard, the settings REST endpoints and web.settings_helper - test_panels: accept/deny/delete/unblock, the per-panel settings form and the legacy api/ panel endpoints - test_relay_groups: form views, htmx endpoints and the REST listing - test_files: firmware/LittleFS/TFT upload, checksum and download, including which TFT each panel type gets and a contract test for the exclusive Range end the panel firmware depends on - test_misc: messages, HA/OpenHAB entity lookups, hostname lookup and read-only listings New known issues are pinned with @expectedFailure as before. Co-Authored-By: Claude Opus 5.5 --- .../nspanelmanager/web/tests/test_files.py | 163 +++++++++ .../web/nspanelmanager/web/tests/test_misc.py | 165 +++++++++ .../nspanelmanager/web/tests/test_pages.py | 147 ++++++++ .../nspanelmanager/web/tests/test_panels.py | 274 +++++++++++++++ .../web/tests/test_relay_groups.py | 172 ++++++++++ .../nspanelmanager/web/tests/test_settings.py | 317 ++++++++++++++++++ 6 files changed, 1238 insertions(+) create mode 100644 docker/web/nspanelmanager/web/tests/test_files.py create mode 100644 docker/web/nspanelmanager/web/tests/test_misc.py create mode 100644 docker/web/nspanelmanager/web/tests/test_pages.py create mode 100644 docker/web/nspanelmanager/web/tests/test_panels.py create mode 100644 docker/web/nspanelmanager/web/tests/test_relay_groups.py create mode 100644 docker/web/nspanelmanager/web/tests/test_settings.py diff --git a/docker/web/nspanelmanager/web/tests/test_files.py b/docker/web/nspanelmanager/web/tests/test_files.py new file mode 100644 index 00000000..1bbbd6d8 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_files.py @@ -0,0 +1,163 @@ +""" +Firmware, LittleFS and TFT images: uploading from the UI, and the checksum and download +endpoints panels use to decide whether to update and to fetch the image. +""" + +import hashlib +import shutil +import tempfile +from unittest import expectedFailure + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from django.urls import reverse + +from web.settings_helper import set_nspanel_setting_value + +from .harness import NSPMTestCase + +FIRMWARE = bytes(range(256)) * 4 # 1 KiB with a recognisable pattern + + +def md5(data): + return hashlib.md5(data).hexdigest() + + +class FileTestCase(NSPMTestCase): + def setUp(self): + super().setUp() + media_root = tempfile.mkdtemp(prefix="nspm-media-") + self.addCleanup(shutil.rmtree, media_root, ignore_errors=True) + override = override_settings(MEDIA_ROOT=media_root) + override.enable() + self.addCleanup(override.disable) + + def upload(self, url_name, field, data, **form): + return self.client.post(reverse(url_name), {field: SimpleUploadedFile("upload.bin", data), **form}) + + +class FirmwareTests(FileTestCase): + def test_upload_then_checksum_and_download(self): + response = self.upload("save_new_firmware", "firmware", FIRMWARE, model="sonoff") + + self.assertRedirects(response, "/", fetch_redirect_response=False) + self.assertEqual(self.client.get(reverse("checksum_firmware")).content.decode(), md5(FIRMWARE)) + self.assertEqual(self.client.get(reverse("download_firmware")).content, FIRMWARE) + self.assertManagerReloaded(times=1) + + def test_models_are_stored_separately(self): + self.upload("save_new_firmware", "firmware", b"sonoff", model="sonoff") + self.upload("save_new_firmware", "firmware", b"custom", model="custom") + + self.assertEqual(self.client.get(reverse("download_firmware"), {"model": "custom"}).content, b"custom") + self.assertEqual(self.client.get(reverse("download_firmware"), {"model": "sonoff"}).content, b"sonoff") + self.assertEqual(self.client.get(reverse("checksum_firmware"), {"model": "custom"}).content.decode(), md5(b"custom")) + + def test_upload_replaces_previous_image(self): + self.upload("save_new_firmware", "firmware", b"old", model="sonoff") + self.upload("save_new_firmware", "firmware", b"new", model="sonoff") + + self.assertEqual(self.client.get(reverse("download_firmware")).content, b"new") + + def test_upload_without_model_is_ignored(self): + self.upload("save_new_firmware", "firmware", FIRMWARE) + + self.assertManagerNotReloaded() + self.assertRejected(self.client.get(reverse("download_firmware"))) + + def test_unknown_model_is_rejected(self): + for url_name in ("download_firmware", "checksum_firmware", "download_data_file", "checksum_data_file"): + with self.subTest(url_name=url_name): + self.assertEqual(self.client.get(reverse(url_name), {"model": "../../etc"}).status_code, 400) + + def test_range_requests_use_an_exclusive_end(self): + # CONTRACT with the panel firmware (NSPanelManager-firmware, UpdateManager.cpp): the + # "bytes=start-end" end is treated as EXCLUSIVE (Python slice), not inclusive as RFC 7233 + # says. The firmware deliberately sends ranges in the same convention. Changing either + # side alone corrupts OTA updates. + self.upload("save_new_firmware", "firmware", FIRMWARE, model="sonoff") + + response = self.client.get(reverse("download_firmware"), HTTP_RANGE="bytes=100-200") + + self.assertEqual(response.content, FIRMWARE[100:200]) + self.assertEqual(len(response.content), 100) + + def test_open_ended_range(self): + self.upload("save_new_firmware", "firmware", FIRMWARE, model="sonoff") + + self.assertEqual(self.client.get(reverse("download_firmware"), HTTP_RANGE="bytes=1000-").content, FIRMWARE[1000:]) + + def test_merged_flash_upload_does_not_reload_manager(self): + # MQTTManager does not track the merged image's checksum, so there is nothing to reload. + self.upload("save_new_merged_flash", "bin", FIRMWARE, model="sonoff") + + self.assertManagerNotReloaded() + + @expectedFailure + def test_checksum_of_missing_image_is_not_found(self): + # KNOWN GAP: when no image has been uploaded the checksum endpoints return HTTP 200 with + # the body "None" (str(None)), which a panel cannot tell apart from a real checksum. + response = self.client.get(reverse("checksum_firmware")) + + self.assertEqual(response.status_code, 404) + + +class DataFileTests(FileTestCase): + def test_upload_then_checksum_and_download(self): + self.upload("save_new_data_file", "data_file", FIRMWARE, model="sonoff") + + self.assertEqual(self.client.get(reverse("checksum_data_file")).content.decode(), md5(FIRMWARE)) + self.assertEqual(self.client.get(reverse("download_data_file")).content, FIRMWARE) + self.assertEqual(self.client.get(reverse("download_data_file"), HTTP_RANGE="bytes=0-16").content, FIRMWARE[:16]) + self.assertManagerReloaded(times=1) + + +class TFTTests(FileTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room() + self.panel = self.make_panel(self.room) + + def upload_tft(self, tft_file_type, data): + return self.upload("save_new_tft_file", "tft_file", data, tft_file_type=tft_file_type) + + def panel_tft(self, **headers): + return self.client.get(reverse("download_tft", kwargs={"panel_id": self.panel.id}), **headers).content + + def configure_panel(self, is_us_panel="False", orientation=None, tft="tft1"): + set_nspanel_setting_value(self.panel.id, "is_us_panel", is_us_panel) + set_nspanel_setting_value(self.panel.id, "selected_tft", tft) + if orientation: + set_nspanel_setting_value(self.panel.id, "us_panel_orientation", orientation) + + def test_each_panel_gets_the_image_for_its_type_and_selected_tft(self): + for tft_file_type in ("eu1", "eu3", "us2", "us4_horizontal_mirrored"): + self.upload_tft(tft_file_type, tft_file_type.encode()) + + cases = [ + ({}, b"eu1"), # defaults: EU panel, tft1 + ({"tft": "tft3"}, b"eu3"), + ({"is_us_panel": "True", "orientation": "vertical", "tft": "tft2"}, b"us2"), + # A horizontal US panel has the same screen orientation as an EU panel. + ({"is_us_panel": "True", "orientation": "horizontal", "tft": "tft3"}, b"eu3"), + ({"is_us_panel": "True", "orientation": "horizontal_mirrored", "tft": "tft4"}, b"us4_horizontal_mirrored"), + ] + for config, expected in cases: + with self.subTest(config=config): + self.configure_panel(**config) + self.assertEqual(self.panel_tft(), expected) + self.assertEqual(self.client.get(reverse("checksum_tft_file", kwargs={"panel_id": self.panel.id})).content.decode(), md5(expected)) + + def test_tft_range_download(self): + self.upload_tft("eu1", FIRMWARE) + + self.assertEqual(self.panel_tft(HTTP_RANGE="bytes=10-20"), FIRMWARE[10:20]) + + def test_upload_reloads_manager(self): + self.upload_tft("eu1", FIRMWARE) + + self.assertManagerReloaded(times=1) + + def test_unknown_tft_type_is_rejected(self): + self.assertEqual(self.upload_tft("eu9", FIRMWARE).status_code, 500) + self.assertManagerNotReloaded() diff --git a/docker/web/nspanelmanager/web/tests/test_misc.py b/docker/web/nspanelmanager/web/tests/test_misc.py new file mode 100644 index 00000000..6fda9522 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_misc.py @@ -0,0 +1,165 @@ +""" +Smaller endpoints: messages, Home Assistant / OpenHAB entity lookups used by the entity +modal, hostname lookup and read-only listings. +""" + +import json +import socket +from unittest import expectedFailure, mock + +from django.urls import reverse + +from web.models import Message + +from .harness import NSPMTestCase + + +class MessageTests(NSPMTestCase): + def test_unread_messages_are_flagged_and_can_be_marked_read(self): + message = Message.objects.create(title="Update available", message="v2.1 is out") + self.assertTrue(self.client.get(reverse("index")).context["has_unread_messages"]) + + response = self.client.get(reverse("htmx_mark_message_read", kwargs={"message_id": message.id})) + + self.assertContains(response, "Update available") + message.refresh_from_db() + self.assertTrue(message.read) + self.assertFalse(self.client.get(reverse("index")).context["has_unread_messages"]) + + def test_mark_unknown_message_is_rejected(self): + self.assertEqual(self.client.get(reverse("htmx_mark_message_read", kwargs={"message_id": 999})).status_code, 500) + + +class EntitySourceLookupTests(NSPMTestCase): + """The lists the React entity modal offers when picking a Home Assistant / OpenHAB item.""" + + def test_home_assistant_entities(self): + self.home_assistant.add("light.kitchen", friendly_name="Kitchen") + self.home_assistant.add("switch.fan", friendly_name="Fan") + + response = self.client.get(reverse("rest_get_home_assistant_entities")) + + self.assertEqual(response.status_code, 200) + self.assertEqual([(i["item_id"], i["label"]) for i in response.json()["items"]], [("light.kitchen", "Kitchen"), ("switch.fan", "Fan")]) + + def test_home_assistant_entities_filtered_by_domain(self): + self.home_assistant.add("light.kitchen") + self.home_assistant.add("switch.fan") + self.home_assistant.add("climate.lounge") + + response = self.client.get(reverse("rest_get_home_assistant_entities"), {"filter": json.dumps({"type": ["switch", "climate"]})}) + + self.assertEqual([i["item_id"] for i in response.json()["items"]], ["switch.fan", "climate.lounge"]) + + def test_entity_without_friendly_name_is_labelled_by_id(self): + self.home_assistant.add("light.unnamed") + + self.assertEqual(self.client.get(reverse("rest_get_home_assistant_entities")).json()["items"][0]["label"], "light.unnamed") + + def test_home_assistant_error_is_reported(self): + self.home_assistant.fail = True + + response = self.client.get(reverse("rest_get_home_assistant_entities")) + + self.assertEqual(response.json()["items"], []) + self.assertTrue(response.json()["errors"]) + + def test_home_assistant_not_configured_is_reported(self): + from web.settings_helper import set_setting_value + + set_setting_value("home_assistant_token", "") + + self.assertTrue(self.client.get(reverse("rest_get_home_assistant_entities")).json()["errors"]) + + def test_openhab_items_include_scenes(self): + self.openhab_items = [{"type": "openhab", "label": "Lamp", "item_id": "Lamp"}] + + response = self.client.get(reverse("rest_get_openhab_items")) + + # The fake returns the same list for items and for scenes (rules), so both are merged in. + self.assertEqual([i["item_id"] for i in response.json()["items"]], ["Lamp", "Lamp"]) + + def test_lookups_are_read_only(self): + self.assertEqual(self.client.post(reverse("rest_get_home_assistant_entities")).status_code, 405) + self.assertEqual(self.client.post(reverse("rest_get_openhab_items")).status_code, 405) + + +class HostnameLookupTests(NSPMTestCase): + def test_resolves_hostname(self): + with mock.patch("web.rest.socket.gethostbyname", return_value="192.168.1.20") as lookup: + response = self.post_json(reverse("get_ip_by_hostname"), {"hostname": "mqtt.local"}) + + lookup.assert_called_once_with("mqtt.local") + self.assertEqual(response.json(), {"ip": "192.168.1.20"}) + + def test_unknown_hostname_is_404(self): + with mock.patch("web.rest.socket.gethostbyname", side_effect=socket.gaierror): + self.assertEqual(self.post_json(reverse("get_ip_by_hostname"), {"hostname": "nope.invalid"}).status_code, 404) + + def test_missing_hostname_is_rejected(self): + with mock.patch("web.rest.socket.gethostbyname") as lookup: + self.assertEqual(self.post_json(reverse("get_ip_by_hostname"), {}).status_code, 500) + lookup.assert_not_called() + + +class ReadOnlyListingTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + + def test_room_entities(self): + light = self.make_light(self.room, "Ceiling") + self.make_light(self.make_room("Lounge"), "Lamp") + + response = self.client.get(reverse("rest_room_entities", kwargs={"room_id": self.room.id})) + + self.assertEqual([e["id"] for e in response.json()["entities"]], [light.id]) + + def test_global_entities_pages(self): + page = self.global_scenes_page() + scene = self.make_scene(None, "Goodnight") + + response = self.client.get(reverse("rest_global_entities_pages")) + + pages = response.json()["entities_pages"] + self.assertEqual([(p["id"], p["room_id"]) for p in pages], [(page.id, None)]) + self.assertEqual([s["id"] for s in pages[0]["scenes"]], [scene.id]) + + def test_single_entities_page(self): + light = self.make_light(self.room, slot=1) + + response = self.client.get(reverse("rest_room_entities_page", kwargs={"page_id": self.entities_page(self.room).id})) + + self.assertEqual([e["id"] for e in response.json()["entities"]], [light.id]) + + def test_get_scene(self): + scene = self.make_scene(self.room, "Dinner", backend_name="scene.dinner") + + result = self.client.get(reverse("rest_get_scene", kwargs={"scene_id": scene.id})).json()["result"] + + self.assertEqual((result["friendly_name"], result["backend_name"], result["room_id"]), ("Dinner", "scene.dinner", self.room.id)) + + def test_scenes_listing(self): + scene = self.make_scene(self.room, "Dinner") + self.make_scene(None, "Goodnight") + + response = self.client.get(reverse("rest_lights"), {"room_id": self.room.id}) + + self.assertEqual([s["scene_id"] for s in response.json()["scenes"]], [scene.id]) + + def test_legacy_scene_and_room_config(self): + scene = self.make_scene(self.room, "Dinner") + + self.assertEqual(self.client.get(reverse("get_scenes")).json()["scenes"][0]["scene_id"], scene.id) + self.assertEqual(self.client.get(reverse("get_nspanel_config", kwargs={"room_id": self.room.id})).json()["name"], "Kitchen") + + @expectedFailure + def test_scenes_listing_filters_by_scene_id(self): + # KNOWN BUG: scenes_get() checks for a "light_id" parameter but then filters on + # "scene_id", so ?scene_id=N is ignored and every scene is returned. + scene = self.make_scene(self.room, "Dinner") + self.make_scene(self.room, "Breakfast") + + response = self.client.get(reverse("rest_lights"), {"scene_id": scene.id}) + + self.assertEqual([s["scene_id"] for s in response.json()["scenes"]], [scene.id]) diff --git a/docker/web/nspanelmanager/web/tests/test_pages.py b/docker/web/nspanelmanager/web/tests/test_pages.py new file mode 100644 index 00000000..75894748 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_pages.py @@ -0,0 +1,147 @@ +""" +Every page and htmx partial renders. These catch broken templates, template tags and +view context (e.g. a renamed model field referenced from a template), which otherwise +only show up when someone opens the page. +""" + +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import Message, RelayGroup +from web.settings_helper import set_setting_value + +from .harness import NSPMTestCase + + +class PageRenderTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.make_light(self.room, "Ceiling") + self.make_scene(self.room, "Dinner") + self.make_scene(None, "Goodnight", scene_type="nspm_scene", backend_name="") + self.panel = self.make_panel(self.room, "Kitchen panel", accepted=True) + self.denied_panel = self.make_panel(self.room, "Denied panel", mac="AA:BB:CC:DD:EE:01", denied=True) + self.relay_group = RelayGroup.objects.create(friendly_name="Hall lights") + Message.objects.create(title="Hello", message="World") + + def assertRenders(self, url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200, f"GET {url} returned {response.status_code}") + return response + + def test_full_pages(self): + pages = [ + reverse("index"), + reverse("rooms"), + reverse("edit_room", kwargs={"room_id": self.room.id}), + reverse("edit_nspanel", kwargs={"panel_id": self.panel.id}), + reverse("settings"), + reverse("relay_groups"), + reverse("global_scenes"), + reverse("weather_and_time"), + reverse("denied_nspanels"), + ] + for url in pages: + with self.subTest(url=url): + self.assertRenders(url) + + def test_pages_render_with_no_configuration(self): + # A fresh install: no settings, no rooms, no panels. + self.room.delete() + for url in (reverse("index"), reverse("rooms"), reverse("settings"), reverse("weather_and_time"), reverse("global_scenes"), reverse("relay_groups")): + with self.subTest(url=url): + self.assertRenders(url) + + def test_index_prompts_for_manager_address_until_configured(self): + self.assertContains(self.assertRenders(reverse("index")), "No manager address configured") + + def test_index_lists_panels_but_not_denied_ones(self): + response = self.assertRenders(reverse("htmx_partial_index_nspanels_section")) + + self.assertContains(response, "Kitchen panel") + self.assertNotContains(response, "Denied panel") + + def test_htmx_partials(self): + partials = [ + reverse("htmx_partial_index_nspanels_section"), + reverse("htmx_partial_select_new_entity_type_react", kwargs={"entities_page_id": self.entities_page(self.room).id, "room_view_position": 1}), + reverse("htmx_partial_select_weather_location"), + reverse("htmx_partial_select_weather_outside_temperature_sensor"), + reverse("htmx_show_messages"), + reverse("htmx_modal_create_new_relay_group"), + reverse("htmx_modal_edit_relay_group", kwargs={"relay_group_id": self.relay_group.id}), + reverse("htmx_modal_add_relay_to_relay_group", kwargs={"relay_group_id": self.relay_group.id}), + ] + for url in partials: + with self.subTest(url=url): + self.assertRenders(url) + + def test_initial_setup_modals(self): + for name in ( + "htmx_initial_setup_welcome", + "htmx_initial_setup_manager_settings", + "htmx_initial_setup_mqtt_settings", + "htmx_initial_setup_home_assistant_settings", + "htmx_initial_setup_openhab_settings", + "htmx_initial_setup_finished", + ): + with self.subTest(name=name): + self.assertRenders(reverse(name)) + + def test_sensor_pickers_list_home_assistant_entities(self): + self.home_assistant.add("sensor.kitchen_temperature", friendly_name="Kitchen temperature") + + for url in ( + reverse("htmx_partial_select_room_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant", "room_id": self.room.id}), + reverse("htmx_partial_select_weather_outside_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant"}), + ): + with self.subTest(url=url): + self.assertContains(self.assertRenders(url), "Kitchen temperature") + + def test_sensor_provider_picker_skips_straight_to_the_only_configured_source(self): + room_list = reverse("htmx_partial_select_room_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant", "room_id": self.room.id}) + weather_list = reverse("htmx_partial_select_weather_outside_temperature_sensor_from_list", kwargs={"entity_source": "home_assistant"}) + + self.assertRedirects(self.client.get(reverse("htmx_partial_select_room_temperature_sensor_provider", kwargs={"room_id": self.room.id})), room_list, fetch_redirect_response=False) + self.assertRedirects(self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider")), weather_list, fetch_redirect_response=False) + + set_setting_value("home_assistant_token", "") + set_setting_value("openhab_address", "http://openhab.test") + set_setting_value("openhab_token", "token") + self.assertRedirects( + self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider")), + reverse("htmx_partial_select_weather_outside_temperature_sensor_from_list", kwargs={"entity_source": "openhab"}), + fetch_redirect_response=False, + ) + + def test_sensor_provider_picker_offers_a_choice_when_both_are_configured(self): + set_setting_value("openhab_address", "http://openhab.test") + set_setting_value("openhab_token", "token") + + self.assertRenders(reverse("htmx_partial_select_room_temperature_sensor_provider", kwargs={"room_id": self.room.id})) + self.assertRenders(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider")) + + def test_sensor_provider_picker_with_nothing_configured_is_an_error(self): + set_setting_value("home_assistant_token", "") + + self.assertRejected(self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider"))) + + @expectedFailure + def test_unused_legacy_partials_render(self): + # KNOWN BUG: two routes nothing in the UI calls any more are broken. Fix or delete them. + # - nspanel_index_view: the nspanel_status_header component picks its template by URL + # name and has none for this URL, so rendering raises ImproperlyConfigured. + # - edit_entities_page: its template was removed with the move to React. + self.assertRenders(reverse("htmx_partial_nspanel_index_view", kwargs={"nspanel_id": self.panel.id})) + self.assertRenders(reverse("htmx_partial_edit_entities_page", kwargs={"page_id": self.entities_page(self.room).id})) + + def test_unknown_objects_are_rejected(self): + for url in ( + reverse("edit_nspanel", kwargs={"panel_id": 999}), + reverse("htmx_partial_nspanel_index_view", kwargs={"nspanel_id": 999}), + reverse("htmx_modal_edit_relay_group", kwargs={"relay_group_id": 999}), + ): + with self.subTest(url=url): + self.assertRejected(self.client.get(url)) diff --git a/docker/web/nspanelmanager/web/tests/test_panels.py b/docker/web/nspanelmanager/web/tests/test_panels.py new file mode 100644 index 00000000..8cf1e44e --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_panels.py @@ -0,0 +1,274 @@ +""" +Panel lifecycle (accept, deny, delete, unblock), the per-panel settings form, and the +legacy panel-facing api/ endpoints. +""" + +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import Entity, NSPanel +from web.settings_helper import get_nspanel_setting_with_default, set_nspanel_setting_value + +from .harness import NSPMTestCase + + +class PanelLifecycleTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.panel = self.make_panel(self.room, "New panel") + + def test_accept_register_request(self): + self.panel.denied = True + self.panel.save() + + response = self.client.post(reverse("htmx_nspanel_accept_register_request", kwargs={"nspanel_id": self.panel.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["HX-Refresh"], "true") + self.panel.refresh_from_db() + self.assertEqual((self.panel.accepted, self.panel.denied), (True, False)) + self.assertManagerReloaded(times=1) + + def test_deny_register_request(self): + response = self.client.post(reverse("htmx_nspanel_deny_register_request", kwargs={"nspanel_id": self.panel.id})) + + self.assertEqual(response.status_code, 200) + self.panel.refresh_from_db() + self.assertTrue(self.panel.denied) + self.assertManagerReloaded(times=1) + + def test_denied_panels_page_lists_denied_panel(self): + self.client.post(reverse("htmx_nspanel_deny_register_request", kwargs={"nspanel_id": self.panel.id})) + + self.assertContains(self.client.get(reverse("denied_nspanels")), "New panel") + + def test_accept_and_deny_require_post(self): + for name in ("htmx_nspanel_accept_register_request", "htmx_nspanel_deny_register_request"): + with self.subTest(name=name): + self.assertEqual(self.client.get(reverse(name, kwargs={"nspanel_id": self.panel.id})).status_code, 405) + self.panel.refresh_from_db() + self.assertEqual((self.panel.accepted, self.panel.denied), (False, False)) + self.assertManagerNotReloaded() + + def test_accept_unknown_panel_is_rejected(self): + self.assertRejected(self.client.post(reverse("htmx_nspanel_accept_register_request", kwargs={"nspanel_id": 999}))) + self.assertManagerNotReloaded() + + def test_delete_panel(self): + set_nspanel_setting_value(self.panel.id, "screen_dim_level", "50") + + response = self.client.delete(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["HX-Redirect"], "/") + self.assertFalse(NSPanel.objects.exists()) + self.assertEqual(get_nspanel_setting_with_default(self.panel.id, "screen_dim_level", "gone"), "gone") + + @expectedFailure + def test_delete_panel_reloads_manager(self): + # KNOWN BUG: nspanel_delete() never calls send_mqttmanager_reload_command() (deny and + # unblock both do), so MQTTManager keeps the deleted panel until its next reload. + self.client.delete(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})) + + self.assertManagerReloaded(times=1) + + def test_delete_requires_delete_method(self): + self.assertEqual(self.client.post(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})).status_code, 405) + self.assertTrue(NSPanel.objects.exists()) + + def test_unblock_removes_the_panel_so_it_can_register_again(self): + for url_name, method in (("htmx_unblock_nspanel", "delete"), ("unblock_nspanel", "get")): + with self.subTest(url_name=url_name): + panel = self.make_panel(self.room, "Blocked", mac=f"AA:BB:CC:DD:EE:{len(url_name):02d}", denied=True) + getattr(self.client, method)(reverse(url_name, kwargs={"nspanel_id": panel.id})) + self.assertFalse(NSPanel.objects.filter(id=panel.id).exists()) + self.assertManagerReloaded(times=2) + + def test_deleting_a_room_keeps_its_panels(self): + other = self.make_room("Lounge") + + self.client.get(reverse("delete_room", kwargs={"room_id": self.room.id})) + + self.panel.refresh_from_db() + self.assertEqual(self.panel.room, other) + + +# Every field the panel settings form submits, with "use global default" choices. +PANEL_FORM = { + "name": "Kitchen panel", + "button1_mode": "0", + "button1_detached_mode_light": "", + "button1_custom_mqtt_topic": "", + "button1_custom_mqtt_payload": "", + "button2_mode": "0", + "button2_detached_mode_light": "", + "button2_custom_mqtt_topic": "", + "button2_custom_mqtt_payload": "", + "screen_dim_level": "", + "screensaver_dim_level": "", + "screensaver_activation_timeout": "", + "screensaver_mode": "global", + "show_screensaver_inside_temperature": "global", + "show_screensaver_outside_temperature": "global", + "temperature_calibration": "", + "relay1_default_mode": "False", + "relay2_default_mode": "False", + "default_page": "0", + "lock_to_default_room": "False", + "reverse_relays": "False", + "button1_relay_lower_temperature": "", + "button1_relay_upper_temperature": "", + "button2_relay_lower_temperature": "", + "button2_relay_upper_temperature": "", + "panel_type": "eu", + "selected_tft": "tft1", + "relay1_is_light": "False", + "relay2_is_light": "False", +} + + +class PanelSettingsFormTests(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.panel = self.make_panel(self.room, "Panel", accepted=True) + + def save(self, **overrides): + return self.client.post(reverse("save_panel_settings", kwargs={"panel_id": self.panel.id}), PANEL_FORM | {"room_id": self.room.id} | overrides) + + def panel_setting(self, name, default=None): + return get_nspanel_setting_with_default(self.panel.id, name, default) + + def test_save_basic_settings(self): + lounge = self.make_room("Lounge") + + response = self.save(room_id=lounge.id, name="Renamed", default_page="2", temperature_calibration="-1.5") + + self.assertRedirects(response, reverse("edit_nspanel", kwargs={"panel_id": self.panel.id}), fetch_redirect_response=False) + self.panel.refresh_from_db() + self.assertEqual((self.panel.friendly_name, self.panel.room), ("Renamed", lounge)) + self.assertEqual(self.panel_setting("default_page"), "2") + self.assertEqual(self.panel_setting("temperature_calibration"), "-1.5") + self.assertManagerReloaded(times=1) + + def test_blank_overrides_fall_back_to_global_settings(self): + set_nspanel_setting_value(self.panel.id, "screen_dim_level", "40") + set_nspanel_setting_value(self.panel.id, "screensaver_mode", "no_background") + + self.save() + + self.assertIsNone(self.panel_setting("screen_dim_level")) + self.assertIsNone(self.panel_setting("screensaver_mode")) + self.assertEqual(self.panel_setting("temperature_calibration"), "0.0") + + def test_per_panel_overrides_are_stored(self): + self.save(screen_dim_level="40", screensaver_mode="no_background", show_screensaver_inside_temperature="False") + + self.assertEqual(self.panel_setting("screen_dim_level"), "40") + self.assertEqual(self.panel_setting("screensaver_mode"), "no_background") + self.assertEqual(self.panel_setting("show_screensaver_inside_temperature"), "False") + + def test_panel_types(self): + cases = { + "eu": ("False", None), + "us_vertical": ("True", "vertical"), + "us_horizontal": ("True", "horizontal"), + "us_horizontal_mirrored": ("True", "horizontal_mirrored"), + } + for panel_type, expected in cases.items(): + with self.subTest(panel_type=panel_type): + self.save(panel_type=panel_type) + is_us = self.panel_setting("is_us_panel") + self.assertEqual(is_us, expected[0]) + if expected[1]: + self.assertEqual(self.panel_setting("us_panel_orientation"), expected[1]) + + def test_button_detached_mode_links_an_entity(self): + light = self.make_light(self.room, "Ceiling") + + self.save(button1_mode="1", button1_detached_mode_light=str(light.id)) + + self.panel.refresh_from_db() + self.assertEqual(self.panel.button1_mode, 1) + self.assertEqual(self.panel.button1_detached_mode_entity, light) + + self.save(button1_mode="0") + + self.panel.refresh_from_db() + self.assertIsNone(self.panel.button1_detached_mode_entity) + + def test_deleting_the_detached_entity_unlinks_it(self): + light = self.make_light(self.room, "Ceiling") + self.save(button2_mode="1", button2_detached_mode_light=str(light.id)) + + Entity.objects.all().delete() + + self.panel.refresh_from_db() + self.assertIsNone(self.panel.button2_detached_mode_entity) + + def test_button_custom_mqtt_mode(self): + self.save(button2_mode="2", button2_custom_mqtt_topic="garage/door", button2_custom_mqtt_payload="toggle") + + self.assertEqual((self.panel_setting("button2_mqtt_topic"), self.panel_setting("button2_mqtt_payload")), ("garage/door", "toggle")) + + self.save(button2_mode="0") + + self.assertIsNone(self.panel_setting("button2_mqtt_topic")) + + def test_detached_mode_with_unknown_entity_is_rejected(self): + self.assertRejected(self.save(button1_mode="1", button1_detached_mode_light="999")) + self.panel.refresh_from_db() + self.assertEqual(self.panel.button1_mode, 0) + self.assertManagerNotReloaded() + + def test_non_numeric_temperature_calibration_is_rejected(self): + self.assertRejected(self.save(temperature_calibration="warm")) + self.assertManagerNotReloaded() + + def test_unknown_room_is_rejected(self): + self.assertRejected(self.save(room_id=999)) + self.panel.refresh_from_db() + self.assertEqual(self.panel.room, self.room) + + def test_edit_page_shows_saved_values(self): + self.save(name="Hallway", screen_dim_level="40") + + response = self.client.get(reverse("edit_nspanel", kwargs={"panel_id": self.panel.id})) + + self.assertContains(response, "Hallway") + self.assertEqual(response.context["settings"]["screen_dim_level"], "40") + + +class LegacyPanelAPITests(NSPMTestCase): + """api/* endpoints from before panel config moved to protobuf over MQTT.""" + + def setUp(self): + super().setUp() + self.room = self.make_room("Kitchen") + self.panel = self.make_panel(self.room, "Kitchen panel", mac="C0:49:EF:00:00:01") + + @expectedFailure + def test_get_nspanel_config(self): + # KNOWN BUG: get_nspanel_config() reads nspanel.button1_detached_mode_light, which was + # renamed to button1_detached_mode_entity, so it always returns 500. (The URL is marked + # "TODO: Remove" in urls.py; deleting it would also resolve this.) + response = self.client.get(reverse("get_nspanel_config"), {"mac": self.panel.mac_address}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["name"], "Kitchen panel") + + def test_get_nspanel_config_for_unknown_panel_is_rejected(self): + self.assertEqual(self.client.get(reverse("get_nspanel_config"), {"mac": "00:00:00:00:00:00"}).status_code, 500) + + def test_online_status_for_unknown_panel_is_rejected(self): + response = self.client.post(reverse("set_panel_online_status", kwargs={"panel_mac": "00:00:00:00:00:00"}), '{"state": "online"}', content_type="application/json") + + self.assertEqual(response.status_code, 500) + + def test_restart_mqtt_manager(self): + response = self.client.post(reverse("restart_mqtt_manager")) + + self.assertEqual(response.json(), {"result": "OK"}) + self.assertEqual(self.mqttmanager.restarts, 1) diff --git a/docker/web/nspanelmanager/web/tests/test_relay_groups.py b/docker/web/nspanelmanager/web/tests/test_relay_groups.py new file mode 100644 index 00000000..dc975ad8 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_relay_groups.py @@ -0,0 +1,172 @@ +""" +Relay groups: tie panel relays together so switching one switches them all. +Covered through both the form views and the htmx endpoints, plus the REST listing +MQTTManager reads. +""" + +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import NSPanel, RelayGroup, RelayGroupBinding + +from .harness import NSPMTestCase + + +class RelayGroupTestCase(NSPMTestCase): + def setUp(self): + super().setUp() + self.room = self.make_room("Hall") + self.panel = self.make_panel(self.room, "Hall panel", accepted=True) + self.other_panel = self.make_panel(self.room, "Stairs panel", mac="AA:BB:CC:DD:EE:01", accepted=True) + self.group = RelayGroup.objects.create(friendly_name="Hall lights") + + def bind(self, panel, relay_num): + return RelayGroupBinding.objects.create(relay_group=self.group, nspanel=panel, relay_num=relay_num) + + +class RelayGroupViewTests(RelayGroupTestCase): + def test_create_group(self): + response = self.client.post(reverse("create_or_update_relay_group"), {"relay_group_id": "", "relay_group_name": "Porch"}) + + self.assertRedirects(response, reverse("relay_groups"), fetch_redirect_response=False) + self.assertTrue(RelayGroup.objects.filter(friendly_name="Porch").exists()) + self.assertManagerReloaded(times=1) + + def test_rename_group(self): + self.client.post(reverse("create_or_update_relay_group"), {"relay_group_id": self.group.id, "relay_group_name": "Landing"}) + + self.group.refresh_from_db() + self.assertEqual(self.group.friendly_name, "Landing") + self.assertEqual(RelayGroup.objects.count(), 1) + + def test_add_relay_to_group(self): + response = self.client.post(reverse("add_nspanel_relay_to_group"), {"relay_group_id": self.group.id, "nspanel_id": self.panel.id, "relay_num": "2"}) + + self.assertEqual(response.status_code, 302) + binding = RelayGroupBinding.objects.get() + self.assertEqual((binding.relay_group, binding.nspanel, binding.relay_num), (self.group, self.panel, 2)) + self.assertManagerReloaded(times=1) + + def test_adding_the_same_relay_twice_is_a_no_op(self): + self.bind(self.panel, 1) + + self.client.post(reverse("add_nspanel_relay_to_group"), {"relay_group_id": self.group.id, "nspanel_id": self.panel.id, "relay_num": "1"}) + + self.assertEqual(RelayGroupBinding.objects.count(), 1) + self.assertManagerNotReloaded() + + def test_add_relay_with_unknown_group_or_panel_is_rejected(self): + for form in ({"relay_group_id": 999, "nspanel_id": self.panel.id, "relay_num": "1"}, {"relay_group_id": self.group.id, "nspanel_id": 999, "relay_num": "1"}): + with self.subTest(form=form): + self.assertRejected(self.client.post(reverse("add_nspanel_relay_to_group"), form)) + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertManagerNotReloaded() + + def test_remove_relay_from_group(self): + binding = self.bind(self.panel, 1) + kept = self.bind(self.other_panel, 1) + + self.client.get(reverse("delete_relay_group_binding", kwargs={"relay_binding_id": binding.id})) + + self.assertEqual(list(RelayGroupBinding.objects.all()), [kept]) + self.assertManagerReloaded(times=1) + + def test_delete_group_removes_its_bindings(self): + self.bind(self.panel, 1) + + self.client.get(reverse("delete_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertFalse(RelayGroup.objects.exists()) + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertManagerReloaded(times=1) + + def test_delete_unknown_group_is_rejected(self): + self.assertRejected(self.client.get(reverse("delete_relay_group", kwargs={"relay_group_id": 999}))) + self.assertManagerNotReloaded() + + def test_deleting_a_panel_removes_its_bindings(self): + self.bind(self.panel, 1) + + NSPanel.objects.filter(id=self.panel.id).delete() + + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertTrue(RelayGroup.objects.exists()) + + def test_relay_groups_page_lists_groups_and_panels(self): + self.bind(self.panel, 2) + + response = self.client.get(reverse("relay_groups")) + + self.assertContains(response, "Hall lights") + self.assertContains(response, "Hall panel") + + +class RelayGroupHTMXTests(RelayGroupTestCase): + def test_create_and_rename(self): + response = self.client.post(reverse("htmx_save_relay_group"), {"name": "Porch"}) + + self.assertEqual(response["HX-Refresh"], "true") + porch = RelayGroup.objects.get(friendly_name="Porch") + + self.client.post(reverse("htmx_save_relay_group"), {"relay_group_id": porch.id, "name": "Deck"}) + + porch.refresh_from_db() + self.assertEqual(porch.friendly_name, "Deck") + + def test_add_and_remove_relay(self): + response = self.client.post(reverse("htmx_add_relay_to_relay_group", kwargs={"relay_group_id": self.group.id}), {"nspanel_id": self.panel.id, "relay_selection": "1"}) + + self.assertEqual(response["HX-Refresh"], "true") + binding = RelayGroupBinding.objects.get() + self.assertManagerReloaded(times=1) + + self.client.post(reverse("htmx_add_relay_to_relay_group", kwargs={"relay_group_id": self.group.id}), {"nspanel_id": self.panel.id, "relay_selection": "1"}) + self.assertEqual(RelayGroupBinding.objects.count(), 1) + + self.client.delete(reverse("htmx_remove_relay_from_relay_group", kwargs={"relay_binding_id": binding.id})) + + self.assertFalse(RelayGroupBinding.objects.exists()) + self.assertManagerReloaded(times=2) + + def test_add_relay_modal_only_offers_accepted_panels(self): + self.make_panel(self.room, "Pending panel", mac="AA:BB:CC:DD:EE:02") + + response = self.client.get(reverse("htmx_modal_add_relay_to_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertContains(response, "Hall panel") + self.assertNotContains(response, "Pending panel") + + def test_delete_group(self): + self.bind(self.panel, 1) + + self.client.delete(reverse("htmx_delete_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertFalse(RelayGroup.objects.exists()) + self.assertFalse(RelayGroupBinding.objects.exists()) + + @expectedFailure + def test_save_and_delete_group_reload_manager(self): + # KNOWN BUG: unlike the form views, the htmx relay_group_save() and relay_group_delete() + # never call send_mqttmanager_reload_command(), so MQTTManager keeps driving the relays + # of a deleted group until its next reload. + self.client.post(reverse("htmx_save_relay_group"), {"name": "Porch"}) + self.client.delete(reverse("htmx_delete_relay_group", kwargs={"relay_group_id": self.group.id})) + + self.assertManagerReloaded(times=2) + + +class RelayGroupRESTTests(RelayGroupTestCase): + def test_list_relay_groups(self): + self.bind(self.panel, 1) + self.bind(self.other_panel, 2) + + response = self.client.get(reverse("rest_relay_groups")) + + self.assertEqual(response.status_code, 200) + group = response.json()["relay_groups"][0] + self.assertEqual((group["relay_group_id"], group["name"]), (self.group.id, "Hall lights")) + self.assertCountEqual(group["relays"], [{"nspanel_id": self.panel.id, "relay_num": 1}, {"nspanel_id": self.other_panel.id, "relay_num": 2}]) + + def test_relay_groups_endpoint_is_read_only(self): + self.assertEqual(self.client.post(reverse("rest_relay_groups")).status_code, 405) diff --git a/docker/web/nspanelmanager/web/tests/test_settings.py b/docker/web/nspanelmanager/web/tests/test_settings.py new file mode 100644 index 00000000..c41aa6b5 --- /dev/null +++ b/docker/web/nspanelmanager/web/tests/test_settings.py @@ -0,0 +1,317 @@ +""" +Global settings: the settings page form, weather & time, theme, the first-run setup +wizard, the settings REST endpoints and web.settings_helper. +""" + +from unittest import expectedFailure + +from django.urls import reverse + +from web.models import NSPanelSettings, Settings +from web.settings_helper import ( + delete_nspanel_setting, + does_setting_exist, + get_nspanel_setting_with_default, + get_setting_with_default, + set_nspanel_setting_value, + set_setting_value, +) + +from .harness import NSPMTestCase + +# Every field the settings page form submits. +SETTINGS_FORM = { + "mqtt_server": "mqtt.local", + "mqtt_port": "1883", + "mqtt_username": "nspm", + "mqtt_password": "", + "home_assistant_address": "http://ha.local:8123", + "home_assistant_token": "", + "openhab_address": "", + "openhab_token": "", + "raise_to_100_light_level": "95", + "default_nspanel_type": "eu", + "color_temp_min": "2000", + "color_temp_max": "6000", + "reverse_color_temp": "False", + "min_button_push_time": "50", + "button_long_press_time": "300", + "special_mode_trigger_time": "300", + "special_mode_release_time": "5000", + "screensaver_activation_timeout": "30000", + "screen_dim_level": "100", + "screensaver_dim_level": "0", + "screensaver_mode": "with_background", + "show_screensaver_inside_temperature": "True", + "show_screensaver_outside_temperature": "True", + "turn_on_behavior": "color_temp", + "max_live_log_messages": "10", + "max_log_buffer_size": "10", + "mqttmanager_log_level": "info", + "manager_address": "192.168.1.10", + "manager_port": "8000", + "optimistic_mode": "optimistic", + "all_rooms_status_backoff_time": "250", + "room_status_backoff_time": "250", + "light_turn_on_brightness": "50", +} + + +def setting(name): + return get_setting_with_default(name) + + +class SettingsPageTests(NSPMTestCase): + def save(self, **overrides): + return self.client.post(reverse("save_settings"), SETTINGS_FORM | overrides) + + def test_save_settings(self): + response = self.save() + + self.assertRedirects(response, reverse("settings"), fetch_redirect_response=False) + for name in ("mqtt_server", "mqtt_port", "color_temp_min", "screensaver_mode", "manager_address", "light_turn_on_brightness"): + with self.subTest(name=name): + self.assertEqual(setting(name), SETTINGS_FORM[name]) + self.assertEqual(setting("optimistic_mode"), "True") + self.assertManagerReloaded(times=1) + + def test_trailing_slash_is_stripped_from_addresses(self): + self.save(home_assistant_address="http://ha.local:8123/", openhab_address="http://openhab.local/") + + self.assertEqual(setting("home_assistant_address"), "http://ha.local:8123") + self.assertEqual(setting("openhab_address"), "http://openhab.local") + + def test_blank_secret_fields_keep_the_stored_secret(self): + # The form never echoes secrets back, so a blank field means "unchanged". + set_setting_value("mqtt_password", "hunter2") + set_setting_value("home_assistant_token", "ha-token") + set_setting_value("openhab_token", "oh-token") + + self.save() + + self.assertEqual((setting("mqtt_password"), setting("home_assistant_token"), setting("openhab_token")), ("hunter2", "ha-token", "oh-token")) + + def test_new_secrets_replace_the_stored_ones(self): + set_setting_value("mqtt_password", "hunter2") + + self.save(mqtt_password="correct horse", home_assistant_token="new-token") + + self.assertEqual((setting("mqtt_password"), setting("home_assistant_token")), ("correct horse", "new-token")) + + def test_clear_checkboxes_remove_secrets(self): + set_setting_value("mqtt_password", "hunter2") + set_setting_value("openhab_token", "oh-token") + + self.save(clear_mqtt_password="on", clear_home_assistant_token="on", clear_openhab_token="on", mqtt_password="ignored") + + self.assertEqual((setting("mqtt_password"), setting("home_assistant_token"), setting("openhab_token")), ("", "", "")) + + def test_optional_fields_fall_back_to_defaults(self): + form = {k: v for k, v in SETTINGS_FORM.items() if k not in ("show_screensaver_inside_temperature", "turn_on_behavior", "optimistic_mode")} + set_setting_value("optimistic_mode", "False") + + self.client.post(reverse("save_settings"), form) + + self.assertEqual(setting("show_screensaver_inside_temperature"), "True") + self.assertEqual(setting("turn_on_behavior"), "color_temp") + self.assertEqual(setting("optimistic_mode"), "False") # left alone when not submitted + + def test_missing_required_field_is_rejected(self): + form = {k: v for k, v in SETTINGS_FORM.items() if k != "manager_port"} + + self.assertRejected(self.client.post(reverse("save_settings"), form)) + self.assertManagerNotReloaded() + + def test_settings_page_shows_saved_values_but_not_secrets(self): + self.save(mqtt_password="hunter2", home_assistant_token="ha-secret-token") + + response = self.client.get(reverse("settings")) + + self.assertContains(response, "mqtt.local") + self.assertNotContains(response, "hunter2") + self.assertNotContains(response, "ha-secret-token") + + +WEATHER_FORM = { + "location_latitude": "-36.85", + "location_longitude": "174.76", + "wind_speed_format": "kmh", + "precipitation_format": "mm", + "outside_temp_provider": "home_assistant", + "outside_temp_sensor": "sensor.outside", + "weather_update_interval": "10", + "date_format": "%d/%m/%Y", + "clock_us_style": "False", + "use_fahrenheit": "True", +} + + +class WeatherAndThemeTests(NSPMTestCase): + def test_save_weather_and_time(self): + response = self.client.post(reverse("weather_and_time"), WEATHER_FORM) + + self.assertRedirects(response, reverse("weather_and_time"), fetch_redirect_response=False) + self.assertEqual(setting("location_latitude"), "-36.85") + self.assertEqual(setting("weather_wind_speed_format"), "kmh") + self.assertEqual(setting("outside_temp_sensor_entity_id"), "sensor.outside") + self.assertEqual(setting("use_fahrenheit"), "True") + self.assertManagerReloaded(times=1) + + def test_fahrenheit_is_shown_on_the_dashboard(self): + self.client.post(reverse("weather_and_time"), WEATHER_FORM) + room = self.make_room() + self.make_panel(room, accepted=True) + + self.assertContains(self.client.get(reverse("htmx_partial_index_nspanels_section")), "°F") + + def test_weather_form_missing_field_is_rejected(self): + form = {k: v for k, v in WEATHER_FORM.items() if k != "use_fahrenheit"} + + self.assertRejected(self.client.post(reverse("weather_and_time"), form)) + self.assertManagerNotReloaded() + + def test_interface_theme(self): + response = self.client.post(reverse("htmx_interface_theme"), {"theme-dropdown": "dracula"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(setting("theme"), "dracula") + self.assertContains(self.client.get(reverse("index")), "dracula") + + +class InitialSetupTests(NSPMTestCase): + """The first-run wizard: each step saves its settings and returns the next step.""" + + def test_wizard_saves_each_step(self): + steps = [ + ("htmx_initial_setup_manager_settings", {"manager_address": "192.168.1.10", "manager_port": "8000"}, "mqtt_server"), + ("htmx_initial_setup_mqtt_settings", {"mqtt_server": "mqtt.local", "mqtt_port": "1883", "mqtt_username": "u", "mqtt_password": "p"}, "home_assistant_address"), + ("htmx_initial_setup_home_assistant_settings", {"home_assistant_address": "http://ha.local", "home_assistant_token": "t"}, "openhab_address"), + ("htmx_initial_setup_openhab_settings", {"openhab_address": "", "openhab_token": ""}, None), + ] + for url_name, form, next_step_field in steps: + with self.subTest(step=url_name): + response = self.client.post(reverse(url_name), form) + self.assertEqual(response.status_code, 200) + if next_step_field: + self.assertContains(response, f'name="{next_step_field}"') + for name, value in form.items(): + self.assertEqual(setting(name), value) + self.assertManagerReloaded(times=4) + + def test_step_only_changes_submitted_fields(self): + set_setting_value("mqtt_password", "keep-me") + + self.client.post(reverse("htmx_initial_setup_mqtt_settings"), {"mqtt_server": "mqtt.local"}) + + self.assertEqual(setting("mqtt_server"), "mqtt.local") + self.assertEqual(setting("mqtt_password"), "keep-me") + + +class SettingsRESTTests(NSPMTestCase): + def configure_secrets(self): + set_setting_value("mqtt_password", "hunter2") + set_setting_value("home_assistant_token", "ha-secret") + set_setting_value("openhab_token", "") + + def test_settings_endpoint_masks_secrets(self): + self.configure_secrets() + set_setting_value("mqtt_server", "mqtt.local") + + response = self.client.get(reverse("rest_settings")) + + self.assertEqual(response.status_code, 200) + settings = response.json()["settings"] + self.assertEqual(settings["mqtt_server"], "mqtt.local") + for secret in ("mqtt_password", "home_assistant_token", "openhab_token"): + self.assertNotIn(secret, settings) + self.assertEqual((settings["mqtt_password_set"], settings["home_assistant_token_set"], settings["openhab_token_set"]), (True, True, False)) + self.assertNotIn("hunter2", response.content.decode()) + + def test_settings_endpoint_is_read_only(self): + self.assertEqual(self.client.post(reverse("rest_settings")).status_code, 405) + + @expectedFailure + def test_settings_endpoint_works_before_all_secrets_are_set(self): + # KNOWN BUG: settings_get() does `del settings["openhab_token"]` (and the same for the + # other secrets) without checking the key exists, so it returns 500 until every + # secret setting has been saved at least once, e.g. if OpenHAB was never configured. + set_setting_value("mqtt_password", "hunter2") + + self.assertEqual(self.client.get(reverse("rest_settings")).status_code, 200) + + def test_mqttmanager_can_read_a_setting(self): + set_setting_value("color_temp_min", "2000") + + response = self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": "color_temp_min"})) + + self.assertEqual(response.json(), {"status": "ok", "settings": {"color_temp_min": "2000"}}) + + def test_mqttmanager_can_read_several_settings(self): + set_setting_value("color_temp_min", "2000") + set_setting_value("color_temp_max", "6000") + + response = self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["color_temp_min", "color_temp_max"]}) + + self.assertEqual(response.json()["settings"], {"color_temp_min": "2000", "color_temp_max": "6000"}) + + def test_mqttmanager_settings_endpoints_check_method(self): + self.assertEqual(self.client.post(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": "x"})).status_code, 405) + self.assertEqual(self.client.get(reverse("rest_mqttmanager_settings_post")).status_code, 405) + + def test_mqttmanager_settings_endpoint_refuses_banned_keys_as_listed(self): + for key in ("MQTT_PASSWORD", "HOME_ASSISTANT_TOKEN"): + with self.subTest(key=key): + self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) + self.assertEqual(self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["color_temp_min", "OPENHAB_TOKEN"]}).status_code, 403) + + @expectedFailure + def test_mqttmanager_settings_endpoint_refuses_secrets(self): + # KNOWN BUG: banned_setting_keys in rest.py is upper case ("HOME_ASSISTANT_TOKEN") but + # settings are stored lower case ("home_assistant_token") and the check is + # case-sensitive, so the secrets it is meant to protect are returned. + self.configure_secrets() + + for key in ("mqtt_password", "home_assistant_token", "openhab_token"): + with self.subTest(key=key): + self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) + self.assertEqual(self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["mqtt_password"]}).status_code, 403) + + +class SettingsHelperTests(NSPMTestCase): + def test_global_settings(self): + self.assertEqual(get_setting_with_default("not_set"), "") + self.assertFalse(does_setting_exist("not_set")) + + set_setting_value("colour", "blue") + set_setting_value("colour", "green") # update, not duplicate + + self.assertEqual(get_setting_with_default("colour"), "green") + self.assertTrue(does_setting_exist("colour")) + self.assertEqual(Settings.objects.filter(name="colour").count(), 1) + + def test_values_are_returned_as_strings(self): + set_setting_value("level", 42) + + self.assertEqual(get_setting_with_default("level"), "42") + + def test_panel_settings(self): + room = self.make_room() + panel = self.make_panel(room) + other = self.make_panel(room, mac="AA:BB:CC:DD:EE:01") + + self.assertEqual(get_nspanel_setting_with_default(panel.id, "dim", "default"), "default") + set_nspanel_setting_value(panel.id, "dim", "50") + set_nspanel_setting_value(panel.id, "dim", "60") + + self.assertEqual(get_nspanel_setting_with_default(panel.id, "dim", "default"), "60") + self.assertEqual(get_nspanel_setting_with_default(other.id, "dim", "default"), "default") + self.assertEqual(NSPanelSettings.objects.filter(nspanel=panel, name="dim").count(), 1) + + delete_nspanel_setting(panel.id, "dim") + self.assertEqual(get_nspanel_setting_with_default(panel.id, "dim", "default"), "default") + + def test_panel_settings_for_unknown_panel(self): + self.assertEqual(get_nspanel_setting_with_default(999, "dim", "default"), "default") + set_nspanel_setting_value(999, "dim", "50") # silently ignored + delete_nspanel_setting(999, "dim") + self.assertFalse(NSPanelSettings.objects.exists()) From 024e4df5446de57b1671b58a3bbd42634b38d71b Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 07:51:22 -0700 Subject: [PATCH 03/17] Make the MQTTManager settings endpoint's banned-key check case-insensitive banned_setting_keys lists secrets in upper case ("HOME_ASSISTANT_TOKEN"), but settings are stored in lower case ("home_assistant_token") and the comparison was case-sensitive, so /rest/mqttmanager/settings returned the MQTT password and Home Assistant/OpenHAB tokens it was meant to withhold. Nothing in the repo calls this endpoint (MQTTManager reads settings from the database directly), so refusing these keys breaks no caller. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 2 +- docker/web/nspanelmanager/web/tests/test_settings.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index ad756308..c074aa8a 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -103,7 +103,7 @@ def test_openhab(request): def mqttmanager_get_setting(request, setting_key): - if setting_key in banned_setting_keys: + if setting_key.upper() in banned_setting_keys: return JsonResponse({"status": "error"}, status=403) # Return error forbidden try: diff --git a/docker/web/nspanelmanager/web/tests/test_settings.py b/docker/web/nspanelmanager/web/tests/test_settings.py index c41aa6b5..af1ecf31 100644 --- a/docker/web/nspanelmanager/web/tests/test_settings.py +++ b/docker/web/nspanelmanager/web/tests/test_settings.py @@ -264,11 +264,9 @@ def test_mqttmanager_settings_endpoint_refuses_banned_keys_as_listed(self): self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) self.assertEqual(self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["color_temp_min", "OPENHAB_TOKEN"]}).status_code, 403) - @expectedFailure def test_mqttmanager_settings_endpoint_refuses_secrets(self): - # KNOWN BUG: banned_setting_keys in rest.py is upper case ("HOME_ASSISTANT_TOKEN") but - # settings are stored lower case ("home_assistant_token") and the check is - # case-sensitive, so the secrets it is meant to protect are returned. + # banned_setting_keys is upper case but settings are stored lower case, so the check + # must be case-insensitive or the secrets it protects are returned. self.configure_secrets() for key in ("mqtt_password", "home_assistant_token", "openhab_token"): From 23e8d28eec1c2427952c730702377544525723e2 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:23:48 -0700 Subject: [PATCH 04/17] Tell MQTTManager to reload after deletes and edits that skipped it These views changed config MQTTManager reads from the database but never called send_mqttmanager_reload_command(), so panels kept the old config until something else triggered a reload: - DELETE /rest/scenes/ - PUT /rest/entities_pages/ (page resize) - htmx/nspanels//delete - htmx relay group save and delete Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/htmx.py | 3 +++ docker/web/nspanelmanager/web/rest.py | 2 ++ docker/web/nspanelmanager/web/tests/test_layout.py | 7 ------- docker/web/nspanelmanager/web/tests/test_panels.py | 3 --- docker/web/nspanelmanager/web/tests/test_relay_groups.py | 6 ------ 5 files changed, 5 insertions(+), 16 deletions(-) diff --git a/docker/web/nspanelmanager/web/htmx.py b/docker/web/nspanelmanager/web/htmx.py index 73d194f6..12b86625 100644 --- a/docker/web/nspanelmanager/web/htmx.py +++ b/docker/web/nspanelmanager/web/htmx.py @@ -105,6 +105,7 @@ def nspanel_delete(request, nspanel_id): if request.method == "DELETE": nspanel = NSPanel.objects.get(id=nspanel_id) nspanel.delete() + send_mqttmanager_reload_command() response = HttpResponse("", status=200) response["HX-Redirect"] = "/" return response @@ -267,6 +268,7 @@ def relay_group_save(request): rg = RelayGroup() rg.friendly_name = request.POST["name"] rg.save() + send_mqttmanager_reload_command() response = HttpResponse() response["HX-Refresh"] = "true" @@ -278,6 +280,7 @@ def relay_group_delete(request, relay_group_id): if request.method == "DELETE": rg = RelayGroup.objects.get(id=relay_group_id) rg.delete() + send_mqttmanager_reload_command() response = HttpResponse() response["HX-Refresh"] = "true" diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index c074aa8a..8618de04 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -368,6 +368,7 @@ def room_entities_page(request, page_id): db_page.page_type = data.get("number_of_entities", db_page.page_type) db_page.display_order = data.get("display_order", db_page.display_order) db_page.save() + send_mqttmanager_reload_command() return JsonResponse({"status": "ok"}, status=200) except Exception as ex: logging.exception(ex) @@ -732,6 +733,7 @@ def get_scene(request, scene_id): elif request.method == "DELETE": scene = Scene.objects.get(id=scene_id) scene.delete() + send_mqttmanager_reload_command() return JsonResponse({"status": "success"}) except Exception as ex: logging.exception(ex) diff --git a/docker/web/nspanelmanager/web/tests/test_layout.py b/docker/web/nspanelmanager/web/tests/test_layout.py index 4b17a7e8..c7011542 100644 --- a/docker/web/nspanelmanager/web/tests/test_layout.py +++ b/docker/web/nspanelmanager/web/tests/test_layout.py @@ -153,11 +153,7 @@ def test_delete_scene(self): self.assertEqual(response.status_code, 200) self.assertFalse(Scene.objects.exists()) - @expectedFailure def test_delete_scene_reloads_manager(self): - # KNOWN BUG: DELETE /rest/scenes/ removes the row but never calls - # send_mqttmanager_reload_command(), so the panel keeps showing the scene until - # something else triggers a reload. scene = self.make_scene(self.room) self.client.delete(reverse("rest_get_scene", kwargs={"scene_id": scene.id})) @@ -263,10 +259,7 @@ def test_resize_page(self): page.refresh_from_db() self.assertEqual(page.page_type, 12) - @expectedFailure def test_resize_page_reloads_manager(self): - # KNOWN BUG: PUT /rest/entities_pages/ saves the new size but never calls - # send_mqttmanager_reload_command(), so panels keep the old layout. page = self.entities_page(self.room) self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 12}) diff --git a/docker/web/nspanelmanager/web/tests/test_panels.py b/docker/web/nspanelmanager/web/tests/test_panels.py index 8cf1e44e..36ec549f 100644 --- a/docker/web/nspanelmanager/web/tests/test_panels.py +++ b/docker/web/nspanelmanager/web/tests/test_panels.py @@ -66,10 +66,7 @@ def test_delete_panel(self): self.assertFalse(NSPanel.objects.exists()) self.assertEqual(get_nspanel_setting_with_default(self.panel.id, "screen_dim_level", "gone"), "gone") - @expectedFailure def test_delete_panel_reloads_manager(self): - # KNOWN BUG: nspanel_delete() never calls send_mqttmanager_reload_command() (deny and - # unblock both do), so MQTTManager keeps the deleted panel until its next reload. self.client.delete(reverse("htmx_nspanel_delete", kwargs={"nspanel_id": self.panel.id})) self.assertManagerReloaded(times=1) diff --git a/docker/web/nspanelmanager/web/tests/test_relay_groups.py b/docker/web/nspanelmanager/web/tests/test_relay_groups.py index dc975ad8..eb74882a 100644 --- a/docker/web/nspanelmanager/web/tests/test_relay_groups.py +++ b/docker/web/nspanelmanager/web/tests/test_relay_groups.py @@ -4,8 +4,6 @@ MQTTManager reads. """ -from unittest import expectedFailure - from django.urls import reverse from web.models import NSPanel, RelayGroup, RelayGroupBinding @@ -145,11 +143,7 @@ def test_delete_group(self): self.assertFalse(RelayGroup.objects.exists()) self.assertFalse(RelayGroupBinding.objects.exists()) - @expectedFailure def test_save_and_delete_group_reload_manager(self): - # KNOWN BUG: unlike the form views, the htmx relay_group_save() and relay_group_delete() - # never call send_mqttmanager_reload_command(), so MQTTManager keeps driving the relays - # of a deleted group until its next reload. self.client.post(reverse("htmx_save_relay_group"), {"name": "Porch"}) self.client.delete(reverse("htmx_delete_relay_group", kwargs={"relay_group_id": self.group.id})) From 833a65af285e3c07d09d8b37d08dd71e4b90bcf7 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:24:39 -0700 Subject: [PATCH 05/17] Make /rest/rooms create and delete match the UI's room views - POST /rest/rooms now creates the room's entities and scenes pages, as save_new_room does, instead of leaving them to the next restart. The per-room part of create_entity_pages_for_all_rooms() is split out as create_entity_pages_for_room() so both paths share it. - The name must be 1-30 characters (Room.friendly_name's max_length, which SQLite does not enforce). - DELETE /rest/rooms/ moves the room's panels to another room, as delete_room does, and tells MQTTManager to reload. Creating a room now reloads too. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/apps.py | 46 +++++++++++-------- docker/web/nspanelmanager/web/rest.py | 20 ++++++-- .../nspanelmanager/web/tests/test_rooms.py | 20 ++++---- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/docker/web/nspanelmanager/web/apps.py b/docker/web/nspanelmanager/web/apps.py index fa01240b..6816c0bb 100755 --- a/docker/web/nspanelmanager/web/apps.py +++ b/docker/web/nspanelmanager/web/apps.py @@ -74,28 +74,34 @@ def send_mqttmanager_reload_command(): def create_entity_pages_for_all_rooms(): - from .models import Room, RoomEntitiesPage + from .models import Room for room in Room.objects.all(): - # Check if an existing scenes page exists, if it does not, create one - existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True) - if not existing_pages.exists(): - page = RoomEntitiesPage() - page.room = room - page.display_order = 0 - page.page_type = 4 - page.is_scenes_page = True - page.save() - - # Check if an existing entities page exists, if it doesn not, create one. - existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False) - if not existing_pages.exists(): - page = RoomEntitiesPage() - page.room = room - page.display_order = 0 - page.page_type = 4 - page.is_scenes_page = False - page.save() + create_entity_pages_for_room(room) + + +def create_entity_pages_for_room(room): + from .models import RoomEntitiesPage + + # Check if an existing scenes page exists, if it does not, create one + existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=True) + if not existing_pages.exists(): + page = RoomEntitiesPage() + page.room = room + page.display_order = 0 + page.page_type = 4 + page.is_scenes_page = True + page.save() + + # Check if an existing entities page exists, if it doesn not, create one. + existing_pages = RoomEntitiesPage.objects.filter(room=room, is_scenes_page=False) + if not existing_pages.exists(): + page = RoomEntitiesPage() + page.room = room + page.display_order = 0 + page.page_type = 4 + page.is_scenes_page = False + page.save() def create_global_scenes_page(): diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index 8618de04..c4a2aea4 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -7,6 +7,7 @@ import requests from django.core.files.storage import FileSystemStorage +from django.db import transaction from django.http import HttpResponse, JsonResponse from django.views.decorators.csrf import csrf_exempt @@ -18,7 +19,7 @@ set_setting_value, ) -from .apps import send_mqttmanager_reload_command +from .apps import create_entity_pages_for_room, send_mqttmanager_reload_command from .models import Entity, LightState, NSPanel, RelayGroup, Room, RoomEntitiesPage, Scene, Settings ######################## @@ -524,7 +525,12 @@ def room_delete(request, room_id): if request.method == "DELETE": try: room = Room.objects.get(id=room_id) + # Panels are not deleted with their room. As in the UI's delete_room view, move them to another room. + new_room = Room.objects.exclude(id=room.id).first() + if new_room: + NSPanel.objects.filter(room=room).update(room=new_room) room.delete() + send_mqttmanager_reload_command() return JsonResponse({"status": "ok", "room_id": room_id}, status=200) except Exception as ex: logging.exception(ex) @@ -575,9 +581,15 @@ def room_create(request): if request.method == "POST": try: data = json.loads(request.body) - new_room = Room() - new_room.friendly_name = data["name"] - new_room.save() + max_length = Room._meta.get_field("friendly_name").max_length + if not data["name"] or len(data["name"]) > max_length: + return JsonResponse({"status": "error", "message": f"Room name must be 1 to {max_length} characters"}, status=400) + with transaction.atomic(): + new_room = Room() + new_room.friendly_name = data["name"] + new_room.save() + create_entity_pages_for_room(new_room) + send_mqttmanager_reload_command() return JsonResponse({"status": "ok", "room_id": new_room.id}, status=200) except Exception as ex: logging.exception(ex) diff --git a/docker/web/nspanelmanager/web/tests/test_rooms.py b/docker/web/nspanelmanager/web/tests/test_rooms.py index 8bcb6326..97324104 100644 --- a/docker/web/nspanelmanager/web/tests/test_rooms.py +++ b/docker/web/nspanelmanager/web/tests/test_rooms.py @@ -1,5 +1,4 @@ import json -from unittest import expectedFailure from django.urls import reverse @@ -144,10 +143,7 @@ def test_get_single_room(self): self.assertEqual(response.json()["rooms"], [{"id": lounge.id, "name": "Lounge"}]) - @expectedFailure def test_delete_room_reloads_manager(self): - # KNOWN GAP: unlike the UI's delete_room view, DELETE /rest/rooms/ never calls - # send_mqttmanager_reload_command(), and does not move the room's panels elsewhere. room = self.make_room("Office") self.make_room("Lounge") @@ -155,19 +151,23 @@ def test_delete_room_reloads_manager(self): self.assertManagerReloaded(times=1) - @expectedFailure + def test_delete_room_rehomes_panels(self): + office = self.make_room("Office") + lounge = self.make_room("Lounge") + panel = self.make_panel(office) + + self.client.delete(reverse("rest_room_delete", kwargs={"room_id": office.id})) + + panel.refresh_from_db() + self.assertEqual(panel.room, lounge) + def test_created_room_has_entity_pages(self): - # KNOWN GAP: unlike the UI, the REST API creates a room with no entities/scenes - # pages. They only appear after the next restart (create_entity_pages_for_all_rooms). response = self.post_json(reverse("rest_rooms_create"), {"name": "Office"}) room_id = response.json()["room_id"] self.assertEqual(RoomEntitiesPage.objects.filter(room_id=room_id).count(), 2) - @expectedFailure def test_create_room_rejects_overlong_name(self): - # KNOWN GAP: Room.friendly_name is max_length=30 but nothing validates it and - # SQLite does not enforce VARCHAR lengths, so any length is stored. response = self.post_json(reverse("rest_rooms_create"), {"name": "x" * 100}) self.assertRejected(response) From c9cf90dd126e1dcfe6ddd31116ef9af91cc9cbfa Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:25:06 -0700 Subject: [PATCH 06/17] Apply PUT /rest/rooms//entities/order atomically Each entry was saved as it was read, so an unknown id part-way through the list returned an error but left the entries before it already moved, a half-applied layout. Run the updates in one transaction so a failed request changes nothing. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 22 ++++++++++--------- .../nspanelmanager/web/tests/test_layout.py | 5 +---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index c4a2aea4..8cef5248 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -278,16 +278,18 @@ def put_room_entities_order(request, room_id): if request.method == "PUT": try: data = json.loads(request.body) - for entity in data["entities"]: - db_entity = Entity.objects.get(id=entity["id"]) - db_entity.room_view_position = entity["room_view_position"] - db_entity.entities_page_id = entity["entities_page_id"] - db_entity.save() - for scene in data["scenes"]: - db_scene = Scene.objects.get(id=scene["id"]) - db_scene.room_view_position = scene["room_view_position"] - db_scene.entities_page_id = scene["entities_page_id"] - db_scene.save() + # All or nothing: a bad entry must not leave the entries before it already moved. + with transaction.atomic(): + for entity in data["entities"]: + db_entity = Entity.objects.get(id=entity["id"]) + db_entity.room_view_position = entity["room_view_position"] + db_entity.entities_page_id = entity["entities_page_id"] + db_entity.save() + for scene in data["scenes"]: + db_scene = Scene.objects.get(id=scene["id"]) + db_scene.room_view_position = scene["room_view_position"] + db_scene.entities_page_id = scene["entities_page_id"] + db_scene.save() send_mqttmanager_reload_command() return JsonResponse({"status": "ok"}, status=200) except Exception as ex: diff --git a/docker/web/nspanelmanager/web/tests/test_layout.py b/docker/web/nspanelmanager/web/tests/test_layout.py index c7011542..bd9a4b9f 100644 --- a/docker/web/nspanelmanager/web/tests/test_layout.py +++ b/docker/web/nspanelmanager/web/tests/test_layout.py @@ -102,17 +102,14 @@ def test_unknown_entity_is_rejected(self): def test_other_methods_are_rejected(self): self.assertEqual(self.client.get(reverse("rest_put_room_entities_order", kwargs={"room_id": self.room.id})).status_code, 405) - @expectedFailure def test_failed_reorder_changes_nothing(self): - # KNOWN GAP: the reorder is not atomic. Entities before the bad entry in the list are - # already saved when it fails, so the DB is left half-reordered, and MQTTManager is - # not told to reload. light = self.make_light(self.room, slot=0) response = self.save_order([position(light, self.page, 3), {"id": 999, "entities_page_id": self.page.id, "room_view_position": 0}]) self.assertRejected(response) self.assertAt(light, self.page, 0) + self.assertManagerNotReloaded() @expectedFailure def test_move_to_slot_outside_page_is_rejected(self): From 3bb09722388152d679d4793b16ea63ca1e804d7a Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:26:00 -0700 Subject: [PATCH 07/17] Validate where entities and scenes are placed on a page The /rest/entities/* endpoints stored room_view_position and entities_page_id as given, so the UI (or any other caller) could: - put two entities in the same slot; - put one in a slot past the end of its page, where the panel never shows it; - put an entity on another room's page. get_placement_error() checks all three and the endpoints return 400 with a message. Only a new or changed placement is checked, so an entity already saved in a bad slot can still be edited in place. PUT /rest/rooms//entities/order now also rejects slots past the end of a page. It does not check for occupied slots, because a swap moves two entities through each other's slot in one request. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 87 +++++++++++++++---- .../nspanelmanager/web/tests/test_entities.py | 17 ++-- .../nspanelmanager/web/tests/test_layout.py | 2 - 3 files changed, 82 insertions(+), 24 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index 8cef5248..fa0054ca 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -278,6 +278,10 @@ def put_room_entities_order(request, room_id): if request.method == "PUT": try: data = json.loads(request.body) + for item in data["entities"] + data["scenes"]: + page = RoomEntitiesPage.objects.get(id=item["entities_page_id"]) + if not 0 <= item["room_view_position"] < page.page_type: + return JsonResponse({"status": "error", "message": f"room_view_position must be between 0 and {page.page_type - 1} on page {page.id}."}, status=400) # All or nothing: a bad entry must not leave the entries before it already moved. with transaction.atomic(): for entity in data["entities"]: @@ -756,6 +760,24 @@ def get_scene(request, scene_id): ### Generic Entity section ### +def get_placement_error(obj, room, entities_page, room_view_position): + """Return why obj (an Entity or Scene) cannot go in that slot, or None if it can. + + Only a new or changed placement is checked, so entities saved before these checks + existed can still be edited without being moved. + """ + room_id = room.id if room else None + if obj.pk is not None and (obj.room_id, obj.entities_page_id, obj.room_view_position) == (room_id, entities_page.id, room_view_position): + return None + if entities_page.room_id != room_id: + return "The entities page belongs to a different room." + if not 0 <= room_view_position < entities_page.page_type: + return f"room_view_position must be between 0 and {entities_page.page_type - 1} on this page." + if type(obj).objects.filter(entities_page=entities_page, room_view_position=room_view_position).exclude(pk=obj.pk).exists(): + return "That position on the page is already taken." + return None + + def get_rest_entitiy_representation(entity_id): entity = Entity.objects.get(id=entity_id) return { @@ -849,10 +871,17 @@ def put_light_entity(request): new_light = Entity() new_light.entity_type = Entity.EntityType.LIGHT + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_light, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_light.friendly_name = data["friendly_name"] - new_light.room = Room.objects.get(id=int(data["room_id"])) - new_light.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_light.room_view_position = int(data["room_view_position"]) + new_light.room = room + new_light.entities_page = entities_page + new_light.room_view_position = room_view_position new_light.entity_data = entity_data new_light.save() @@ -907,10 +936,17 @@ def put_switch_entity(request): new_switch = Entity() new_switch.entity_type = Entity.EntityType.SWITCH + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_switch, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_switch.friendly_name = data["friendly_name"] - new_switch.room = Room.objects.get(id=int(data["room_id"])) - new_switch.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_switch.room_view_position = int(data["room_view_position"]) + new_switch.room = room + new_switch.entities_page = entities_page + new_switch.room_view_position = room_view_position new_switch.entity_data = entity_data new_switch.save() @@ -967,10 +1003,17 @@ def put_button_entity(request): new_button = Entity() new_button.entity_type = Entity.EntityType.BUTTON + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_button, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_button.friendly_name = data["friendly_name"] - new_button.room = Room.objects.get(id=int(data["room_id"])) - new_button.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_button.room_view_position = int(data["room_view_position"]) + new_button.room = room + new_button.entities_page = entities_page + new_button.room_view_position = room_view_position new_button.entity_data = entity_data new_button.save() @@ -1059,10 +1102,17 @@ def put_thermostat_entity(request): new_thermostat = Entity() new_thermostat.entity_type = Entity.EntityType.THERMOSTAT + room = Room.objects.get(id=int(data["room_id"])) + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_thermostat, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_thermostat.friendly_name = data["friendly_name"] - new_thermostat.room = Room.objects.get(id=int(data["room_id"])) - new_thermostat.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_thermostat.room_view_position = int(data["room_view_position"]) + new_thermostat.room = room + new_thermostat.entities_page = entities_page + new_thermostat.room_view_position = room_view_position new_thermostat.entity_data = entity_data new_thermostat.save() @@ -1240,10 +1290,17 @@ def put_scene_entity(request): else: new_scene = Scene() + room = Room.objects.get(id=int(data["room_id"])) if data["room_id"] else None + entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) + room_view_position = int(data["room_view_position"]) + placement_error = get_placement_error(new_scene, room, entities_page, room_view_position) + if placement_error: + return JsonResponse({"status": "error", "message": placement_error}, status=400) + new_scene.friendly_name = data["friendly_name"] - new_scene.room = Room.objects.get(id=int(data["room_id"])) if data["room_id"] else None - new_scene.entities_page = RoomEntitiesPage.objects.get(id=int(data["entities_page_id"])) - new_scene.room_view_position = int(data["room_view_position"]) + new_scene.room = room + new_scene.entities_page = entities_page + new_scene.room_view_position = room_view_position new_scene.scene_type = data.get("scene_type", "") new_scene.backend_name = data.get("backend_name", "") diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py index ec0ece24..6be76800 100644 --- a/docker/web/nspanelmanager/web/tests/test_entities.py +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -84,17 +84,13 @@ def test_edit_updates_in_place(self): def test_edit_unknown_id_is_rejected(self): self.assertCreatesNothing(self.save(self.payload(id=999))) - @expectedFailure def test_add_to_occupied_slot_is_rejected(self): - # KNOWN GAP: nothing checks the slot is free, so two entities end up in one slot. self.save(self.payload(slot=0)) self.assertRejected(self.save(self.payload(slot=0))) self.assertEqual(self.model.objects.count(), 1) - @expectedFailure def test_add_to_slot_outside_page_is_rejected(self): - # KNOWN GAP: room_view_position is not checked against the page size (4 here). self.assertCreatesNothing(self.save(self.payload(slot=7))) @@ -165,14 +161,21 @@ def test_unknown_controller_is_rejected(self): # treats an unknown controller as Home Assistant. self.assertCreatesNothing(self.save(self.payload(controller="zigbee"))) - @expectedFailure def test_page_from_another_room_is_rejected(self): - # KNOWN GAP: room_id and entities_page_id are not checked against each other, so an - # entity can belong to one room but be displayed on another room's page. other_page = self.entities_page(self.make_room("Lounge")) self.assertCreatesNothing(self.save(self.payload(entities_page_id=other_page.id))) + def test_entity_saved_before_placement_checks_can_still_be_edited(self): + # Only a changed placement is validated, so rows already in a bad slot stay editable. + light = self.make_light(self.room, slot=7) + + response = self.save(self.payload(slot=7, id=light.id, friendly_name="Renamed")) + + self.assertEqual(response.status_code, 200) + light.refresh_from_db() + self.assertEqual(light.friendly_name, "Renamed") + class SwitchTests(EntityRESTCommonTests, EntityRESTTestCase): url = "rest_entities_switches" diff --git a/docker/web/nspanelmanager/web/tests/test_layout.py b/docker/web/nspanelmanager/web/tests/test_layout.py index bd9a4b9f..2b932da9 100644 --- a/docker/web/nspanelmanager/web/tests/test_layout.py +++ b/docker/web/nspanelmanager/web/tests/test_layout.py @@ -111,9 +111,7 @@ def test_failed_reorder_changes_nothing(self): self.assertAt(light, self.page, 0) self.assertManagerNotReloaded() - @expectedFailure def test_move_to_slot_outside_page_is_rejected(self): - # KNOWN GAP: room_view_position is not checked against the page size. light = self.make_light(self.room, slot=0) self.assertRejected(self.save_order([position(light, self.page, 11)])) From bd6f3ed8d7198cb3428b7bf6e093f07f43216ad1 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:26:58 -0700 Subject: [PATCH 08/17] Reject entities and scenes MQTTManager cannot load The entity endpoints stored controller and scene_type as given. MQTTManager logs an error for an entity whose controller its class does not handle and skips a scene with an unknown scene_type, so these saved fine but never worked on a panel. Now rejected with 400: - a controller the entity type does not support (e.g. an OpenHAB button); - an unknown scene_type; - a Home Assistant or OpenHAB scene with an empty backend_name, which could never be activated. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 40 +++++++++++++++++++ .../nspanelmanager/web/tests/test_entities.py | 11 ++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index fa0054ca..0a10a775 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -760,6 +760,25 @@ def get_scene(request, scene_id): ### Generic Entity section ### +# The controllers MQTTManager's entity classes (docker/MQTTManager/include/{light,switch,button,thermostat}/) +# accept for each entity type. Anything else is logged as an error when it loads the entity. +ENTITY_CONTROLLERS = { + Entity.EntityType.LIGHT: ("home_assistant", "openhab"), + Entity.EntityType.SWITCH: ("home_assistant", "openhab"), + Entity.EntityType.BUTTON: ("home_assistant", "nspm"), + Entity.EntityType.THERMOSTAT: ("home_assistant", "openhab"), +} + +# The scene types MQTTManager's EntityManager loads. It ignores scenes of any other type. +SCENE_TYPES = ("home_assistant", "openhab", "nspm_scene") + + +def get_controller_error(entity_type, controller): + if controller not in ENTITY_CONTROLLERS[entity_type]: + return f"Unknown controller {controller!r}. Expected one of: {', '.join(ENTITY_CONTROLLERS[entity_type])}." + return None + + def get_placement_error(obj, room, entities_page, room_view_position): """Return why obj (an Entity or Scene) cannot go in that slot, or None if it can. @@ -852,6 +871,10 @@ def put_light_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.LIGHT, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "controller": data["controller"], "home_assistant_name": data.get("home_assistant_name", ""), @@ -925,6 +948,10 @@ def put_switch_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.SWITCH, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "openhab_item_switch": data.get("openhab_item_switch", ""), "home_assistant_name": data.get("home_assistant_name", ""), @@ -991,6 +1018,10 @@ def put_button_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.BUTTON, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "mqtt_topic": data.get("mqtt_topic", ""), "mqtt_payload": data.get("mqtt_payload", ""), @@ -1078,6 +1109,10 @@ def put_thermostat_entity(request): if field not in item: return JsonResponse({"status": "error", "message": f"Missing required field in {mode}. Missing field: {field}"}, status=400) + controller_error = get_controller_error(Entity.EntityType.THERMOSTAT, data["controller"]) + if controller_error: + return JsonResponse({"status": "error", "message": controller_error}, status=400) + entity_data = { "controller": data.get("controller", ""), "fan_modes": data.get("fan_modes", []), @@ -1285,6 +1320,11 @@ def put_scene_entity(request): if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + if data["scene_type"] not in SCENE_TYPES: + return JsonResponse({"status": "error", "message": f"Unknown scene_type {data['scene_type']!r}. Expected one of: {', '.join(SCENE_TYPES)}."}, status=400) + if data["scene_type"] != "nspm_scene" and not data["backend_name"]: + return JsonResponse({"status": "error", "message": "backend_name is required for Home Assistant and OpenHAB scenes."}, status=400) + if "id" in data and data["id"]: new_scene = Scene.objects.get(id=int(data["id"])) else: diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py index 6be76800..f853dcf8 100644 --- a/docker/web/nspanelmanager/web/tests/test_entities.py +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -155,10 +155,7 @@ def test_other_methods_are_rejected(self): self.assertRejected(self.client.post(reverse(self.url), {})) self.assertFalse(Entity.objects.exists()) - @expectedFailure def test_unknown_controller_is_rejected(self): - # KNOWN GAP: controller is stored as given. MQTTManager logs an error and silently - # treats an unknown controller as Home Assistant. self.assertCreatesNothing(self.save(self.payload(controller="zigbee"))) def test_page_from_another_room_is_rejected(self): @@ -237,6 +234,10 @@ def test_add_home_assistant_button(self): self.assertEqual(button.entity_data["home_assistant_name"], "input_button.doorbell") self.assertLoadableByManager(button) + def test_openhab_controller_is_rejected(self): + # MQTTManager only has Home Assistant and MQTT ("nspm") buttons. + self.assertCreatesNothing(self.save(self.payload(controller="openhab"))) + class ThermostatTests(EntityRESTCommonTests, EntityRESTTestCase): url = "rest_entities_thermostats" @@ -328,12 +329,8 @@ def test_add_global_nspm_scene(self): self.assertIsNone(scene.room) self.assertLoadableByManager(scene) - @expectedFailure def test_home_assistant_scene_without_backend_name_is_rejected(self): - # KNOWN GAP: an empty backend_name is accepted; the scene can never be activated. self.assertCreatesNothing(self.save(self.payload(backend_name=""))) - @expectedFailure def test_unknown_scene_type_is_rejected(self): - # KNOWN GAP: MQTTManager ignores scenes whose scene_type it does not recognise. self.assertCreatesNothing(self.save(self.payload(scene_type="zigbee"))) From d3d7952bc10f260b3ad16b06eab6a3f658d78950 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:27:46 -0700 Subject: [PATCH 09/17] Only allow 4, 8 and 12 entity pages, and do not strand entities on shrink - Creating a page (room or global) with any other size is rejected with 400; the panel GUI only has those three layouts. - PUT /rest/entities_pages/ checks the new size the same way, and refuses to shrink a page while an entity or scene sits in a slot past the new end. Those entities stayed attached to the page but were never shown on the panel. The UI already only offers 4/8/12. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 26 ++++++++++++++++- .../nspanelmanager/web/tests/test_layout.py | 29 ++++++++++++++----- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index 0a10a775..10322c60 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -274,6 +274,16 @@ def rooms_get(request): return JsonResponse({"status": "error"}, status=500) +# The entities page layouts the panel GUI has. +PAGE_SIZES = (4, 8, 12) + + +def get_page_size_error(page_type): + if page_type not in PAGE_SIZES: + return f"Unsupported page size {page_type!r}. Expected one of: {', '.join(map(str, PAGE_SIZES))}." + return None + + def put_room_entities_order(request, room_id): if request.method == "PUT": try: @@ -332,6 +342,9 @@ def room_entities_pages(request, room_id): for field in required_fields: if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + page_size_error = get_page_size_error(data["type"]) + if page_size_error: + return JsonResponse({"status": "error", "message": page_size_error}, status=400) room = Room.objects.get(id=room_id) pages = RoomEntitiesPage.objects.filter(room=room).order_by("display_order") @@ -372,7 +385,15 @@ def room_entities_page(request, page_id): try: data = json.loads(request.body) db_page = RoomEntitiesPage.objects.get(id=page_id) - db_page.page_type = data.get("number_of_entities", db_page.page_type) + new_page_type = data.get("number_of_entities", db_page.page_type) + if new_page_type != db_page.page_type: + page_size_error = get_page_size_error(new_page_type) + if page_size_error: + return JsonResponse({"status": "error", "message": page_size_error}, status=400) + # Shrinking must not leave entities in slots the panel no longer shows. + if db_page.entity_set.filter(room_view_position__gte=new_page_type).exists() or db_page.scene_set.filter(room_view_position__gte=new_page_type).exists(): + return JsonResponse({"status": "error", "message": f"Move or remove the entities in slots {new_page_type + 1} and above before shrinking this page."}, status=400) + db_page.page_type = new_page_type db_page.display_order = data.get("display_order", db_page.display_order) db_page.save() send_mqttmanager_reload_command() @@ -503,6 +524,9 @@ def global_entities_pages(request): for field in required_fields: if field not in data: return JsonResponse({"status": "error", "message": f"Missing required field: {field}"}, status=400) + page_size_error = get_page_size_error(data["type"]) + if page_size_error: + return JsonResponse({"status": "error", "message": page_size_error}, status=400) pages = RoomEntitiesPage.objects.filter(room=None).order_by("display_order") new_display_order = 0 # Default to zero of no pages exists diff --git a/docker/web/nspanelmanager/web/tests/test_layout.py b/docker/web/nspanelmanager/web/tests/test_layout.py index 2b932da9..91aa2e9d 100644 --- a/docker/web/nspanelmanager/web/tests/test_layout.py +++ b/docker/web/nspanelmanager/web/tests/test_layout.py @@ -3,8 +3,6 @@ React entities-page view calls. """ -from unittest import expectedFailure - from django.urls import reverse from web.models import Entity, RoomEntitiesPage, Scene @@ -203,9 +201,7 @@ def test_create_page_in_unknown_room_is_rejected(self): self.assertEqual(RoomEntitiesPage.objects.count(), before) self.assertManagerNotReloaded() - @expectedFailure def test_create_page_with_unsupported_size_is_rejected(self): - # KNOWN GAP: panels only support 4, 8 or 12 entity pages but any number is stored. before = RoomEntitiesPage.objects.count() self.assertRejected(self.create_page(6)) @@ -261,13 +257,30 @@ def test_resize_page_reloads_manager(self): self.assertManagerReloaded(times=1) - @expectedFailure def test_shrinking_page_does_not_strand_entities(self): - # KNOWN GAP: shrinking a 12-slot page to 4 leaves entities in slots 4-11 attached to - # the page but never shown on the panel. Either reject the change or relocate them. page = RoomEntitiesPage.objects.create(room=self.room, page_type=12, is_scenes_page=False, display_order=2) light = self.make_light(self.room, page=page, slot=9) - self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 4}) + response = self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 4}) + self.assertRejected(response) + page.refresh_from_db() + self.assertEqual(page.page_type, 12) self.assertLoadableByManager(light) + self.assertManagerNotReloaded() + + def test_shrinking_page_with_free_trailing_slots(self): + page = RoomEntitiesPage.objects.create(room=self.room, page_type=12, is_scenes_page=False, display_order=2) + light = self.make_light(self.room, page=page, slot=3) + + response = self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 4}) + + self.assertEqual(response.status_code, 200) + self.assertLoadableByManager(light) + + def test_resize_to_unsupported_size_is_rejected(self): + page = self.entities_page(self.room) + + self.assertRejected(self.put_json(reverse("rest_room_entities_page", kwargs={"page_id": page.id}), {"number_of_entities": 6})) + page.refresh_from_db() + self.assertEqual(page.page_type, 4) From cf0ede1bc071795304bbf4d7a2655df0cdea377c Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:29:09 -0700 Subject: [PATCH 10/17] Reject non-PUT requests to the thermostat and scene entity endpoints The method check in entities_thermostats() and entities_scenes() was commented out, so a GET, POST or DELETE was handled as a save. Restore it, and answer unsupported methods on all five /rest/entities/* save endpoints with 405 rather than 403. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 18 +++++++++--------- .../nspanelmanager/web/tests/test_entities.py | 18 +++++------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index 10322c60..648dd857 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -868,7 +868,7 @@ def entities_lights(request): except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_light_entity(request): @@ -952,7 +952,7 @@ def entities_switches(request): except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_switch_entity(request): @@ -1021,7 +1021,7 @@ def entities_buttons(request): except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_button_entity(request): @@ -1087,12 +1087,12 @@ def put_button_entity(request): def entities_thermostats(request): try: - # if request.method == "PUT": - return put_thermostat_entity(request) + if request.method == "PUT": + return put_thermostat_entity(request) except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_thermostat_entity(request): @@ -1318,12 +1318,12 @@ def put_media_player_entity(request): def entities_scenes(request): try: - # if request.method == "PUT": - return put_scene_entity(request) + if request.method == "PUT": + return put_scene_entity(request) except Exception as ex: logging.exception(ex) return JsonResponse({"status": "error"}, status=500) - return JsonResponse({"status": "error", "error": "Unsupported method"}, status=403) + return JsonResponse({"status": "error", "error": "Unsupported method"}, status=405) def put_scene_entity(request): diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py index f853dcf8..7994a7f7 100644 --- a/docker/web/nspanelmanager/web/tests/test_entities.py +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -3,8 +3,6 @@ payloads the React "add/edit entity" modal sends. """ -from unittest import expectedFailure - from django.urls import reverse from web.models import Entity, Scene @@ -84,6 +82,11 @@ def test_edit_updates_in_place(self): def test_edit_unknown_id_is_rejected(self): self.assertCreatesNothing(self.save(self.payload(id=999))) + def test_other_methods_are_rejected(self): + # POST carries a valid payload, so only the method check can stop it being saved. + self.assertEqual(self.client.get(reverse(self.url)).status_code, 405) + self.assertCreatesNothing(self.post_json(reverse(self.url), self.payload())) + def test_add_to_occupied_slot_is_rejected(self): self.save(self.payload(slot=0)) @@ -150,11 +153,6 @@ def test_boolean_flags_accept_strings(self): data = Entity.objects.get().entity_data self.assertEqual((data["can_dim"], data["can_rgb"], data["is_ceiling_light"]), (False, True, False)) - def test_other_methods_are_rejected(self): - self.assertRejected(self.client.get(reverse(self.url))) - self.assertRejected(self.client.post(reverse(self.url), {})) - self.assertFalse(Entity.objects.exists()) - def test_unknown_controller_is_rejected(self): self.assertCreatesNothing(self.save(self.payload(controller="zigbee"))) @@ -285,12 +283,6 @@ def test_mode_missing_a_field_is_rejected(self): def test_non_numeric_step_size_is_rejected(self): self.assertCreatesNothing(self.save(self.payload(step_size="lots"))) - @expectedFailure - def test_other_methods_are_rejected(self): - # KNOWN GAP: the method check in entities_thermostats() is commented out, so any - # method is handled as a save. A GET only fails because it has no JSON body. - self.assertEqual(self.client.get(reverse(self.url)).status_code, 405) - class SceneTests(EntityRESTCommonTests, EntityRESTTestCase): url = "rest_entities_scenes" From a44657a6365bf2fd5a1411ee7119c4f9dda30fe8 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:29:50 -0700 Subject: [PATCH 11/17] Fix three small REST bugs: settings 500, scene filter, missing checksums - GET /rest/settings returned 500 until the Home Assistant token, the OpenHAB token and the MQTT password had all been saved at least once, because it del'd each key without checking it existed (e.g. OpenHAB never configured). Missing secrets now report *_set: false. - GET /rest/scenes?scene_id=N checked for a light_id parameter, then filtered on scene_id, so the filter was ignored and every scene came back. - The checksum_firmware, checksum_data_file and checksum_tft_file endpoints returned 200 with the body "None" when no image had been uploaded. They now return 404 with an empty body. The panel firmware does not check the status code, so for a panel this only changes the string it compares against its stored checksum. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/rest.py | 11 ++++------- docker/web/nspanelmanager/web/tests/test_files.py | 13 ++++++------- docker/web/nspanelmanager/web/tests/test_misc.py | 5 +---- .../web/nspanelmanager/web/tests/test_settings.py | 14 +++++++------- docker/web/nspanelmanager/web/views.py | 13 ++++++++++--- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/docker/web/nspanelmanager/web/rest.py b/docker/web/nspanelmanager/web/rest.py index 648dd857..f45e706c 100644 --- a/docker/web/nspanelmanager/web/rest.py +++ b/docker/web/nspanelmanager/web/rest.py @@ -221,12 +221,9 @@ def settings_get(request): settings = {} for setting in Settings.objects.all(): settings[setting.name] = setting.value - settings["home_assistant_token_set"] = settings.get("home_assistant_token", "") != "" - del settings["home_assistant_token"] - settings["openhab_token_set"] = settings.get("openhab_token", "") != "" - del settings["openhab_token"] - settings["mqtt_password_set"] = settings.get("mqtt_password", "") != "" - del settings["mqtt_password"] + # Never return secrets, only whether they are set. Any of them may never have been saved. + for secret in ("home_assistant_token", "openhab_token", "mqtt_password"): + settings[f"{secret}_set"] = settings.pop(secret, "") != "" return JsonResponse({"status": "ok", "settings": settings}, status=200) @@ -671,7 +668,7 @@ def scenes(request): def scenes_get(request): try: scenes = [] - if request.GET.get("light_id"): + if request.GET.get("scene_id"): scenes_objects = Scene.objects.filter(id=request.GET.get("scene_id")) elif request.GET.get("room_id"): scenes_objects = Scene.objects.filter(room_id=request.GET.get("room_id")) diff --git a/docker/web/nspanelmanager/web/tests/test_files.py b/docker/web/nspanelmanager/web/tests/test_files.py index 1bbbd6d8..0b3ef771 100644 --- a/docker/web/nspanelmanager/web/tests/test_files.py +++ b/docker/web/nspanelmanager/web/tests/test_files.py @@ -6,7 +6,6 @@ import hashlib import shutil import tempfile -from unittest import expectedFailure from django.core.files.uploadedfile import SimpleUploadedFile from django.test import override_settings @@ -93,13 +92,13 @@ def test_merged_flash_upload_does_not_reload_manager(self): self.assertManagerNotReloaded() - @expectedFailure def test_checksum_of_missing_image_is_not_found(self): - # KNOWN GAP: when no image has been uploaded the checksum endpoints return HTTP 200 with - # the body "None" (str(None)), which a panel cannot tell apart from a real checksum. - response = self.client.get(reverse("checksum_firmware")) - - self.assertEqual(response.status_code, 404) + panel = self.make_panel(self.make_room()) + for url in (reverse("checksum_firmware"), reverse("checksum_data_file"), reverse("checksum_tft_file", kwargs={"panel_id": panel.id})): + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.content, b"") class DataFileTests(FileTestCase): diff --git a/docker/web/nspanelmanager/web/tests/test_misc.py b/docker/web/nspanelmanager/web/tests/test_misc.py index 6fda9522..e626d519 100644 --- a/docker/web/nspanelmanager/web/tests/test_misc.py +++ b/docker/web/nspanelmanager/web/tests/test_misc.py @@ -5,7 +5,7 @@ import json import socket -from unittest import expectedFailure, mock +from unittest import mock from django.urls import reverse @@ -153,10 +153,7 @@ def test_legacy_scene_and_room_config(self): self.assertEqual(self.client.get(reverse("get_scenes")).json()["scenes"][0]["scene_id"], scene.id) self.assertEqual(self.client.get(reverse("get_nspanel_config", kwargs={"room_id": self.room.id})).json()["name"], "Kitchen") - @expectedFailure def test_scenes_listing_filters_by_scene_id(self): - # KNOWN BUG: scenes_get() checks for a "light_id" parameter but then filters on - # "scene_id", so ?scene_id=N is ignored and every scene is returned. scene = self.make_scene(self.room, "Dinner") self.make_scene(self.room, "Breakfast") diff --git a/docker/web/nspanelmanager/web/tests/test_settings.py b/docker/web/nspanelmanager/web/tests/test_settings.py index af1ecf31..56ed3bae 100644 --- a/docker/web/nspanelmanager/web/tests/test_settings.py +++ b/docker/web/nspanelmanager/web/tests/test_settings.py @@ -3,8 +3,6 @@ wizard, the settings REST endpoints and web.settings_helper. """ -from unittest import expectedFailure - from django.urls import reverse from web.models import NSPanelSettings, Settings @@ -230,14 +228,16 @@ def test_settings_endpoint_masks_secrets(self): def test_settings_endpoint_is_read_only(self): self.assertEqual(self.client.post(reverse("rest_settings")).status_code, 405) - @expectedFailure def test_settings_endpoint_works_before_all_secrets_are_set(self): - # KNOWN BUG: settings_get() does `del settings["openhab_token"]` (and the same for the - # other secrets) without checking the key exists, so it returns 500 until every - # secret setting has been saved at least once, e.g. if OpenHAB was never configured. set_setting_value("mqtt_password", "hunter2") - self.assertEqual(self.client.get(reverse("rest_settings")).status_code, 200) + response = self.client.get(reverse("rest_settings")) + + self.assertEqual(response.status_code, 200) + settings = response.json()["settings"] + self.assertEqual((settings["mqtt_password_set"], settings["home_assistant_token_set"], settings["openhab_token_set"]), (True, True, False)) + self.assertNotIn("mqtt_password", settings) + self.assertNotIn("home_assistant_token", settings) def test_mqttmanager_can_read_a_setting(self): set_setting_value("color_temp_min", "2000") diff --git a/docker/web/nspanelmanager/web/views.py b/docker/web/nspanelmanager/web/views.py index 8ede62f8..88dc89ec 100755 --- a/docker/web/nspanelmanager/web/views.py +++ b/docker/web/nspanelmanager/web/views.py @@ -697,6 +697,13 @@ def download_tft(request, panel_id): return HttpResponse(fs.open(tft_file).read(), content_type="application/octet-stream") +def checksum_response(md5sum): + # A panel compares the body against its stored checksum, so never send "None" as if it were one. + if md5sum is None: + return HttpResponse("", status=404) + return HttpResponse(md5sum) + + def checksum_firmware(request): if "model" in request.GET: model = request.GET["model"] @@ -706,7 +713,7 @@ def checksum_firmware(request): # As model was not specified, default to sonoff as that was the original model model = "sonoff" - return HttpResponse(get_file_md5sum(f"firmware/{model}/firmware.bin")) + return checksum_response(get_file_md5sum(f"firmware/{model}/firmware.bin")) def checksum_data_file(request): @@ -718,7 +725,7 @@ def checksum_data_file(request): # As model was not specified, default to sonoff as that was the original model model = "sonoff" - return HttpResponse(get_file_md5sum(f"firmware/{model}/data_file.bin")) + return checksum_response(get_file_md5sum(f"firmware/{model}/data_file.bin")) def checksum_tft_file(request, panel_id): @@ -739,7 +746,7 @@ def checksum_tft_file(request, panel_id): else: print(f"ERROR! Could not determine TFT file for NSPanel with ID {panel_id}") - return HttpResponse(get_file_md5sum(tft_file)) + return checksum_response(get_file_md5sum(tft_file)) def get_manual(request): From 08c05f6fc0be0b7623b8afd6b5185f9a97f82883 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 08:30:55 -0700 Subject: [PATCH 12/17] Fix api/get_nspanel_config and remove two dead htmx partials - api/get_nspanel_config always returned 500: it read button1/2_detached_mode_light, which were renamed to button1/2_detached_mode_entity. Use the new names, and fix the else branch writing -1 to "button1_detached_mode_light" instead of the "button1_detached_light" key the other branch sets. Kept rather than removed because panels on older firmware may still call it. - Remove htmx/partial/nspanel_index_view/, which always raised ImproperlyConfigured (nspanel_status_header has no template for that URL name), and htmx/partial/edit_entities_page/, whose template was removed in the move to React, along with its save/ route and the edit button in the old htmx entities-page component that linked to it. Nothing in the current UI reaches any of them. Co-Authored-By: Claude Opus 5.5 --- docker/web/nspanelmanager/web/api.py | 10 +++---- docker/web/nspanelmanager/web/htmx.py | 26 ------------------- .../nspanelmanager/web/tests/test_pages.py | 12 --------- .../nspanelmanager/web/tests/test_panels.py | 6 ----- docker/web/nspanelmanager/web/urls.py | 2 -- 5 files changed, 5 insertions(+), 51 deletions(-) diff --git a/docker/web/nspanelmanager/web/api.py b/docker/web/nspanelmanager/web/api.py index a78d1c93..7166eea2 100644 --- a/docker/web/nspanelmanager/web/api.py +++ b/docker/web/nspanelmanager/web/api.py @@ -231,13 +231,13 @@ def get_nspanel_config(request): base["button2_mqtt_topic"] = get_nspanel_setting_with_default(nspanel.id, "button2_mqtt_topic", "") base["button2_mqtt_payload"] = get_nspanel_setting_with_default(nspanel.id, "button2_mqtt_payload", "") - if nspanel.button1_detached_mode_light: - base["button1_detached_light"] = nspanel.button1_detached_mode_light.id + if nspanel.button1_detached_mode_entity: + base["button1_detached_light"] = nspanel.button1_detached_mode_entity.id else: - base["button1_detached_mode_light"] = -1 + base["button1_detached_light"] = -1 - if nspanel.button2_detached_mode_light: - base["button2_detached_light"] = nspanel.button2_detached_mode_light.id + if nspanel.button2_detached_mode_entity: + base["button2_detached_light"] = nspanel.button2_detached_mode_entity.id else: base["button2_detached_light"] = -1 base["rooms"] = [] diff --git a/docker/web/nspanelmanager/web/htmx.py b/docker/web/nspanelmanager/web/htmx.py index 12b86625..541d35c8 100644 --- a/docker/web/nspanelmanager/web/htmx.py +++ b/docker/web/nspanelmanager/web/htmx.py @@ -541,32 +541,6 @@ def partial_remove_entity_from_page_slot(request, page_id, slot_id): ) -def partial_edit_entities_page(request, page_id): - data = { - "page_id": page_id, - } - return render(request, "partial/edit_entities_page.html", data) - - -def partial_save_edit_entities_page(request, page_id, page_type): - page = RoomEntitiesPage.objects.get(id=page_id) - page.page_type = page_type - page.save() - - room_id = 0 - if page.room: - room_id = page.room.id - send_mqttmanager_reload_command() - entities_pages = NSPanelRoomEntitiesPages() - return entities_pages.get( - request=request, - view="edit_room", - room_id=room_id, - is_scenes_pages=page.is_scenes_page, - is_global_scenes_page=(page.room is None), - ) - - def get_entity_in_page_slot(page_id, slot_id): page = RoomEntitiesPage.objects.get(id=page_id) entities = Entity.objects.filter(entities_page=page, room_view_position=slot_id) diff --git a/docker/web/nspanelmanager/web/tests/test_pages.py b/docker/web/nspanelmanager/web/tests/test_pages.py index 75894748..4f1fda52 100644 --- a/docker/web/nspanelmanager/web/tests/test_pages.py +++ b/docker/web/nspanelmanager/web/tests/test_pages.py @@ -4,8 +4,6 @@ only show up when someone opens the page. """ -from unittest import expectedFailure - from django.urls import reverse from web.models import Message, RelayGroup @@ -128,19 +126,9 @@ def test_sensor_provider_picker_with_nothing_configured_is_an_error(self): self.assertRejected(self.client.get(reverse("htmx_partial_select_weather_outside_temperature_sensor_provider"))) - @expectedFailure - def test_unused_legacy_partials_render(self): - # KNOWN BUG: two routes nothing in the UI calls any more are broken. Fix or delete them. - # - nspanel_index_view: the nspanel_status_header component picks its template by URL - # name and has none for this URL, so rendering raises ImproperlyConfigured. - # - edit_entities_page: its template was removed with the move to React. - self.assertRenders(reverse("htmx_partial_nspanel_index_view", kwargs={"nspanel_id": self.panel.id})) - self.assertRenders(reverse("htmx_partial_edit_entities_page", kwargs={"page_id": self.entities_page(self.room).id})) - def test_unknown_objects_are_rejected(self): for url in ( reverse("edit_nspanel", kwargs={"panel_id": 999}), - reverse("htmx_partial_nspanel_index_view", kwargs={"nspanel_id": 999}), reverse("htmx_modal_edit_relay_group", kwargs={"relay_group_id": 999}), ): with self.subTest(url=url): diff --git a/docker/web/nspanelmanager/web/tests/test_panels.py b/docker/web/nspanelmanager/web/tests/test_panels.py index 36ec549f..b16cec30 100644 --- a/docker/web/nspanelmanager/web/tests/test_panels.py +++ b/docker/web/nspanelmanager/web/tests/test_panels.py @@ -3,8 +3,6 @@ legacy panel-facing api/ endpoints. """ -from unittest import expectedFailure - from django.urls import reverse from web.models import Entity, NSPanel @@ -246,11 +244,7 @@ def setUp(self): self.room = self.make_room("Kitchen") self.panel = self.make_panel(self.room, "Kitchen panel", mac="C0:49:EF:00:00:01") - @expectedFailure def test_get_nspanel_config(self): - # KNOWN BUG: get_nspanel_config() reads nspanel.button1_detached_mode_light, which was - # renamed to button1_detached_mode_entity, so it always returns 500. (The URL is marked - # "TODO: Remove" in urls.py; deleting it would also resolve this.) response = self.client.get(reverse("get_nspanel_config"), {"mac": self.panel.mac_address}) self.assertEqual(response.status_code, 200) diff --git a/docker/web/nspanelmanager/web/urls.py b/docker/web/nspanelmanager/web/urls.py index 4d38d8f3..e080f68a 100644 --- a/docker/web/nspanelmanager/web/urls.py +++ b/docker/web/nspanelmanager/web/urls.py @@ -112,8 +112,6 @@ path("htmx/partial/edit_light_entity_in_room/", htmx.partial_entity_edit_light_entity, name="htmx_partial_edit_light_entity"), path("htmx/partial/edit_scene_entity/", htmx.partial_entity_edit_scene_entity, name="htmx_partial_edit_scene_entity"), path("htmx/partial/remove_entity_from_page_slot//", htmx.partial_remove_entity_from_page_slot, name="htmx_partial_remove_entity_from_page_slot"), - path("htmx/partial/edit_entities_page/", htmx.partial_edit_entities_page, name="htmx_partial_edit_entities_page"), - path("htmx/partial/edit_entities_page//save/", htmx.partial_save_edit_entities_page, name="htmx_partial_save_edit_entities_page"), path("htmx/partial/move_entity", htmx.partial_move_entity, name="htmx_partial_move_entity"), path("htmx/partial/reorder_entity_pages", htmx.partial_move_entities_pages, name="htmx_partial_move_entities_pages"), path("htmx/partial/delete_entities_page/", htmx.partial_delete_entities_page, name="htmx_partial_delete_entities_page"), From f30383848c0ff5cdbf03a2f47da071d2bc17f31f Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 20:58:09 -0700 Subject: [PATCH 13/17] Update tests for the React first page (#391) - The htmx first-run wizard and the index panels partial are gone; the React InitialSetup now saves everything with one POST /rest/settings. Test that endpoint (saves, only touches submitted keys, rejects bad payloads, reloads once) and that the index shows InitialSetup until a manager address is configured. - /rest/mqttmanager/settings (POST) was removed; keep the banned-key tests for the per-key GET endpoint. - GET /rest/rooms now returns friendly_name, display_order and the temperature sensor fields; PUT /rest/rooms updates a room. Pin the display_order/displayOrder mismatch in room_put as a known bug. - Thermostats now require use_current_temperature. Co-Authored-By: Claude Opus 5.5 --- .../nspanelmanager/web/tests/test_entities.py | 2 + .../nspanelmanager/web/tests/test_pages.py | 23 +----- .../nspanelmanager/web/tests/test_rooms.py | 31 +++++++- .../nspanelmanager/web/tests/test_settings.py | 78 +++++++++---------- 4 files changed, 70 insertions(+), 64 deletions(-) diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py index 7994a7f7..d9715937 100644 --- a/docker/web/nspanelmanager/web/tests/test_entities.py +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -247,6 +247,7 @@ def payload(self, slot=0, **overrides): "type": "thermostat", "friendly_name": "Heat pump", "step_size": "0.5", + "use_current_temperature": "True", "home_assistant_name": "climate.kitchen", "openhab_fan_mode_item": "", "openhab_hvac_mode_item": "", @@ -269,6 +270,7 @@ def test_add_thermostat(self): self.assertEqual(thermostat.entity_data["step_size"], 0.5) self.assertEqual([m["value"] for m in thermostat.entity_data["hvac_modes"]], ["heat", "cool"]) self.assertEqual(thermostat.entity_data["home_assistant_name"], "climate.kitchen") + self.assertIs(thermostat.entity_data["use_current_temperature"], True) self.assertLoadableByManager(thermostat) self.assertManagerReloaded(times=1) diff --git a/docker/web/nspanelmanager/web/tests/test_pages.py b/docker/web/nspanelmanager/web/tests/test_pages.py index 4f1fda52..41aaf033 100644 --- a/docker/web/nspanelmanager/web/tests/test_pages.py +++ b/docker/web/nspanelmanager/web/tests/test_pages.py @@ -52,18 +52,15 @@ def test_pages_render_with_no_configuration(self): with self.subTest(url=url): self.assertRenders(url) - def test_index_prompts_for_manager_address_until_configured(self): - self.assertContains(self.assertRenders(reverse("index")), "No manager address configured") + def test_index_shows_initial_setup_until_manager_address_is_configured(self): + self.assertContains(self.assertRenders(reverse("index")), "data-react-component='InitialSetup'") - def test_index_lists_panels_but_not_denied_ones(self): - response = self.assertRenders(reverse("htmx_partial_index_nspanels_section")) + set_setting_value("manager_address", "192.168.1.10") - self.assertContains(response, "Kitchen panel") - self.assertNotContains(response, "Denied panel") + self.assertNotContains(self.assertRenders(reverse("index")), "data-react-component='InitialSetup'") def test_htmx_partials(self): partials = [ - reverse("htmx_partial_index_nspanels_section"), reverse("htmx_partial_select_new_entity_type_react", kwargs={"entities_page_id": self.entities_page(self.room).id, "room_view_position": 1}), reverse("htmx_partial_select_weather_location"), reverse("htmx_partial_select_weather_outside_temperature_sensor"), @@ -76,18 +73,6 @@ def test_htmx_partials(self): with self.subTest(url=url): self.assertRenders(url) - def test_initial_setup_modals(self): - for name in ( - "htmx_initial_setup_welcome", - "htmx_initial_setup_manager_settings", - "htmx_initial_setup_mqtt_settings", - "htmx_initial_setup_home_assistant_settings", - "htmx_initial_setup_openhab_settings", - "htmx_initial_setup_finished", - ): - with self.subTest(name=name): - self.assertRenders(reverse(name)) - def test_sensor_pickers_list_home_assistant_entities(self): self.home_assistant.add("sensor.kitchen_temperature", friendly_name="Kitchen temperature") diff --git a/docker/web/nspanelmanager/web/tests/test_rooms.py b/docker/web/nspanelmanager/web/tests/test_rooms.py index 97324104..a3883885 100644 --- a/docker/web/nspanelmanager/web/tests/test_rooms.py +++ b/docker/web/nspanelmanager/web/tests/test_rooms.py @@ -1,4 +1,5 @@ import json +from unittest import expectedFailure from django.urls import reverse @@ -103,7 +104,7 @@ def test_create_room_rejects_bad_payloads(self): self.assertFalse(Room.objects.exists()) def test_rooms_endpoint_rejects_unsupported_methods(self): - self.assertEqual(self.client.put(reverse("rest_rooms_create")).status_code, 405) + self.assertEqual(self.client.delete(reverse("rest_rooms_create")).status_code, 405) room = self.make_room() self.assertEqual(self.client.get(reverse("rest_room_delete", kwargs={"room_id": room.id})).status_code, 405) @@ -133,7 +134,8 @@ def test_list_rooms(self): response = self.client.get(reverse("rest_rooms_create")) self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["rooms"], [{"id": office.id, "name": "Office"}, {"id": lounge.id, "name": "Lounge"}]) + self.assertEqual([(r["id"], r["friendly_name"]) for r in response.json()["rooms"]], [(office.id, "Office"), (lounge.id, "Lounge")]) + self.assertEqual(set(response.json()["rooms"][0]), {"id", "friendly_name", "display_order", "room_temp_provider", "room_temp_sensor"}) def test_get_single_room(self): self.make_room("Office") @@ -141,7 +143,30 @@ def test_get_single_room(self): response = self.client.get(reverse("rest_rooms_create"), {"id": lounge.id}) - self.assertEqual(response.json()["rooms"], [{"id": lounge.id, "name": "Lounge"}]) + self.assertEqual([(r["id"], r["friendly_name"]) for r in response.json()["rooms"]], [(lounge.id, "Lounge")]) + + def test_update_room(self): + room = self.make_room("Office") + + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "friendly_name": "Study", "room_temp_provider": "home_assistant", "room_temp_sensor": "sensor.study"}) + + self.assertEqual(response.status_code, 200) + room.refresh_from_db() + self.assertEqual((room.friendly_name, room.room_temp_provider, room.room_temp_sensor), ("Study", "home_assistant", "sensor.study")) + self.assertManagerReloaded(times=1) + + @expectedFailure + def test_update_room_display_order(self): + # KNOWN BUG: room_put checks for "displayOrder" but reads data["display_order"], so + # sending display_order (the key GET /rest/rooms returns) is silently ignored and + # sending displayOrder raises KeyError and returns 500. + room = self.make_room("Office") + + response = self.put_json(reverse("rest_rooms_create"), {"id": room.id, "display_order": 7}) + + self.assertEqual(response.status_code, 200) + room.refresh_from_db() + self.assertEqual(room.displayOrder, 7) def test_delete_room_reloads_manager(self): room = self.make_room("Office") diff --git a/docker/web/nspanelmanager/web/tests/test_settings.py b/docker/web/nspanelmanager/web/tests/test_settings.py index 56ed3bae..ebbdff32 100644 --- a/docker/web/nspanelmanager/web/tests/test_settings.py +++ b/docker/web/nspanelmanager/web/tests/test_settings.py @@ -155,13 +155,6 @@ def test_save_weather_and_time(self): self.assertEqual(setting("use_fahrenheit"), "True") self.assertManagerReloaded(times=1) - def test_fahrenheit_is_shown_on_the_dashboard(self): - self.client.post(reverse("weather_and_time"), WEATHER_FORM) - room = self.make_room() - self.make_panel(room, accepted=True) - - self.assertContains(self.client.get(reverse("htmx_partial_index_nspanels_section")), "°F") - def test_weather_form_missing_field_is_rejected(self): form = {k: v for k, v in WEATHER_FORM.items() if k != "use_fahrenheit"} @@ -177,33 +170,45 @@ def test_interface_theme(self): class InitialSetupTests(NSPMTestCase): - """The first-run wizard: each step saves its settings and returns the next step.""" - - def test_wizard_saves_each_step(self): - steps = [ - ("htmx_initial_setup_manager_settings", {"manager_address": "192.168.1.10", "manager_port": "8000"}, "mqtt_server"), - ("htmx_initial_setup_mqtt_settings", {"mqtt_server": "mqtt.local", "mqtt_port": "1883", "mqtt_username": "u", "mqtt_password": "p"}, "home_assistant_address"), - ("htmx_initial_setup_home_assistant_settings", {"home_assistant_address": "http://ha.local", "home_assistant_token": "t"}, "openhab_address"), - ("htmx_initial_setup_openhab_settings", {"openhab_address": "", "openhab_token": ""}, None), - ] - for url_name, form, next_step_field in steps: - with self.subTest(step=url_name): - response = self.client.post(reverse(url_name), form) - self.assertEqual(response.status_code, 200) - if next_step_field: - self.assertContains(response, f'name="{next_step_field}"') - for name, value in form.items(): - self.assertEqual(setting(name), value) - self.assertManagerReloaded(times=4) - - def test_step_only_changes_submitted_fields(self): + """The first-run wizard (React InitialSetup) saves everything in one POST /rest/settings.""" + + WIZARD_SETTINGS = { + "manager_address": "192.168.1.10", + "manager_port": "8000", + "mqtt_server": "mqtt.local", + "mqtt_port": "1883", + "mqtt_username": "u", + "mqtt_password": "p", + "home_assistant_address": "http://ha.local", + "home_assistant_token": "t", + "openhab_address": "", + "openhab_token": "", + } + + def test_wizard_saves_all_settings(self): + response = self.post_json(reverse("rest_settings"), {"settings": self.WIZARD_SETTINGS}) + + self.assertEqual(response.status_code, 200) + for name, value in self.WIZARD_SETTINGS.items(): + self.assertEqual(setting(name), value) + self.assertManagerReloaded(times=1) + + def test_only_submitted_settings_change(self): set_setting_value("mqtt_password", "keep-me") - self.client.post(reverse("htmx_initial_setup_mqtt_settings"), {"mqtt_server": "mqtt.local"}) + self.post_json(reverse("rest_settings"), {"settings": {"mqtt_server": "mqtt.local"}}) self.assertEqual(setting("mqtt_server"), "mqtt.local") self.assertEqual(setting("mqtt_password"), "keep-me") + def test_bad_payloads_are_rejected(self): + before = list(Settings.objects.values_list("name", "value")) + for body in ({}, {"settings": ["mqtt_server"]}, {"settings": "mqtt_server"}): + with self.subTest(body=body): + self.assertEqual(self.post_json(reverse("rest_settings"), body).status_code, 400) + self.assertEqual(list(Settings.objects.values_list("name", "value")), before) + self.assertManagerNotReloaded() + class SettingsRESTTests(NSPMTestCase): def configure_secrets(self): @@ -225,8 +230,8 @@ def test_settings_endpoint_masks_secrets(self): self.assertEqual((settings["mqtt_password_set"], settings["home_assistant_token_set"], settings["openhab_token_set"]), (True, True, False)) self.assertNotIn("hunter2", response.content.decode()) - def test_settings_endpoint_is_read_only(self): - self.assertEqual(self.client.post(reverse("rest_settings")).status_code, 405) + def test_settings_endpoint_rejects_unsupported_methods(self): + self.assertEqual(self.client.put(reverse("rest_settings")).status_code, 405) def test_settings_endpoint_works_before_all_secrets_are_set(self): set_setting_value("mqtt_password", "hunter2") @@ -246,23 +251,13 @@ def test_mqttmanager_can_read_a_setting(self): self.assertEqual(response.json(), {"status": "ok", "settings": {"color_temp_min": "2000"}}) - def test_mqttmanager_can_read_several_settings(self): - set_setting_value("color_temp_min", "2000") - set_setting_value("color_temp_max", "6000") - - response = self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["color_temp_min", "color_temp_max"]}) - - self.assertEqual(response.json()["settings"], {"color_temp_min": "2000", "color_temp_max": "6000"}) - - def test_mqttmanager_settings_endpoints_check_method(self): + def test_mqttmanager_settings_endpoint_checks_method(self): self.assertEqual(self.client.post(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": "x"})).status_code, 405) - self.assertEqual(self.client.get(reverse("rest_mqttmanager_settings_post")).status_code, 405) def test_mqttmanager_settings_endpoint_refuses_banned_keys_as_listed(self): for key in ("MQTT_PASSWORD", "HOME_ASSISTANT_TOKEN"): with self.subTest(key=key): self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) - self.assertEqual(self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["color_temp_min", "OPENHAB_TOKEN"]}).status_code, 403) def test_mqttmanager_settings_endpoint_refuses_secrets(self): # banned_setting_keys is upper case but settings are stored lower case, so the check @@ -272,7 +267,6 @@ def test_mqttmanager_settings_endpoint_refuses_secrets(self): for key in ("mqtt_password", "home_assistant_token", "openhab_token"): with self.subTest(key=key): self.assertEqual(self.client.get(reverse("rest_mqttmanager_get_setting", kwargs={"setting_key": key})).status_code, 403) - self.assertEqual(self.post_json(reverse("rest_mqttmanager_settings_post"), {"settings": ["mqtt_password"]}).status_code, 403) class SettingsHelperTests(NSPMTestCase): From 328186aa6e578262bc3d34cb26a7220f6e3a61f2 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 21:04:58 -0700 Subject: [PATCH 14/17] Remove the leftover htmx setup wizard that 500s every page on a fresh install #391 replaced the htmx first-run wizard with the React InitialSetup and removed its htmx_initial_setup_* URLs, but base.html still included modals/initial_setup/welcome.html whenever manager_address is empty. That template reverses the removed URLs, so on a fresh install every page, including the index that shows the new wizard, raised NoReverseMatch and returned 500. Remove the include and the now-unreferenced modals/initial_setup templates, and the index.html script tag for initial_setup.js, which #391 deleted. Co-Authored-By: Claude Opus 5.5 --- .../nspanelmanager/web/templates/base.html | 3 - .../nspanelmanager/web/templates/index.html | 1 - .../modals/initial_setup/finished.html | 61 ------------ .../modals/initial_setup/home_assistant.html | 74 -------------- .../initial_setup/manager_settings.html | 99 ------------------- .../templates/modals/initial_setup/mqtt.html | 95 ------------------ .../modals/initial_setup/openhab.html | 70 ------------- .../modals/initial_setup/welcome.html | 59 ----------- 8 files changed, 462 deletions(-) delete mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html delete mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html delete mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html delete mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html delete mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html delete mode 100644 docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html diff --git a/docker/web/nspanelmanager/web/templates/base.html b/docker/web/nspanelmanager/web/templates/base.html index d4896ea0..7ce58cc7 100644 --- a/docker/web/nspanelmanager/web/templates/base.html +++ b/docker/web/nspanelmanager/web/templates/base.html @@ -68,9 +68,6 @@ {% endfor %}
- - {% if manager_address == "" %}{% include 'modals/initial_setup/welcome.html' %}{% endif %} - {% block content %} {% endblock %} diff --git a/docker/web/nspanelmanager/web/templates/index.html b/docker/web/nspanelmanager/web/templates/index.html index 5f3c2933..9951689f 100644 --- a/docker/web/nspanelmanager/web/templates/index.html +++ b/docker/web/nspanelmanager/web/templates/index.html @@ -4,7 +4,6 @@ {% block head %} {% load static %} -{% if manager_address == "" %}{% endif %} {% endblock %} {% block content %} diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html deleted file mode 100644 index f611b63f..00000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/finished.html +++ /dev/null @@ -1,61 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html deleted file mode 100644 index 87f90def..00000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/home_assistant.html +++ /dev/null @@ -1,74 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html deleted file mode 100644 index 15aad28e..00000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/manager_settings.html +++ /dev/null @@ -1,99 +0,0 @@ -{% load static %} - - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html deleted file mode 100644 index 60e65169..00000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/mqtt.html +++ /dev/null @@ -1,95 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html deleted file mode 100644 index ca0f144d..00000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/openhab.html +++ /dev/null @@ -1,70 +0,0 @@ -{% load static %} - - - - - - - - diff --git a/docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html b/docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html deleted file mode 100644 index 3f7313f3..00000000 --- a/docker/web/nspanelmanager/web/templates/modals/initial_setup/welcome.html +++ /dev/null @@ -1,59 +0,0 @@ -{% load static %} - - - - - - - - From 16ce94c3cc1ec72d8aae5c803a02ef49e5c7f446 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 21:08:49 -0700 Subject: [PATCH 15/17] Make the wizard test settings a module constant (ruff RUF012) Co-Authored-By: Claude Opus 5.5 --- .../nspanelmanager/web/tests/test_settings.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docker/web/nspanelmanager/web/tests/test_settings.py b/docker/web/nspanelmanager/web/tests/test_settings.py index ebbdff32..e9899e01 100644 --- a/docker/web/nspanelmanager/web/tests/test_settings.py +++ b/docker/web/nspanelmanager/web/tests/test_settings.py @@ -169,27 +169,29 @@ def test_interface_theme(self): self.assertContains(self.client.get(reverse("index")), "dracula") +# Everything the React first-run wizard submits. +WIZARD_SETTINGS = { + "manager_address": "192.168.1.10", + "manager_port": "8000", + "mqtt_server": "mqtt.local", + "mqtt_port": "1883", + "mqtt_username": "u", + "mqtt_password": "p", + "home_assistant_address": "http://ha.local", + "home_assistant_token": "t", + "openhab_address": "", + "openhab_token": "", +} + + class InitialSetupTests(NSPMTestCase): """The first-run wizard (React InitialSetup) saves everything in one POST /rest/settings.""" - WIZARD_SETTINGS = { - "manager_address": "192.168.1.10", - "manager_port": "8000", - "mqtt_server": "mqtt.local", - "mqtt_port": "1883", - "mqtt_username": "u", - "mqtt_password": "p", - "home_assistant_address": "http://ha.local", - "home_assistant_token": "t", - "openhab_address": "", - "openhab_token": "", - } - def test_wizard_saves_all_settings(self): - response = self.post_json(reverse("rest_settings"), {"settings": self.WIZARD_SETTINGS}) + response = self.post_json(reverse("rest_settings"), {"settings": WIZARD_SETTINGS}) self.assertEqual(response.status_code, 200) - for name, value in self.WIZARD_SETTINGS.items(): + for name, value in WIZARD_SETTINGS.items(): self.assertEqual(setting(name), value) self.assertManagerReloaded(times=1) From 58e7ac885604998cf5c1cb85be6eb7fafc89e500 Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 25 Sep 2026 21:56:32 -0700 Subject: [PATCH 16/17] Report Django test results in the GitHub Actions job summary Write JUnit XML from the test run in CI (via unittest-xml-reporting, enabled by NSPM_TEST_JUNIT_DIR) and publish it with action-junit-report. annotate_only is used because creating a check run needs checks: write, which pull requests from forks don't get. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/django-tests.yml | 16 +++++++++++++++- .../nspanelmanager/test_settings.py | 6 ++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 6dda8480..d134282c 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -26,7 +26,21 @@ jobs: cache-dependency-path: docker/web/requirements.txt - name: Install dependencies - run: pip install -r docker/web/requirements.txt + run: pip install -r docker/web/requirements.txt unittest-xml-reporting - name: Run tests run: docker/web/run_tests.sh -v 2 + env: + NSPM_TEST_JUNIT_DIR: ${{ github.workspace }}/test-results + + # annotate_only writes the job summary and failure annotations without creating a + # check run, which needs checks: write and so fails on pull requests from forks. + - name: Report test results + if: ${{ !cancelled() }} + uses: mikepenz/action-junit-report@v6 + with: + report_paths: test-results/*.xml + annotate_only: true + detailed_summary: true + include_passed: true + require_tests: true diff --git a/docker/web/nspanelmanager/nspanelmanager/test_settings.py b/docker/web/nspanelmanager/nspanelmanager/test_settings.py index 64377289..0ee438d0 100644 --- a/docker/web/nspanelmanager/nspanelmanager/test_settings.py +++ b/docker/web/nspanelmanager/nspanelmanager/test_settings.py @@ -22,3 +22,9 @@ # Set NSPM_TEST_LOG_LEVEL=INFO (or DEBUG) to see them when debugging a failing test. LOGGING["root"]["level"] = os.environ.get("NSPM_TEST_LOG_LEVEL", "CRITICAL") LOGGING["loggers"] = {"django.request": {"level": os.environ.get("NSPM_TEST_LOG_LEVEL", "CRITICAL")}} + +# CI sets NSPM_TEST_JUNIT_DIR to also write JUnit XML results (needs unittest-xml-reporting). +if os.environ.get("NSPM_TEST_JUNIT_DIR"): + TEST_RUNNER = "xmlrunner.extra.djangotestrunner.XMLTestRunner" + TEST_OUTPUT_DIR = os.environ["NSPM_TEST_JUNIT_DIR"] + TEST_OUTPUT_FILE_NAME = "django-tests.xml" From 6fb9b635bea79ade5ff2bdfa499be9874bfb0d2d Mon Sep 17 00:00:00 2001 From: James Mulcahy Date: Fri, 2 Oct 2026 06:25:35 -0700 Subject: [PATCH 17/17] Test adding Home Assistant scripts as scenes The React scene picker fetches /rest/home_assistant/entities and offers the scene.* and script.* items. Check that scripts are listed there, and that a script can be saved as a room or global scene through /rest/entities/scenes in a form the manager can load. Co-Authored-By: Claude Opus 5.5 --- .../nspanelmanager/web/tests/test_entities.py | 20 +++++++++++++++++++ .../web/nspanelmanager/web/tests/test_misc.py | 12 +++++++++++ 2 files changed, 32 insertions(+) diff --git a/docker/web/nspanelmanager/web/tests/test_entities.py b/docker/web/nspanelmanager/web/tests/test_entities.py index d9715937..cd10c808 100644 --- a/docker/web/nspanelmanager/web/tests/test_entities.py +++ b/docker/web/nspanelmanager/web/tests/test_entities.py @@ -323,6 +323,26 @@ def test_add_global_nspm_scene(self): self.assertIsNone(scene.room) self.assertLoadableByManager(scene) + def test_add_home_assistant_script_as_a_room_scene(self): + self.assertEqual(self.save(self.payload(friendly_name="Lights out", backend_name="script.lights_out")).status_code, 200) + + scene = Scene.objects.get() + self.assertEqual((scene.scene_type, scene.backend_name), ("home_assistant", "script.lights_out")) + self.assertEqual(scene.room, self.room) + self.assertLoadableByManager(scene) + self.assertManagerReloaded(times=1) + + def test_add_home_assistant_script_as_a_global_scene(self): + page = self.global_scenes_page() + + response = self.save(self.payload(room_id=None, entities_page_id=page.id, friendly_name="All off", backend_name="script.all_off")) + + self.assertEqual(response.status_code, 200) + scene = Scene.objects.get() + self.assertIsNone(scene.room) + self.assertEqual(scene.backend_name, "script.all_off") + self.assertLoadableByManager(scene) + def test_home_assistant_scene_without_backend_name_is_rejected(self): self.assertCreatesNothing(self.save(self.payload(backend_name=""))) diff --git a/docker/web/nspanelmanager/web/tests/test_misc.py b/docker/web/nspanelmanager/web/tests/test_misc.py index e626d519..559752dd 100644 --- a/docker/web/nspanelmanager/web/tests/test_misc.py +++ b/docker/web/nspanelmanager/web/tests/test_misc.py @@ -51,6 +51,18 @@ def test_home_assistant_entities_filtered_by_domain(self): self.assertEqual([i["item_id"] for i in response.json()["items"]], ["switch.fan", "climate.lounge"]) + def test_home_assistant_scripts_are_offered_with_scenes(self): + # The React scene picker fetches every entity and keeps scene.* and script.* itself. + self.home_assistant.add("scene.movie_time", friendly_name="Movie time") + self.home_assistant.add("script.lights_out", friendly_name="Lights out") + self.home_assistant.add("light.kitchen") + + response = self.client.get(reverse("rest_get_home_assistant_entities")) + + items = {i["item_id"]: i["label"] for i in response.json()["items"]} + self.assertEqual(items["scene.movie_time"], "Movie time") + self.assertEqual(items["script.lights_out"], "Lights out") + def test_entity_without_friendly_name_is_labelled_by_id(self): self.home_assistant.add("light.unnamed")