core/tests/helpers/test_storage.py

1183 lines
41 KiB
Python
Raw Normal View History

"""Tests for the storage helper."""
import asyncio
from datetime import timedelta
import json
import os
2023-02-20 10:42:56 +00:00
from typing import Any, NamedTuple
2021-01-01 21:31:56 +00:00
from unittest.mock import Mock, patch
from freezegun.api import FrozenDateTimeFactory
import py
import pytest
from homeassistant.const import (
EVENT_HOMEASSISTANT_FINAL_WRITE,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir, storage
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util
from homeassistant.util.color import RGBColor
from tests.common import (
async_fire_time_changed,
async_fire_time_changed_exact,
async_test_home_assistant,
)
MOCK_VERSION = 1
MOCK_VERSION_2 = 2
MOCK_MINOR_VERSION_1 = 1
MOCK_MINOR_VERSION_2 = 2
2019-07-31 19:25:30 +00:00
MOCK_KEY = "storage-test"
MOCK_DATA = {"hello": "world"}
MOCK_DATA2 = {"goodbye": "cruel world"}
@pytest.fixture
def store(hass: HomeAssistant) -> storage.Store:
"""Fixture of a store that prevents writing on Home Assistant stop."""
2021-11-18 23:56:22 +00:00
return storage.Store(hass, MOCK_VERSION, MOCK_KEY)
@pytest.fixture
def store_v_1_1(hass: HomeAssistant) -> storage.Store:
"""Fixture of a store that prevents writing on Home Assistant stop."""
2021-11-18 23:56:22 +00:00
return storage.Store(
hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
@pytest.fixture
def store_v_1_2(hass: HomeAssistant) -> storage.Store:
"""Fixture of a store that prevents writing on Home Assistant stop."""
2021-11-18 23:56:22 +00:00
return storage.Store(
hass, MOCK_VERSION, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_2
)
@pytest.fixture
def store_v_2_1(hass: HomeAssistant) -> storage.Store:
"""Fixture of a store that prevents writing on Home Assistant stop."""
2021-11-18 23:56:22 +00:00
return storage.Store(
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
@pytest.fixture
def read_only_store(hass: HomeAssistant) -> storage.Store:
"""Fixture of a read only store."""
return storage.Store(hass, MOCK_VERSION, MOCK_KEY, read_only=True)
async def test_loading(hass: HomeAssistant, store: storage.Store) -> None:
"""Test we can save and load data."""
await store.async_save(MOCK_DATA)
data = await store.async_load()
assert data == MOCK_DATA
async def test_custom_encoder(hass: HomeAssistant) -> None:
"""Test we can save and load data."""
2019-07-31 19:25:30 +00:00
class JSONEncoder(json.JSONEncoder):
"""Mock JSON encoder."""
def default(self, o):
"""Mock JSON encode method."""
return "9"
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY, encoder=JSONEncoder)
2023-01-30 13:06:52 +00:00
with pytest.raises(TypeError):
await store.async_save(Mock())
await store.async_save(object())
data = await store.async_load()
assert data == "9"
async def test_loading_non_existing(hass: HomeAssistant, store: storage.Store) -> None:
"""Test we can save and load data."""
2019-07-31 19:25:30 +00:00
with patch("homeassistant.util.json.open", side_effect=FileNotFoundError):
data = await store.async_load()
assert data is None
2023-02-20 10:42:56 +00:00
async def test_loading_parallel(
hass: HomeAssistant,
store: storage.Store,
2023-02-20 10:42:56 +00:00
hass_storage: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test we can save and load data."""
2019-07-31 19:25:30 +00:00
hass_storage[store.key] = {"version": MOCK_VERSION, "data": MOCK_DATA}
2019-07-31 19:25:30 +00:00
results = await asyncio.gather(store.async_load(), store.async_load())
2021-11-18 23:56:22 +00:00
assert results[0] == MOCK_DATA
assert results[1] == MOCK_DATA
assert caplog.text.count(f"Loading data for {store.key}")
2023-02-20 10:42:56 +00:00
async def test_saving_with_delay(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test saving data after a delay."""
store.async_delay_save(lambda: MOCK_DATA, 1)
assert store.key not in hass_storage
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"data": MOCK_DATA,
}
async def test_saving_with_delay_churn_reduction(
hass: HomeAssistant,
store: storage.Store,
hass_storage: dict[str, Any],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test saving data after a delay with timer churn reduction."""
store.async_delay_save(lambda: MOCK_DATA, 1)
assert store.key not in hass_storage
freezer.tick(0.2)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key not in hass_storage
freezer.tick(1)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY,
"data": MOCK_DATA,
}
del hass_storage[store.key]
# Simulate what some of the registries do when they add 100 entities
for _ in range(100):
store.async_delay_save(lambda: MOCK_DATA, 1)
freezer.tick(0.2)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key not in hass_storage
store.async_delay_save(lambda: MOCK_DATA, 1)
freezer.tick(1)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key in hass_storage
del hass_storage[store.key]
store.async_delay_save(lambda: MOCK_DATA, 1)
freezer.tick(0.5)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key not in hass_storage
store.async_delay_save(lambda: MOCK_DATA, 1)
freezer.tick(0.8)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key not in hass_storage
store.async_delay_save(lambda: MOCK_DATA, 1)
freezer.tick(0.8)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key not in hass_storage
freezer.tick(0.2)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key in hass_storage
# Make sure if we do another delayed save
# and one with a shorter delay, the shorter delay wins
del hass_storage[store.key]
store.async_delay_save(lambda: MOCK_DATA, 2)
freezer.tick(0.2)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key not in hass_storage
store.async_delay_save(lambda: MOCK_DATA, 1)
freezer.tick(1.0)
async_fire_time_changed_exact(hass)
await hass.async_block_till_done()
assert store.key in hass_storage
2023-02-20 10:42:56 +00:00
async def test_saving_on_final_write(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test delayed saves trigger when we quit Home Assistant."""
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
store.async_delay_save(lambda: MOCK_DATA, 5)
assert store.key not in hass_storage
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
hass.set_state(CoreState.stopping)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert store.key not in hass_storage
hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
await hass.async_block_till_done()
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"data": MOCK_DATA,
}
2023-02-20 10:42:56 +00:00
async def test_not_delayed_saving_while_stopping(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test delayed saves don't write after the stop event has fired."""
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
hass.set_state(CoreState.stopping)
store.async_delay_save(lambda: MOCK_DATA, 1)
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
await hass.async_block_till_done()
assert store.key not in hass_storage
2023-02-20 10:42:56 +00:00
async def test_not_delayed_saving_after_stopping(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test delayed saves don't write after stop if issued before stopping Home Assistant."""
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
store.async_delay_save(lambda: MOCK_DATA, 10)
assert store.key not in hass_storage
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
hass.set_state(CoreState.stopping)
await hass.async_block_till_done()
assert store.key not in hass_storage
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15))
await hass.async_block_till_done()
assert store.key not in hass_storage
2023-02-20 10:42:56 +00:00
async def test_not_saving_while_stopping(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test saves don't write when stopping Home Assistant."""
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
hass.set_state(CoreState.stopping)
await store.async_save(MOCK_DATA)
assert store.key not in hass_storage
2023-02-20 10:42:56 +00:00
async def test_loading_while_delay(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test we load new data even if not written yet."""
2019-07-31 19:25:30 +00:00
await store.async_save({"delay": "no"})
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"data": {"delay": "no"},
}
2019-07-31 19:25:30 +00:00
store.async_delay_save(lambda: {"delay": "yes"}, 1)
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"data": {"delay": "no"},
}
data = await store.async_load()
2019-07-31 19:25:30 +00:00
assert data == {"delay": "yes"}
2023-02-20 10:42:56 +00:00
async def test_writing_while_writing_delay(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test a write while a write with delay is active."""
2019-07-31 19:25:30 +00:00
store.async_delay_save(lambda: {"delay": "yes"}, 1)
assert store.key not in hass_storage
2019-07-31 19:25:30 +00:00
await store.async_save({"delay": "no"})
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"data": {"delay": "no"},
}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"data": {"delay": "no"},
}
data = await store.async_load()
2019-07-31 19:25:30 +00:00
assert data == {"delay": "no"}
2023-02-20 10:42:56 +00:00
async def test_multiple_delay_save_calls(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test a write while a write with changing delays."""
store.async_delay_save(lambda: {"delay": "yes"}, 1)
store.async_delay_save(lambda: {"delay": "yes"}, 2)
store.async_delay_save(lambda: {"delay": "yes"}, 3)
assert store.key not in hass_storage
await store.async_save({"delay": "no"})
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY,
"data": {"delay": "no"},
}
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY,
"data": {"delay": "no"},
}
data = await store.async_load()
assert data == {"delay": "no"}
async def test_delay_save_zero(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
) -> None:
"""Test async_delay_save accepts 0."""
store.async_delay_save(lambda: {"delay": "0"}, 0)
# sleep is to run one event loop to get the task scheduled
await asyncio.sleep(0)
await hass.async_block_till_done()
assert store.key in hass_storage
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY,
"data": {"delay": "0"},
}
2023-02-20 10:42:56 +00:00
async def test_multiple_save_calls(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test multiple write tasks."""
assert store.key not in hass_storage
tasks = [store.async_save({"savecount": savecount}) for savecount in range(6)]
await asyncio.gather(*tasks)
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY,
"data": {"savecount": 5},
}
data = await store.async_load()
assert data == {"savecount": 5}
2023-02-20 10:42:56 +00:00
async def test_migrator_no_existing_config(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test migrator with no existing config."""
with (
patch("os.path.isfile", return_value=False),
patch.object(store, "async_load", return_value={"cur": "config"}),
2019-07-31 19:25:30 +00:00
):
data = await storage.async_migrator(hass, "old-path", store)
2019-07-31 19:25:30 +00:00
assert data == {"cur": "config"}
assert store.key not in hass_storage
2023-02-20 10:42:56 +00:00
async def test_migrator_existing_config(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test migrating existing config."""
2019-07-31 19:25:30 +00:00
with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove:
data = await storage.async_migrator(
2019-07-31 19:25:30 +00:00
hass, "old-path", store, old_conf_load_func=lambda _: {"old": "config"}
)
assert len(mock_remove.mock_calls) == 1
2019-07-31 19:25:30 +00:00
assert data == {"old": "config"}
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"data": data,
}
2023-02-20 10:42:56 +00:00
async def test_migrator_transforming_config(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test migrating config to new format."""
2019-07-31 19:25:30 +00:00
async def old_conf_migrate_func(old_config):
"""Migrate old config to new format."""
2019-07-31 19:25:30 +00:00
return {"new": old_config["old"]}
2019-07-31 19:25:30 +00:00
with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove:
data = await storage.async_migrator(
2019-07-31 19:25:30 +00:00
hass,
"old-path",
store,
old_conf_migrate_func=old_conf_migrate_func,
2019-07-31 19:25:30 +00:00
old_conf_load_func=lambda _: {"old": "config"},
)
assert len(mock_remove.mock_calls) == 1
2019-07-31 19:25:30 +00:00
assert data == {"new": "config"}
assert hass_storage[store.key] == {
2019-07-31 19:25:30 +00:00
"key": MOCK_KEY,
"version": MOCK_VERSION,
"minor_version": 1,
2019-07-31 19:25:30 +00:00
"data": data,
}
2023-02-20 10:42:56 +00:00
async def test_minor_version_default(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test minor version default."""
await store.async_save(MOCK_DATA)
assert hass_storage[store.key]["minor_version"] == 1
2023-02-20 10:42:56 +00:00
async def test_minor_version(
hass: HomeAssistant, store_v_1_2: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
"""Test minor version."""
await store_v_1_2.async_save(MOCK_DATA)
assert hass_storage[store_v_1_2.key]["minor_version"] == MOCK_MINOR_VERSION_2
2023-02-20 10:42:56 +00:00
async def test_migrate_major_not_implemented_raises(
hass: HomeAssistant, store: storage.Store, store_v_2_1: storage.Store
2023-02-20 10:42:56 +00:00
) -> None:
"""Test migrating between major versions fails if not implemented."""
await store_v_2_1.async_save(MOCK_DATA)
with pytest.raises(NotImplementedError):
await store.async_load()
async def test_migrate_minor_not_implemented(
hass: HomeAssistant,
hass_storage: dict[str, Any],
store_v_1_1: storage.Store,
store_v_1_2: storage.Store,
2023-02-20 10:42:56 +00:00
) -> None:
"""Test migrating between minor versions does not fail if not implemented."""
assert store_v_1_1.key == store_v_1_2.key
await store_v_1_1.async_save(MOCK_DATA)
assert hass_storage[store_v_1_1.key] == {
"key": MOCK_KEY,
"version": MOCK_VERSION,
"minor_version": MOCK_MINOR_VERSION_1,
"data": MOCK_DATA,
}
data = await store_v_1_2.async_load()
assert hass_storage[store_v_1_1.key]["data"] == data
await store_v_1_2.async_save(MOCK_DATA)
assert hass_storage[store_v_1_2.key] == {
"key": MOCK_KEY,
"version": MOCK_VERSION,
"minor_version": MOCK_MINOR_VERSION_2,
"data": MOCK_DATA,
}
2023-02-20 10:42:56 +00:00
async def test_migration(
hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2: storage.Store
2023-02-20 10:42:56 +00:00
) -> None:
"""Test migration."""
calls = 0
class CustomStore(storage.Store):
async def _async_migrate_func(
self, old_major_version, old_minor_version, old_data: dict
):
nonlocal calls
calls += 1
assert old_major_version == store_v_1_2.version
assert old_minor_version == store_v_1_2.minor_version
return old_data
await store_v_1_2.async_save(MOCK_DATA)
assert hass_storage[store_v_1_2.key] == {
"key": MOCK_KEY,
"version": MOCK_VERSION,
"minor_version": MOCK_MINOR_VERSION_2,
"data": MOCK_DATA,
}
assert calls == 0
2022-11-28 14:42:08 +00:00
custom_store = CustomStore(hass, 2, store_v_1_2.key, minor_version=1)
data = await custom_store.async_load()
assert calls == 1
assert hass_storage[store_v_1_2.key]["data"] == data
2022-11-28 14:42:08 +00:00
# Assert the migrated data has been saved
assert hass_storage[custom_store.key] == {
"key": MOCK_KEY,
"version": 2,
"minor_version": 1,
"data": MOCK_DATA,
}
2023-02-20 10:42:56 +00:00
async def test_legacy_migration(
hass: HomeAssistant, hass_storage: dict[str, Any], store_v_1_2: storage.Store
2023-02-20 10:42:56 +00:00
) -> None:
"""Test legacy migration method signature."""
calls = 0
class LegacyStore(storage.Store):
async def _async_migrate_func(self, old_version, old_data: dict):
nonlocal calls
calls += 1
assert old_version == store_v_1_2.version
return old_data
await store_v_1_2.async_save(MOCK_DATA)
assert hass_storage[store_v_1_2.key] == {
"key": MOCK_KEY,
"version": MOCK_VERSION,
"minor_version": MOCK_MINOR_VERSION_2,
"data": MOCK_DATA,
}
assert calls == 0
legacy_store = LegacyStore(hass, 2, store_v_1_2.key, minor_version=1)
data = await legacy_store.async_load()
assert calls == 1
assert hass_storage[store_v_1_2.key]["data"] == data
2022-11-28 14:42:08 +00:00
# Assert the migrated data has been saved
assert hass_storage[legacy_store.key] == {
"key": MOCK_KEY,
"version": 2,
"minor_version": 1,
"data": MOCK_DATA,
}
2021-11-18 23:56:22 +00:00
2023-02-20 10:42:56 +00:00
async def test_changing_delayed_written_data(
hass: HomeAssistant, store: storage.Store, hass_storage: dict[str, Any]
2023-02-20 10:42:56 +00:00
) -> None:
2021-11-18 23:56:22 +00:00
"""Test changing data that is written with delay."""
data_to_store = {"hello": "world"}
store.async_delay_save(lambda: data_to_store, 1)
assert store.key not in hass_storage
loaded_data = await store.async_load()
assert loaded_data == data_to_store
assert loaded_data is not data_to_store
loaded_data["hello"] = "earth"
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
2021-11-18 23:56:22 +00:00
await hass.async_block_till_done()
assert hass_storage[store.key] == {
"version": MOCK_VERSION,
"minor_version": 1,
"key": MOCK_KEY,
"data": {"hello": "world"},
}
async def test_saving_load_round_trip(tmpdir: py.path.local) -> None:
"""Test saving and loading round trip."""
loop = asyncio.get_running_loop()
config_dir = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
class NamedTupleSubclass(NamedTuple):
"""A NamedTuple subclass."""
name: str
nts = NamedTupleSubclass("a")
data = {
"named_tuple_subclass": nts,
"rgb_color": RGBColor(255, 255, 0),
"set": {1, 2, 3},
"list": [1, 2, 3],
"tuple": (1, 2, 3),
"dict_with_int": {1: 1, 2: 2},
"dict_with_named_tuple": {1: nts, 2: nts},
}
store = storage.Store(
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
await store.async_save(data)
load = await store.async_load()
assert load == {
"dict_with_int": {"1": 1, "2": 2},
"dict_with_named_tuple": {"1": ["a"], "2": ["a"]},
"list": [1, 2, 3],
"named_tuple_subclass": ["a"],
"rgb_color": [255, 255, 0],
"set": [1, 2, 3],
"tuple": [1, 2, 3],
}
await hass.async_stop(force=True)
async def test_loading_corrupt_core_file(
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
) -> None:
"""Test we handle unrecoverable corruption in a core file."""
loop = asyncio.get_running_loop()
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
storage_key = "core.anything"
store = storage.Store(
hass, MOCK_VERSION_2, storage_key, minor_version=MOCK_MINOR_VERSION_1
)
await store.async_save({"hello": "world"})
storage_path = os.path.join(tmp_storage, ".storage")
store_file = os.path.join(storage_path, store.key)
data = await store.async_load()
assert data == {"hello": "world"}
def _corrupt_store():
with open(store_file, "w", encoding="utf8") as f:
f.write("corrupt")
await hass.async_add_executor_job(_corrupt_store)
data = await store.async_load()
assert data is None
assert "Unrecoverable error decoding storage" in caplog.text
issue_registry = ir.async_get(hass)
found_issue = None
issue_entry = None
for (domain, issue), entry in issue_registry.issues.items():
if domain == HOMEASSISTANT_DOMAIN and issue.startswith(
f"storage_corruption_{storage_key}_"
):
found_issue = issue
issue_entry = entry
break
assert found_issue is not None
assert issue_entry is not None
assert issue_entry.is_fixable is True
assert issue_entry.translation_placeholders["storage_key"] == storage_key
assert issue_entry.issue_domain == HOMEASSISTANT_DOMAIN
assert (
"unexpected character: line 1 column 1 (char 0)"
in issue_entry.translation_placeholders["error"]
)
files = await hass.async_add_executor_job(
os.listdir, os.path.join(tmp_storage, ".storage")
)
assert ".corrupt" in files[0]
await hass.async_stop(force=True)
async def test_loading_corrupt_file_known_domain(
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
) -> None:
"""Test we handle unrecoverable corruption for a known domain."""
loop = asyncio.get_running_loop()
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
hass.config.components.add("testdomain")
storage_key = "testdomain.testkey"
store = storage.Store(
hass, MOCK_VERSION_2, storage_key, minor_version=MOCK_MINOR_VERSION_1
)
await store.async_save({"hello": "world"})
storage_path = os.path.join(tmp_storage, ".storage")
store_file = os.path.join(storage_path, store.key)
data = await store.async_load()
assert data == {"hello": "world"}
def _corrupt_store():
with open(store_file, "w", encoding="utf8") as f:
f.write('{"valid":"json"}..with..corrupt')
await hass.async_add_executor_job(_corrupt_store)
data = await store.async_load()
assert data is None
assert "Unrecoverable error decoding storage" in caplog.text
issue_registry = ir.async_get(hass)
found_issue = None
issue_entry = None
for (domain, issue), entry in issue_registry.issues.items():
if domain == HOMEASSISTANT_DOMAIN and issue.startswith(
f"storage_corruption_{storage_key}_"
):
found_issue = issue
issue_entry = entry
break
assert found_issue is not None
assert issue_entry is not None
assert issue_entry.is_fixable is True
assert issue_entry.translation_placeholders["storage_key"] == storage_key
assert issue_entry.issue_domain == "testdomain"
assert (
"unexpected content after document: line 1 column 17 (char 16)"
in issue_entry.translation_placeholders["error"]
)
files = await hass.async_add_executor_job(
os.listdir, os.path.join(tmp_storage, ".storage")
)
assert ".corrupt" in files[0]
await hass.async_stop(force=True)
async def test_os_error_is_fatal(tmpdir: py.path.local) -> None:
"""Test OSError during load is fatal."""
loop = asyncio.get_running_loop()
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
store = storage.Store(
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
await store.async_save({"hello": "world"})
with (
pytest.raises(OSError),
patch(
"homeassistant.helpers.storage.json_util.load_json", side_effect=OSError
),
):
await store.async_load()
# Verify second load is also failing
with (
pytest.raises(OSError),
patch(
"homeassistant.helpers.storage.json_util.load_json", side_effect=OSError
),
):
await store.async_load()
await hass.async_stop(force=True)
async def test_json_load_failure(tmpdir: py.path.local) -> None:
"""Test json load raising HomeAssistantError."""
loop = asyncio.get_running_loop()
tmp_storage = await loop.run_in_executor(None, tmpdir.mkdir, "temp_storage")
async with async_test_home_assistant(config_dir=tmp_storage.strpath) as hass:
store = storage.Store(
hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1
)
await store.async_save({"hello": "world"})
base_os_error = OSError()
base_os_error.errno = 30
home_assistant_error = HomeAssistantError()
home_assistant_error.__cause__ = base_os_error
with (
pytest.raises(HomeAssistantError),
patch(
"homeassistant.helpers.storage.json_util.load_json",
side_effect=home_assistant_error,
),
):
await store.async_load()
await hass.async_stop(force=True)
async def test_read_only_store(
hass: HomeAssistant, read_only_store: storage.Store, hass_storage: dict[str, Any]
) -> None:
"""Test store opened in read only mode does not save."""
read_only_store.async_delay_save(lambda: MOCK_DATA, 1)
assert read_only_store.key not in hass_storage
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert read_only_store.key not in hass_storage
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
hass.set_state(CoreState.stopping)
await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
await hass.async_block_till_done()
assert read_only_store.key not in hass_storage
hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE)
await hass.async_block_till_done()
assert read_only_store.key not in hass_storage
async def test_store_manager_caching(
tmpdir: py.path.local, caplog: pytest.LogCaptureFixture
) -> None:
"""Test store manager caching."""
loop = asyncio.get_running_loop()
def _setup_mock_storage():
config_dir = tmpdir.mkdir("temp_config")
tmp_storage = config_dir.mkdir(".storage")
tmp_storage.join("integration1").write_binary(
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
)
tmp_storage.join("integration2").write_binary(
json_bytes({"data": {"integration2": "integration2"}, "version": 1})
)
tmp_storage.join("broken").write_binary(b"invalid")
return config_dir
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
store_manager = storage.get_internal_store_manager(hass)
assert (
store_manager.async_fetch("integration1") is None
) # has data but not cached
assert (
store_manager.async_fetch("integration2") is None
) # has data but not cached
assert (
store_manager.async_fetch("integration3") is None
) # no file not but cached
await store_manager.async_initialize()
assert (
store_manager.async_fetch("integration1") is None
) # has data but not cached
assert (
store_manager.async_fetch("integration2") is None
) # has data but not cached
assert (
store_manager.async_fetch("integration3") is not None
) # no file and initialized
result = store_manager.async_fetch("integration3")
assert result is not None
exists, data = result
assert exists is False
assert data is None
await store_manager.async_preload(["integration3", "integration2", "broken"])
assert "Error loading broken" in caplog.text
assert (
store_manager.async_fetch("integration1") is None
) # has data but not cached
result = store_manager.async_fetch("integration2")
assert result is not None
exists, data = result
assert exists is True
assert data == {"data": {"integration2": "integration2"}, "version": 1}
assert (
store_manager.async_fetch("integration3") is not None
) # no file and initialized
result = store_manager.async_fetch("integration3")
assert result is not None
exists, data = result
assert exists is False
assert data is None
integration1 = storage.Store(hass, 1, "integration1")
await integration1.async_save({"integration1": "updated"})
# Save should invalidate the cache
assert store_manager.async_fetch("integration1") is None # invalidated
integration2 = storage.Store(hass, 1, "integration2")
integration2.async_delay_save(lambda: {"integration2": "updated"})
# Delay save should invalidate the cache after it saves
assert "integration2" not in store_manager._invalidated
# Block twice to flush out the delayed save
await hass.async_block_till_done()
await hass.async_block_till_done()
assert store_manager.async_fetch("integration2") is None # invalidated
store_manager.async_invalidate("integration3")
assert store_manager.async_fetch("integration1") is None # invalidated by save
assert (
store_manager.async_fetch("integration2") is None
) # invalidated by delay save
assert store_manager.async_fetch("integration3") is None # invalidated
await hass.async_stop(force=True)
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
store_manager = storage.get_internal_store_manager(hass)
assert store_manager.async_fetch("integration1") is None
assert store_manager.async_fetch("integration2") is None
assert store_manager.async_fetch("integration3") is None
await store_manager.async_initialize()
await store_manager.async_preload(["integration1", "integration2"])
result = store_manager.async_fetch("integration1")
assert result is not None
exists, data = result
assert exists is True
assert data["data"] == {"integration1": "updated"}
integration1 = storage.Store(hass, 1, "integration1")
assert await integration1.async_load() == {"integration1": "updated"}
# Load should pop the cache
assert store_manager.async_fetch("integration1") is None
integration2 = storage.Store(hass, 1, "integration2")
assert await integration2.async_load() == {"integration2": "updated"}
# Load should pop the cache
assert store_manager.async_fetch("integration2") is None
integration3 = storage.Store(hass, 1, "integration3")
assert await integration3.async_load() is None
await integration3.async_save({"integration3": "updated"})
assert await integration3.async_load() == {"integration3": "updated"}
await hass.async_stop(force=True)
# Now make sure everything still works when we do not
# manually load the storage manager
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
integration1 = storage.Store(hass, 1, "integration1")
assert await integration1.async_load() == {"integration1": "updated"}
await integration1.async_save({"integration1": "updated2"})
assert await integration1.async_load() == {"integration1": "updated2"}
integration2 = storage.Store(hass, 1, "integration2")
assert await integration2.async_load() == {"integration2": "updated"}
await integration2.async_save({"integration2": "updated2"})
assert await integration2.async_load() == {"integration2": "updated2"}
await hass.async_stop(force=True)
# Now remove the stores
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
store_manager = storage.get_internal_store_manager(hass)
await store_manager.async_initialize()
await store_manager.async_preload(["integration1", "integration2"])
integration1 = storage.Store(hass, 1, "integration1")
assert integration1._manager is store_manager
assert await integration1.async_load() == {"integration1": "updated2"}
integration2 = storage.Store(hass, 1, "integration2")
assert integration2._manager is store_manager
assert await integration2.async_load() == {"integration2": "updated2"}
await integration1.async_remove()
await integration2.async_remove()
assert store_manager.async_fetch("integration1") is None
assert store_manager.async_fetch("integration2") is None
assert await integration1.async_load() is None
assert await integration2.async_load() is None
await hass.async_stop(force=True)
# Now make sure the stores are removed and another run works
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
store_manager = storage.get_internal_store_manager(hass)
await store_manager.async_initialize()
await store_manager.async_preload(["integration1"])
result = store_manager.async_fetch("integration1")
assert result is not None
exists, data = result
assert exists is False
assert data is None
await hass.async_stop(force=True)
async def test_store_manager_sub_dirs(tmpdir: py.path.local) -> None:
"""Test store manager ignores subdirs."""
loop = asyncio.get_running_loop()
def _setup_mock_storage():
config_dir = tmpdir.mkdir("temp_config")
sub_dir_storage = config_dir.mkdir(".storage").mkdir("subdir")
sub_dir_storage.join("integration1").write_binary(
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
)
return config_dir
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
store_manager = storage.get_internal_store_manager(hass)
await store_manager.async_initialize()
assert store_manager.async_fetch("subdir/integration1") is None
assert store_manager.async_fetch("subdir/integrationx") is None
integration1 = storage.Store(hass, 1, "subdir/integration1")
assert await integration1.async_load() == {"integration1": "integration1"}
await hass.async_stop(force=True)
async def test_store_manager_cleanup_after_started(
tmpdir: py.path.local, freezer: FrozenDateTimeFactory
) -> None:
"""Test that the cache is cleaned up after startup."""
loop = asyncio.get_running_loop()
def _setup_mock_storage():
config_dir = tmpdir.mkdir("temp_config")
tmp_storage = config_dir.mkdir(".storage")
tmp_storage.join("integration1").write_binary(
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
)
tmp_storage.join("integration2").write_binary(
json_bytes({"data": {"integration2": "integration2"}, "version": 1})
)
return config_dir
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
hass.set_state(CoreState.not_running)
store_manager = storage.get_internal_store_manager(hass)
await store_manager.async_initialize()
await store_manager.async_preload(["integration1", "integration2"])
assert "integration1" in store_manager._data_preload
assert "integration2" in store_manager._data_preload
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
assert "integration1" in store_manager._data_preload
assert "integration2" in store_manager._data_preload
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert "integration1" in store_manager._data_preload
assert "integration2" in store_manager._data_preload
freezer.tick(storage.MANAGER_CLEANUP_DELAY)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# The cache should be removed after the cleanup delay
# since it means nothing ever loaded it and we want to
# recover the memory
assert "integration1" not in store_manager._data_preload
assert "integration2" not in store_manager._data_preload
assert store_manager.async_fetch("integration1") is None
assert store_manager.async_fetch("integration2") is None
await hass.async_stop(force=True)
async def test_store_manager_cleanup_after_stop(
tmpdir: py.path.local, freezer: FrozenDateTimeFactory
) -> None:
"""Test that the cache is cleaned up after stop event.
This should only happen if we stop within the cleanup delay.
"""
loop = asyncio.get_running_loop()
def _setup_mock_storage():
config_dir = tmpdir.mkdir("temp_config")
tmp_storage = config_dir.mkdir(".storage")
tmp_storage.join("integration1").write_binary(
json_bytes({"data": {"integration1": "integration1"}, "version": 1})
)
tmp_storage.join("integration2").write_binary(
json_bytes({"data": {"integration2": "integration2"}, "version": 1})
)
return config_dir
config_dir = await loop.run_in_executor(None, _setup_mock_storage)
async with async_test_home_assistant(config_dir=config_dir.strpath) as hass:
hass.set_state(CoreState.not_running)
store_manager = storage.get_internal_store_manager(hass)
await store_manager.async_initialize()
await store_manager.async_preload(["integration1", "integration2"])
assert "integration1" in store_manager._data_preload
assert "integration2" in store_manager._data_preload
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
await hass.async_block_till_done()
assert "integration1" in store_manager._data_preload
assert "integration2" in store_manager._data_preload
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert "integration1" in store_manager._data_preload
assert "integration2" in store_manager._data_preload
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
assert "integration1" not in store_manager._data_preload
assert "integration2" not in store_manager._data_preload
assert store_manager.async_fetch("integration1") is None
assert store_manager.async_fetch("integration2") is None
await hass.async_stop(force=True)
async def test_storage_concurrent_load(hass: HomeAssistant) -> None:
"""Test that we can load the store concurrently."""
store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
async def _load_store():
await asyncio.sleep(0)
return "data"
with patch.object(store, "_async_load", side_effect=_load_store):
# Test that we can load the store concurrently
loads = await asyncio.gather(
store.async_load(), store.async_load(), store.async_load()
)
for load in loads:
assert load == "data"