From ec0fc2916056936b1cdfd067f0950b9824d5fb97 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 15:10:33 -0400 Subject: [PATCH 1/3] feat: add executor-backed SQLite path resolver helper --- custom_components/nestquest/store.py | 20 +++++ tests/test_store.py | 116 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 custom_components/nestquest/store.py create mode 100644 tests/test_store.py diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py new file mode 100644 index 0000000..00caa59 --- /dev/null +++ b/custom_components/nestquest/store.py @@ -0,0 +1,20 @@ +"""SQLite storage path resolution for the NestQuest integration.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.core import HomeAssistant + +from .const import SQLITE_DB_FILENAME + + +def _ensure_dir(path: Path) -> None: + """Create the given directory and any missing parents.""" + path.mkdir(parents=True, exist_ok=True) + + +async def async_get_db_path(hass: HomeAssistant) -> Path: + """Return the absolute path to the NestQuest SQLite database file.""" + db_path = Path(hass.config.path(SQLITE_DB_FILENAME)) + await hass.async_add_executor_job(_ensure_dir, db_path.parent) + return db_path \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..ceac18a --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,116 @@ +"""Tests for store.py of NestQuest.""" +from __future__ import annotations + +import ast +import asyncio +from pathlib import Path +import sys +from unittest.mock import AsyncMock, MagicMock + +# Mock homeassistant modules if not installed in environment +for _mod in ( + "homeassistant", + "homeassistant.core", + "homeassistant.config_entries", +): + sys.modules.setdefault(_mod, MagicMock()) + +from custom_components.nestquest import store +from custom_components.nestquest.const import SQLITE_DB_FILENAME + + +def _run(coro): + return asyncio.new_event_loop().run_until_complete(coro) + + +def _make_hass(config_dir: Path, execute: bool = True): + hass = MagicMock() + hass.config.path = MagicMock(return_value=str(config_dir / SQLITE_DB_FILENAME)) + hass.async_add_executor_job = AsyncMock( + side_effect=(lambda fn, *a: fn(*a)) if execute else None + ) + return hass + + +def test_returns_absolute_path_from_config_path() -> None: + hass = _make_hass(Path("/config"), execute=False) + db_path = _run(store.async_get_db_path(hass)) + hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME) + assert db_path == Path("/config") / SQLITE_DB_FILENAME + assert db_path.is_absolute() + + +def test_creates_missing_parent_directory(tmp_path) -> None: + nested = tmp_path / "homeassistant" / "config" + hass = _make_hass(nested) + db_path = _run(store.async_get_db_path(hass)) + assert db_path.parent == nested + assert nested.is_dir() + + +def test_two_calls_return_same_path(tmp_path) -> None: + hass = _make_hass(tmp_path) + first = _run(store.async_get_db_path(hass)) + second = _run(store.async_get_db_path(hass)) + assert first == second + assert first.parent.is_dir() + + +def test_existing_directory_does_not_fail(tmp_path) -> None: + nested = tmp_path / "config" + nested.mkdir() + hass = _make_hass(nested) + assert _run(store.async_get_db_path(hass)) == nested / SQLITE_DB_FILENAME + + +def test_mkdir_runs_via_executor(tmp_path) -> None: + nested = tmp_path / "deep" / "nested" + hass = _make_hass(nested) + db_path = _run(store.async_get_db_path(hass)) + assert nested.is_dir() + hass.async_add_executor_job.assert_called_once() + args, kwargs = hass.async_add_executor_job.call_args + fn, fn_args = args[0], args[1:] + assert callable(fn) + assert fn is store._ensure_dir + assert fn_args == (nested,) + assert kwargs == {} + assert db_path.parent == nested + + +def test_executor_helper_creates_directory(tmp_path) -> None: + nested = tmp_path / "executor" / "made" + store._ensure_dir(nested) + assert nested.is_dir() + store._ensure_dir(nested) + assert nested.is_dir() + + +def test_no_sqlite3_import_in_package() -> None: + pkg_dir = Path(store.__file__).parent + py_files = list(pkg_dir.glob("*.py")) + assert py_files + for py_file in py_files: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert alias.name != "sqlite3", ( + f"Found sqlite3 import in {py_file.name}:{node.lineno}" + ) + elif isinstance(node, ast.ImportFrom) and node.module == "sqlite3": + raise AssertionError( + f"Found sqlite3 import in {py_file.name}:{node.lineno}" + ) + + +def test_container_style_path_resolves() -> None: + hass = _make_hass(Path("/config"), execute=False) + assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME + + +def test_core_style_path_resolves(tmp_path) -> None: + config_dir = tmp_path / ".homeassistant" + hass = _make_hass(config_dir) + assert _run(store.async_get_db_path(hass)) == config_dir / SQLITE_DB_FILENAME + assert (config_dir / SQLITE_DB_FILENAME).parent.is_dir() \ No newline at end of file -- 2.55.0 From 7f085b140d12c2623256587ada2cd75eba287a27 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 18:15:06 -0400 Subject: [PATCH 2/3] fix: normalize db path to absolute and assert no database file is created --- custom_components/nestquest/store.py | 2 +- tests/test_store.py | 44 +++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py index 00caa59..8198d79 100644 --- a/custom_components/nestquest/store.py +++ b/custom_components/nestquest/store.py @@ -15,6 +15,6 @@ def _ensure_dir(path: Path) -> None: async def async_get_db_path(hass: HomeAssistant) -> Path: """Return the absolute path to the NestQuest SQLite database file.""" - db_path = Path(hass.config.path(SQLITE_DB_FILENAME)) + db_path = Path(hass.config.path(SQLITE_DB_FILENAME)).absolute() await hass.async_add_executor_job(_ensure_dir, db_path.parent) return db_path \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py index ceac18a..bb4da9c 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -40,12 +40,26 @@ def test_returns_absolute_path_from_config_path() -> None: assert db_path.is_absolute() +def test_relative_config_path_is_normalized_to_absolute(tmp_path) -> None: + hass = MagicMock() + hass.config.path = MagicMock(return_value="relative-config/" + SQLITE_DB_FILENAME) + hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) + db_path = _run(store.async_get_db_path(hass)) + assert db_path.is_absolute() + assert db_path.name == SQLITE_DB_FILENAME + assert str(db_path).endswith("relative-config/" + SQLITE_DB_FILENAME) + assert db_path.parent.is_dir() + assert not db_path.exists() + + def test_creates_missing_parent_directory(tmp_path) -> None: nested = tmp_path / "homeassistant" / "config" hass = _make_hass(nested) db_path = _run(store.async_get_db_path(hass)) assert db_path.parent == nested assert nested.is_dir() + assert db_path.parent.is_dir() + assert not db_path.exists() def test_two_calls_return_same_path(tmp_path) -> None: @@ -54,13 +68,18 @@ def test_two_calls_return_same_path(tmp_path) -> None: second = _run(store.async_get_db_path(hass)) assert first == second assert first.parent.is_dir() + assert not first.exists() + assert not second.exists() def test_existing_directory_does_not_fail(tmp_path) -> None: nested = tmp_path / "config" nested.mkdir() hass = _make_hass(nested) - assert _run(store.async_get_db_path(hass)) == nested / SQLITE_DB_FILENAME + db_path = _run(store.async_get_db_path(hass)) + assert db_path == nested / SQLITE_DB_FILENAME + assert db_path.parent.is_dir() + assert not db_path.exists() def test_mkdir_runs_via_executor(tmp_path) -> None: @@ -76,6 +95,8 @@ def test_mkdir_runs_via_executor(tmp_path) -> None: assert fn_args == (nested,) assert kwargs == {} assert db_path.parent == nested + assert db_path.parent.is_dir() + assert not db_path.exists() def test_executor_helper_creates_directory(tmp_path) -> None: @@ -104,6 +125,27 @@ def test_no_sqlite3_import_in_package() -> None: ) +def test_no_file_creation_calls_in_store() -> None: + source = Path(store.__file__).read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(store.__file__)) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id == "open": + raise AssertionError( + f"Found open() call in store.py:{node.lineno}" + ) + if ( + isinstance(func, ast.Attribute) + and func.attr == "touch" + ): + raise AssertionError( + f"Found touch() call in store.py:{node.lineno}" + ) + assert "touch(" not in source + assert "open(" not in source + + def test_container_style_path_resolves() -> None: hass = _make_hass(Path("/config"), execute=False) assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME -- 2.55.0 From b6a2b59d1c35fc75f9305a6b45608ce179b5c6a4 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 18:24:41 -0400 Subject: [PATCH 3/3] test: isolate cwd in relative db path regression test --- tests/test_store.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_store.py b/tests/test_store.py index bb4da9c..992fbbe 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -40,7 +40,8 @@ def test_returns_absolute_path_from_config_path() -> None: assert db_path.is_absolute() -def test_relative_config_path_is_normalized_to_absolute(tmp_path) -> None: +def test_relative_config_path_is_normalized_to_absolute(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) hass = MagicMock() hass.config.path = MagicMock(return_value="relative-config/" + SQLITE_DB_FILENAME) hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) @@ -49,6 +50,7 @@ def test_relative_config_path_is_normalized_to_absolute(tmp_path) -> None: assert db_path.name == SQLITE_DB_FILENAME assert str(db_path).endswith("relative-config/" + SQLITE_DB_FILENAME) assert db_path.parent.is_dir() + assert (tmp_path / "relative-config").is_dir() assert not db_path.exists() -- 2.55.0