"""Tests for storage_json.py path functions.""" from datetime import datetime import json from pathlib import Path import sys from unittest.mock import MagicMock, Mock, patch import pytest from esphome import config_validation as cv, storage_json from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain from esphome.core import CORE def test_storage_path(setup_core: Path) -> None: """Test storage_path returns correct path for current config.""" CORE.config_path = setup_core / "my_device.yaml" result = storage_json.storage_path() data_dir = Path(CORE.data_dir) expected = data_dir / "storage" / "my_device.yaml.json" assert result == expected def test_ext_storage_path(setup_core: Path) -> None: """Test ext_storage_path returns correct path for given filename.""" result = storage_json.ext_storage_path("other_device.yaml") data_dir = Path(CORE.data_dir) expected = data_dir / "storage" / "other_device.yaml.json" assert result == expected def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None: """Test ext_storage_path works with different file extensions.""" result_yml = storage_json.ext_storage_path("device.yml") assert str(result_yml).endswith("device.yml.json") result_no_ext = storage_json.ext_storage_path("device") assert str(result_no_ext).endswith("device.json") result_path = storage_json.ext_storage_path("my/device.yaml") assert str(result_path).endswith("device.yaml.json") def test_esphome_storage_path(setup_core: Path) -> None: """Test esphome_storage_path returns correct path.""" result = storage_json.esphome_storage_path() data_dir = Path(CORE.data_dir) expected = data_dir / "esphome.json" assert result == expected def test_ignored_devices_storage_path(setup_core: Path) -> None: """Test ignored_devices_storage_path returns correct path.""" result = storage_json.ignored_devices_storage_path() data_dir = Path(CORE.data_dir) expected = data_dir / "ignored-devices.json" assert result == expected def test_trash_storage_path(setup_core: Path) -> None: """Test trash_storage_path returns correct path.""" CORE.config_path = setup_core / "configs" / "device.yaml" result = storage_json.trash_storage_path() expected = setup_core / "configs" / "trash" assert result == expected def test_archive_storage_path(setup_core: Path) -> None: """Test archive_storage_path returns correct path.""" CORE.config_path = setup_core / "configs" / "device.yaml" result = storage_json.archive_storage_path() expected = setup_core / "configs" / "archive" assert result == expected def test_storage_path_with_subdirectory(setup_core: Path) -> None: """Test storage paths work correctly when config is in subdirectory.""" subdir = setup_core / "configs" / "basement" subdir.mkdir(parents=True, exist_ok=True) CORE.config_path = subdir / "sensor.yaml" result = storage_json.storage_path() data_dir = Path(CORE.data_dir) expected = data_dir / "storage" / "sensor.yaml.json" assert result == expected def test_storage_json_firmware_bin_path_property(setup_core: Path) -> None: """Test StorageJSON firmware_bin_path property.""" storage = storage_json.StorageJSON( storage_version=1, name="test_device", friendly_name="Test Device", comment=None, esphome_version="2024.1.0", src_version=None, address="192.168.1.100", web_port=80, target_platform="ESP32", build_path="build/test_device", firmware_bin_path="/path/to/firmware.bin", loaded_integrations={"wifi", "api"}, loaded_platforms=set(), no_mdns=False, ) assert storage.firmware_bin_path == "/path/to/firmware.bin" def test_storage_json_save_creates_directory( setup_core: Path, tmp_path: Path, mock_write_file_if_changed: Mock ) -> None: """Test StorageJSON.save creates storage directory if it doesn't exist.""" storage_dir = tmp_path / "new_data" / "storage" storage_file = storage_dir / "test.json" assert not storage_dir.exists() storage = storage_json.StorageJSON( storage_version=1, name="test", friendly_name="Test", comment=None, esphome_version="2024.1.0", src_version=None, address="test.local", web_port=None, target_platform="ESP8266", build_path=None, firmware_bin_path=None, loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, ) storage.save(str(storage_file)) mock_write_file_if_changed.assert_called_once() call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == str(storage_file) def test_storage_json_from_wizard(setup_core: Path) -> None: """Test StorageJSON.from_wizard creates correct storage object.""" storage = storage_json.StorageJSON.from_wizard( name="my_device", friendly_name="My Device", address="my_device.local", platform="ESP32", ) assert storage.name == "my_device" assert storage.friendly_name == "My Device" assert storage.address == "my_device.local" assert storage.target_platform == "ESP32" assert storage.build_path is None assert storage.firmware_bin_path is None @pytest.mark.skipif(sys.platform == "win32", reason="HA addons don't run on Windows") @patch("esphome.core.is_ha_addon") def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> None: """Test storage paths when running as Home Assistant addon.""" mock_is_ha_addon.return_value = True CORE.config_path = tmp_path / "test.yaml" result = storage_json.storage_path() # When is_ha_addon is True, CORE.data_dir returns "/data" # This is the standard mount point for HA addon containers expected = Path("/data") / "storage" / "test.yaml.json" assert result == expected result = storage_json.esphome_storage_path() expected = Path("/data") / "esphome.json" assert result == expected def test_storage_json_as_dict() -> None: """Test StorageJSON.as_dict returns correct dictionary.""" storage = storage_json.StorageJSON( storage_version=1, name="test_device", friendly_name="Test Device", comment="Test comment", esphome_version="2024.1.0", src_version=1, address="192.168.1.100", web_port=80, target_platform="ESP32", build_path="/path/to/build", firmware_bin_path="/path/to/firmware.bin", loaded_integrations={"wifi", "api", "ota"}, loaded_platforms={"sensor", "binary_sensor"}, no_mdns=True, framework="arduino", core_platform="esp32", area="Living Room", framework_version="5.3.1", ) result = storage.as_dict() assert result["storage_version"] == 1 assert result["name"] == "test_device" assert result["friendly_name"] == "Test Device" assert result["comment"] == "Test comment" assert result["esphome_version"] == "2024.1.0" assert result["src_version"] == 1 assert result["address"] == "192.168.1.100" assert result["web_port"] == 80 assert result["esp_platform"] == "ESP32" assert result["build_path"] == "/path/to/build" assert result["firmware_bin_path"] == "/path/to/firmware.bin" assert "api" in result["loaded_integrations"] assert "wifi" in result["loaded_integrations"] assert "ota" in result["loaded_integrations"] assert result["loaded_integrations"] == sorted( ["wifi", "api", "ota"] ) # Should be sorted assert "sensor" in result["loaded_platforms"] assert result["loaded_platforms"] == sorted( ["sensor", "binary_sensor"] ) # Should be sorted assert result["no_mdns"] is True assert result["framework"] == "arduino" assert result["core_platform"] == "esp32" assert result["area"] == "Living Room" assert result["framework_version"] == "5.3.1" def test_storage_json_to_json() -> None: """Test StorageJSON.to_json returns valid JSON string.""" storage = storage_json.StorageJSON( storage_version=1, name="test", friendly_name="Test", comment=None, esphome_version="2024.1.0", src_version=None, address="test.local", web_port=None, target_platform="ESP8266", build_path=None, firmware_bin_path=None, loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, ) json_str = storage.to_json() # Should be valid JSON parsed = json.loads(json_str) assert parsed["name"] == "test" assert parsed["storage_version"] == 1 # Should end with newline assert json_str.endswith("\n") def test_storage_json_save(tmp_path: Path) -> None: """Test StorageJSON.save writes file correctly.""" storage = storage_json.StorageJSON( storage_version=1, name="test", friendly_name="Test", comment=None, esphome_version="2024.1.0", src_version=None, address="test.local", web_port=None, target_platform="ESP32", build_path=None, firmware_bin_path=None, loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, ) save_path = tmp_path / "test.json" with patch("esphome.storage_json.write_file_if_changed") as mock_write: storage.save(str(save_path)) mock_write.assert_called_once_with(str(save_path), storage.to_json()) def test_storage_json_from_esphome_core(setup_core: Path) -> None: """Test StorageJSON.from_esphome_core creates correct storage object.""" # Mock CORE object mock_core = MagicMock() mock_core.name = "my_device" mock_core.friendly_name = "My Device" mock_core.comment = "A test device" mock_core.address = "192.168.1.50" mock_core.web_port = 8080 mock_core.target_platform = "esp32" mock_core.is_esp32 = True mock_core.build_path = "/build/my_device" mock_core.firmware_bin = "/build/my_device/firmware.bin" mock_core.loaded_integrations = {"wifi", "api"} mock_core.loaded_platforms = {"sensor"} mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}} mock_core.target_framework = "esp-idf" mock_core.toolchain = Toolchain.ESP_IDF mock_core.area = "Living Room" with ( patch("esphome.components.esp32.get_esp32_variant") as mock_variant, patch("esphome.components.esp32.idf_version") as mock_idf_version, ): mock_variant.return_value = "ESP32-C3" mock_idf_version.return_value = cv.Version(5, 3, 1) result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.name == "my_device" assert result.friendly_name == "My Device" assert result.comment == "A test device" assert result.address == "192.168.1.50" assert result.web_port == 8080 assert result.target_platform == "ESP32-C3" assert result.build_path == "/build/my_device" assert result.firmware_bin_path == "/build/my_device/firmware.bin" assert result.loaded_integrations == {"wifi", "api"} assert result.loaded_platforms == {"sensor"} assert result.no_mdns is True assert result.framework == "esp-idf" assert result.core_platform == "esp32" assert result.toolchain == "esp-idf" assert result.area == "Living Room" assert result.framework_version == "5.3.1" def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None: """Test from_esphome_core with mDNS enabled.""" mock_core = MagicMock() mock_core.name = "test" mock_core.friendly_name = "Test" mock_core.comment = None mock_core.address = "test.local" mock_core.web_port = None mock_core.target_platform = "esp8266" mock_core.is_esp32 = False mock_core.build_path = "/build" mock_core.firmware_bin = "/build/firmware.bin" mock_core.loaded_integrations = set() mock_core.loaded_platforms = set() mock_core.config = {} # No MDNS config means enabled mock_core.target_framework = "arduino" mock_core.toolchain = None result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None) assert result.no_mdns is False assert result.toolchain is None def test_storage_json_load_valid_file(tmp_path: Path) -> None: """Test StorageJSON.load with valid JSON file.""" storage_data = { "storage_version": 1, "name": "loaded_device", "friendly_name": "Loaded Device", "comment": "Loaded from file", "esphome_version": "2024.1.0", "src_version": 2, "address": "10.0.0.1", "web_port": 8080, "esp_platform": "ESP32", "build_path": "/loaded/build", "firmware_bin_path": "/loaded/firmware.bin", "loaded_integrations": ["wifi", "api"], "loaded_platforms": ["sensor"], "no_mdns": True, "framework": "arduino", "core_platform": "esp32", } file_path = tmp_path / "storage.json" file_path.write_text(json.dumps(storage_data)) result = storage_json.StorageJSON.load(file_path) assert result is not None assert result.name == "loaded_device" assert result.friendly_name == "Loaded Device" assert result.comment == "Loaded from file" assert result.esphome_version == "2024.1.0" assert result.src_version == 2 assert result.address == "10.0.0.1" assert result.web_port == 8080 assert result.target_platform == "ESP32" assert result.build_path == Path("/loaded/build") assert result.firmware_bin_path == Path("/loaded/firmware.bin") assert result.loaded_integrations == {"wifi", "api"} assert result.loaded_platforms == {"sensor"} assert result.no_mdns is True assert result.framework == "arduino" assert result.core_platform == "esp32" def test_storage_json_load_invalid_file(tmp_path: Path) -> None: """Test StorageJSON.load with invalid JSON file.""" file_path = tmp_path / "invalid.json" file_path.write_text("not valid json{") result = storage_json.StorageJSON.load(file_path) assert result is None def test_storage_json_load_nonexistent_file() -> None: """Test StorageJSON.load with non-existent file.""" result = storage_json.StorageJSON.load("/nonexistent/file.json") assert result is None def test_storage_json_equality() -> None: """Test StorageJSON equality comparison.""" storage1 = storage_json.StorageJSON( storage_version=1, name="test", friendly_name="Test", comment=None, esphome_version="2024.1.0", src_version=1, address="test.local", web_port=80, target_platform="ESP32", build_path="/build", firmware_bin_path="/firmware.bin", loaded_integrations={"wifi"}, loaded_platforms=set(), no_mdns=False, ) storage2 = storage_json.StorageJSON( storage_version=1, name="test", friendly_name="Test", comment=None, esphome_version="2024.1.0", src_version=1, address="test.local", web_port=80, target_platform="ESP32", build_path="/build", firmware_bin_path="/firmware.bin", loaded_integrations={"wifi"}, loaded_platforms=set(), no_mdns=False, ) storage3 = storage_json.StorageJSON( storage_version=1, name="different", # Different name friendly_name="Test", comment=None, esphome_version="2024.1.0", src_version=1, address="test.local", web_port=80, target_platform="ESP32", build_path="/build", firmware_bin_path="/firmware.bin", loaded_integrations={"wifi"}, loaded_platforms=set(), no_mdns=False, ) assert storage1 == storage2 assert storage1 != storage3 assert storage1 != "not a storage object" def _make_storage_with_toolchain( toolchain: str | None, ) -> storage_json.StorageJSON: return storage_json.StorageJSON( storage_version=1, name="dev", friendly_name=None, comment=None, esphome_version="2024.1.0", src_version=1, address="dev.local", web_port=None, target_platform="ESP32", build_path=Path("/build"), firmware_bin_path=Path("/build/firmware.bin"), loaded_integrations=set(), loaded_platforms=set(), no_mdns=False, framework="esp-idf", core_platform="esp32", toolchain=toolchain, ) def test_storage_json_toolchain_round_trip(setup_core: Path) -> None: """Sidecar toolchain survives save -> load -> apply_to_core.""" storage = _make_storage_with_toolchain("esp-idf") path = setup_core / "storage.json" path.write_text(storage.to_json()) # Serialization key is stable -- device-builder relies on it. assert json.loads(path.read_text())["toolchain"] == "esp-idf" loaded = storage_json.StorageJSON.load(path) assert loaded is not None assert loaded.toolchain == "esp-idf" CORE.toolchain = None with patch("esphome.components.esp32.get_esp32_variant"): loaded.apply_to_core() assert CORE.toolchain == Toolchain.ESP_IDF def test_storage_json_apply_to_core_preserves_cli_toolchain( setup_core: Path, ) -> None: """A CLI-set CORE.toolchain wins over the sidecar value.""" loaded = _make_storage_with_toolchain("esp-idf") CORE.toolchain = Toolchain.PLATFORMIO with patch("esphome.components.esp32.get_esp32_variant"): loaded.apply_to_core() assert CORE.toolchain == Toolchain.PLATFORMIO def test_storage_json_apply_to_core_ignores_unknown_toolchain( setup_core: Path, ) -> None: """Unknown enum values (corrupt sidecar / newer ESPHome) fall through to None.""" loaded = _make_storage_with_toolchain("gcc") CORE.toolchain = None with patch("esphome.components.esp32.get_esp32_variant"): loaded.apply_to_core() assert CORE.toolchain is None def test_storage_json_framework_version_round_trip(setup_core: Path) -> None: """Sidecar framework_version restores CORE.data[esp32][idf_version].""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION storage = _make_storage_with_toolchain("esp-idf") storage.framework_version = "5.3.1" path = setup_core / "storage.json" path.write_text(storage.to_json()) assert json.loads(path.read_text())["framework_version"] == "5.3.1" loaded = storage_json.StorageJSON.load(path) assert loaded is not None assert loaded.framework_version == "5.3.1" loaded.apply_to_core() assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1) def test_storage_json_apply_to_core_without_framework_version( setup_core: Path, ) -> None: """Older sidecars lacking framework_version don't populate idf_version.""" from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION loaded = _make_storage_with_toolchain("esp-idf") assert loaded.framework_version is None loaded.apply_to_core() assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32] def test_storage_json_apply_to_core_raises_on_invalid_framework_version( setup_core: Path, ) -> None: """A malformed version string fails with an actionable error at parse time.""" from esphome.core import EsphomeError loaded = _make_storage_with_toolchain("esp-idf") loaded.framework_version = "not-a-version" with pytest.raises(EsphomeError, match="clean the build"): loaded.apply_to_core() def test_esphome_storage_json_as_dict() -> None: """Test EsphomeStorageJSON.as_dict returns correct dictionary.""" storage = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="secret123", last_update_check="2024-01-15T10:30:00", remote_version="2024.1.1", ) result = storage.as_dict() assert result["storage_version"] == 1 assert result["cookie_secret"] == "secret123" assert result["last_update_check"] == "2024-01-15T10:30:00" assert result["remote_version"] == "2024.1.1" def test_esphome_storage_json_last_update_check_property() -> None: """Test EsphomeStorageJSON.last_update_check property.""" storage = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="secret", last_update_check="2024-01-15T10:30:00", remote_version=None, ) # Test getter result = storage.last_update_check assert isinstance(result, datetime) assert result.year == 2024 assert result.month == 1 assert result.day == 15 assert result.hour == 10 assert result.minute == 30 # Test setter — naive datetime matches the storage round-trip format. new_date = datetime(2024, 2, 20, 15, 45, 30) # noqa: DTZ001 storage.last_update_check = new_date assert storage.last_update_check_str == "2024-02-20T15:45:30" def test_esphome_storage_json_last_update_check_invalid() -> None: """Test EsphomeStorageJSON.last_update_check with invalid date.""" storage = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="secret", last_update_check="invalid date", remote_version=None, ) result = storage.last_update_check assert result is None def test_esphome_storage_json_to_json() -> None: """Test EsphomeStorageJSON.to_json returns valid JSON string.""" storage = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="mysecret", last_update_check="2024-01-15T10:30:00", remote_version="2024.1.1", ) json_str = storage.to_json() # Should be valid JSON parsed = json.loads(json_str) assert parsed["cookie_secret"] == "mysecret" assert parsed["storage_version"] == 1 # Should end with newline assert json_str.endswith("\n") def test_esphome_storage_json_save(tmp_path: Path) -> None: """Test EsphomeStorageJSON.save writes file correctly.""" storage = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="secret", last_update_check=None, remote_version=None, ) save_path = tmp_path / "esphome.json" with patch("esphome.storage_json.write_file_if_changed") as mock_write: storage.save(str(save_path)) mock_write.assert_called_once_with(str(save_path), storage.to_json()) def test_esphome_storage_json_load_valid_file(tmp_path: Path) -> None: """Test EsphomeStorageJSON.load with valid JSON file.""" storage_data = { "storage_version": 1, "cookie_secret": "loaded_secret", "last_update_check": "2024-01-20T14:30:00", "remote_version": "2024.1.2", } file_path = tmp_path / "esphome.json" file_path.write_text(json.dumps(storage_data)) result = storage_json.EsphomeStorageJSON.load(str(file_path)) assert result is not None assert result.storage_version == 1 assert result.cookie_secret == "loaded_secret" assert result.last_update_check_str == "2024-01-20T14:30:00" assert result.remote_version == "2024.1.2" def test_esphome_storage_json_load_invalid_file(tmp_path: Path) -> None: """Test EsphomeStorageJSON.load with invalid JSON file.""" file_path = tmp_path / "invalid.json" file_path.write_text("not valid json{") result = storage_json.EsphomeStorageJSON.load(str(file_path)) assert result is None def test_esphome_storage_json_load_nonexistent_file() -> None: """Test EsphomeStorageJSON.load with non-existent file.""" result = storage_json.EsphomeStorageJSON.load("/nonexistent/file.json") assert result is None def test_esphome_storage_json_get_default() -> None: """Test EsphomeStorageJSON.get_default creates default storage.""" with patch("esphome.storage_json.os.urandom") as mock_urandom: # Mock urandom to return predictable bytes mock_urandom.return_value = b"test" * 16 # 64 bytes result = storage_json.EsphomeStorageJSON.get_default() assert result.storage_version == 1 assert len(result.cookie_secret) == 128 # 64 bytes hex = 128 chars assert result.last_update_check is None assert result.remote_version is None def test_esphome_storage_json_equality() -> None: """Test EsphomeStorageJSON equality comparison.""" storage1 = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="secret", last_update_check="2024-01-15T10:30:00", remote_version="2024.1.1", ) storage2 = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="secret", last_update_check="2024-01-15T10:30:00", remote_version="2024.1.1", ) storage3 = storage_json.EsphomeStorageJSON( storage_version=1, cookie_secret="different", # Different secret last_update_check="2024-01-15T10:30:00", remote_version="2024.1.1", ) assert storage1 == storage2 assert storage1 != storage3 assert storage1 != "not a storage object" def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None: """Test loading storage with legacy esphomeyaml_version field.""" storage_data = { "storage_version": 1, "name": "legacy_device", "friendly_name": "Legacy Device", "esphomeyaml_version": "1.14.0", # Legacy field name "address": "legacy.local", "esp_platform": "ESP8266", } file_path = tmp_path / "legacy.json" file_path.write_text(json.dumps(storage_data)) result = storage_json.StorageJSON.load(file_path) assert result is not None assert result.esphome_version == "1.14.0" # Should map to esphome_version def test_storage_json_load_area(tmp_path: Path) -> None: """``area`` round-trips through load; absence loads as None.""" file_path = tmp_path / "with_area.json" file_path.write_text( json.dumps( { "storage_version": 1, "name": "lamp", "friendly_name": "Lamp", "esp_platform": "ESP32", "area": "Living Room", } ) ) result = storage_json.StorageJSON.load(file_path) assert result is not None assert result.area == "Living Room" legacy_path = tmp_path / "no_area.json" legacy_path.write_text( json.dumps( { "storage_version": 1, "name": "lamp", "friendly_name": "Lamp", "esp_platform": "ESP32", } ) ) legacy = storage_json.StorageJSON.load(legacy_path) assert legacy is not None assert legacy.area is None