Extract the storage layer into core
This commit is contained in:
@@ -829,6 +829,24 @@ def make_hass() -> tuple:
|
||||
return hass, registry
|
||||
|
||||
|
||||
def executor_for(hass):
|
||||
"""Return a dynamic executor callable backed by ``hass.async_add_executor_job``.
|
||||
|
||||
The wrapper resolves ``hass.async_add_executor_job`` on every call, so a
|
||||
test that swaps it after constructing the database (to gate or fail
|
||||
specific jobs) is observed without rebuilding the wrapper — mirroring
|
||||
the pre-extraction db.py, which read the attribute dynamically off
|
||||
``hass``. Use this for cancellation/gating tests that reassign
|
||||
``hass.async_add_executor_job``; plain call sites can pass
|
||||
``hass.async_add_executor_job`` directly.
|
||||
"""
|
||||
|
||||
async def _executor(fn, *args, **kwargs):
|
||||
return await hass.async_add_executor_job(fn, *args, **kwargs)
|
||||
|
||||
return _executor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def hass():
|
||||
"""Provide a standard-shaped hass fixture bound to the running test loop.
|
||||
|
||||
@@ -31,7 +31,7 @@ def _make_hass_mock():
|
||||
def _with_db(tmp_path, name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / name)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -33,7 +33,7 @@ def _make_hass_mock():
|
||||
def _with_db(tmp_path, name):
|
||||
def _run_test(body):
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / name)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -360,7 +360,7 @@ def test_create_child_concurrent_same_name_still_warns(tmp_path) -> None:
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "create-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -633,7 +633,7 @@ def test_edit_child_concurrent_opposite_edits_return_own_values(
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "edit-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -685,7 +685,7 @@ def test_set_child_active_concurrent_opposite_transitions_return_own(
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "active-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -61,7 +61,7 @@ D1 = _future_day(1)
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
@@ -630,7 +630,7 @@ def test_complete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "complete-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -1060,7 +1060,7 @@ def test_uncomplete_instance_concurrent_second_call_is_noop(tmp_path) -> None:
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "uncomplete-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -30,7 +30,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
@@ -319,7 +319,7 @@ def test_reorder_concurrent_calls_serialize(tmp_path) -> None:
|
||||
RuntimeError.
|
||||
"""
|
||||
async def _main() -> None:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "reorder-concurrent.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -377,7 +377,7 @@ def test_admin_add_concurrent_calls_are_idempotent(tmp_path) -> None:
|
||||
'see no row' and one of them raise IntegrityError.
|
||||
"""
|
||||
async def _main() -> None:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "admin-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -71,7 +71,7 @@ D20 = _future_day(20)
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
@@ -352,7 +352,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(
|
||||
"custom_components.nestquest.dao_instances.datetime",
|
||||
"core.dao_instances.datetime",
|
||||
_ShiftingModule(),
|
||||
)
|
||||
try:
|
||||
@@ -362,7 +362,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
# the lock, the IN-LOCK re-check sees tomorrow and must
|
||||
# reject the insert — the stale call-date pre-check alone
|
||||
# would have let it through.
|
||||
import custom_components.nestquest.dao_instances as mod
|
||||
import core.dao_instances as mod
|
||||
|
||||
async def _lock_holder():
|
||||
# Hold the CONNECTION lock across a transaction until
|
||||
@@ -402,7 +402,7 @@ def test_upsert_no_past_check_uses_execution_date_not_call_date(
|
||||
return None
|
||||
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "upsert-rollover.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -1141,7 +1141,7 @@ def test_completion_events_dao_instance_has_no_mutation_capability(
|
||||
def test_append_concurrent_events_serialize(tmp_path) -> None:
|
||||
"""Concurrent appends for the same instance all land, in order."""
|
||||
async def _main():
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / "append-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -118,7 +118,7 @@ class World:
|
||||
|
||||
|
||||
async def _world(path) -> World:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
world = World(database)
|
||||
|
||||
@@ -33,7 +33,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
schedules = PresenceSchedulesDao(database)
|
||||
@@ -196,7 +196,7 @@ def test_upsert_concurrent_same_child_serializes(tmp_path) -> None:
|
||||
return fn(*args)
|
||||
|
||||
hass.async_add_executor_job = _gated_executor
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "upsert-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
@@ -766,7 +766,7 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None:
|
||||
return fn(*args)
|
||||
|
||||
hass.async_add_executor_job = _gated_executor
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "override-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -42,7 +42,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
rules = ScheduleRulesDao(database)
|
||||
@@ -325,7 +325,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe(
|
||||
create's validation pauses mid-flight before the delete starts.
|
||||
"""
|
||||
async def _main(order: str):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_path / f"rule-delete-race-{order}.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
+28
-26
@@ -11,9 +11,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import make_hass
|
||||
from conftest import executor_for, make_hass
|
||||
|
||||
from custom_components.nestquest import db as db_module
|
||||
import core.db as db_module
|
||||
from custom_components.nestquest.db import NestQuestDatabase
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def _make_hass_mock():
|
||||
|
||||
def _open_db(path) -> tuple[NestQuestDatabase, MagicMock]:
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database, hass
|
||||
|
||||
@@ -55,7 +55,7 @@ def test_open_enables_foreign_key_enforcement(tmp_path) -> None:
|
||||
|
||||
def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(tmp_path / "exec.db"))
|
||||
first_call = hass.async_add_executor_job.call_args_list[0]
|
||||
assert callable(first_call.args[0])
|
||||
@@ -65,7 +65,7 @@ def test_open_delegates_connection_to_executor(tmp_path) -> None:
|
||||
def test_open_twice_returns_same_wrapper_without_second_connect(tmp_path) -> None:
|
||||
path = tmp_path / "once.db"
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
first = _run(database.open(path))
|
||||
second = _run(database.open(path))
|
||||
assert first is database
|
||||
@@ -111,7 +111,7 @@ def test_every_method_routes_sqlite3_through_executor(tmp_path) -> None:
|
||||
async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||
"""The executor surface is awaited per wrapper call on a live loop."""
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "loop.db")
|
||||
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
@@ -129,7 +129,7 @@ async def test_every_wrapper_call_awaits_executor_job(tmp_path) -> None:
|
||||
async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||
"""execute/execute_many return plain dataclasses, never sqlite3 objects."""
|
||||
hass = _make_hass_mock()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "plain.db")
|
||||
result = await database.execute(
|
||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"
|
||||
@@ -156,7 +156,7 @@ async def test_public_methods_return_only_plain_data(tmp_path) -> None:
|
||||
async def test_no_sqlite3_call_on_event_loop_with_real_executor(tmp_path) -> None:
|
||||
"""With the conftest hass (real run_in_executor), queries still succeed."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "real.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
assert await database.fetch_one("SELECT 1") == (1,)
|
||||
@@ -210,7 +210,7 @@ async def test_real_executor_never_touches_loop_thread(tmp_path, monkeypatch) ->
|
||||
monkeypatch.setattr(db_module.sqlite3, "connect", _traced_connect)
|
||||
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "threads.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
await database.execute("INSERT INTO t (id) VALUES (?)", (1,))
|
||||
@@ -250,7 +250,9 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
|
||||
"""Every sqlite3.* call in db.py sits inside an executor-delegating function.
|
||||
|
||||
A function is executor-delegating when it (or, for nested closures, any of
|
||||
its enclosing functions) passes work to ``hass.async_add_executor_job``.
|
||||
its enclosing functions) passes work to the wrapper's ``self._executor``
|
||||
callable (the Home-Assistant-free contract that mirrors
|
||||
``hass.async_add_executor_job``).
|
||||
"""
|
||||
source = Path(db_module.__file__).read_text(encoding="utf-8")
|
||||
tree = ast.parse(source, filename=str(db_module.__file__))
|
||||
@@ -262,7 +264,7 @@ def test_db_sqlite3_calls_only_inside_executor_delegated_functions() -> None:
|
||||
and any(
|
||||
isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Attribute)
|
||||
and sub.func.attr == "async_add_executor_job"
|
||||
and sub.func.attr == "_executor"
|
||||
for sub in ast.walk(node)
|
||||
)
|
||||
}
|
||||
@@ -375,7 +377,7 @@ async def test_commit_failure_rolls_back_and_next_transaction_works(
|
||||
must run cleanly.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "deferred.db")
|
||||
await database.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
||||
await database.execute(
|
||||
@@ -416,7 +418,7 @@ async def test_cancel_during_open_closes_abandoned_connection(tmp_path) -> None:
|
||||
propagates, and a follow-up open() must succeed cleanly.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
|
||||
open_started = asyncio.Event()
|
||||
release_open = asyncio.Event()
|
||||
@@ -481,7 +483,7 @@ async def test_cancel_during_begin_settles_then_rolls_back(tmp_path) -> None:
|
||||
clear its state, and stay usable for the next transaction.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "cancel-begin.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
||||
@@ -543,7 +545,7 @@ async def test_cancel_during_commit_settles_then_propagates_cancellation(
|
||||
transaction, and the wrapper must stay usable.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "cancel-commit.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
|
||||
@@ -603,7 +605,7 @@ async def test_cancel_during_in_transaction_execute_settles_then_rolls_back(
|
||||
not persist.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "cancel-execute.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("before",))
|
||||
@@ -666,7 +668,7 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
|
||||
commit failure behind the rollback failure.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(executor_for(hass))
|
||||
await database.open(tmp_path / "both-fail.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
|
||||
@@ -698,14 +700,14 @@ async def test_commit_and_rollback_failures_are_both_preserved(tmp_path) -> None
|
||||
async def test_transaction_persists_across_connections(tmp_path) -> None:
|
||||
"""A committed transaction is visible to a brand-new connection."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "persist.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
async with database.transaction():
|
||||
await database.execute("INSERT INTO t (name) VALUES (?)", ("kept",))
|
||||
await database.close()
|
||||
|
||||
other = NestQuestDatabase(hass)
|
||||
other = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await other.open(tmp_path / "persist.db")
|
||||
assert await other.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||
await other.close()
|
||||
@@ -726,7 +728,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
|
||||
while a transaction is open queue behind it on the wrapper lock.
|
||||
"""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "serial.db")
|
||||
await database.execute("CREATE TABLE t (kind TEXT, seq INTEGER)")
|
||||
tx_rounds, writers, writer_rounds = 6, 5, 8
|
||||
@@ -766,7 +768,7 @@ async def test_concurrent_writers_do_not_interleave_commits(tmp_path) -> None:
|
||||
async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> None:
|
||||
"""A second transaction caller is rejected with a clear error."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "reject.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||
|
||||
@@ -791,7 +793,7 @@ async def test_transaction_span_excludes_concurrent_transaction(tmp_path) -> Non
|
||||
async def test_transaction_blocks_other_statements_until_commit(tmp_path) -> None:
|
||||
"""A statement issued during an open transaction queues behind it."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "block.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
|
||||
@@ -830,7 +832,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
|
||||
) -> None:
|
||||
"""A second concurrent transaction() gets a clear RuntimeError."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "concurrent-tx.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||
|
||||
@@ -853,7 +855,7 @@ async def test_concurrent_transaction_is_rejected_with_clear_error(
|
||||
async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||
"""close() serializes: an open transaction finishes before shutdown."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "closerace.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
|
||||
@@ -875,7 +877,7 @@ async def test_close_waits_for_in_flight_transaction(tmp_path) -> None:
|
||||
await asyncio.gather(txn_task, close_task)
|
||||
assert database.connected is False
|
||||
|
||||
witness = NestQuestDatabase(hass)
|
||||
witness = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await witness.open(tmp_path / "closerace.db")
|
||||
assert await witness.fetch_all("SELECT name FROM t") == [("kept",)]
|
||||
await witness.close()
|
||||
@@ -886,7 +888,7 @@ async def test_close_during_transaction_from_same_task_is_rejected(
|
||||
) -> None:
|
||||
"""close() inside its own transaction body raises instead of deadlocking."""
|
||||
hass, _registry = make_hass()
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "selfclose.db")
|
||||
await database.execute("CREATE TABLE t (id INTEGER)")
|
||||
with pytest.raises(RuntimeError, match="transaction"):
|
||||
|
||||
@@ -472,7 +472,7 @@ async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None:
|
||||
# Seed the database BEFORE setup: a restart reopens an existing file that
|
||||
# already holds definitions. The startup backfill must pick them up.
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
@@ -499,7 +499,7 @@ async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None
|
||||
from custom_components.nestquest.store import async_get_db_path
|
||||
|
||||
db_path = await async_get_db_path(hass)
|
||||
pre = NestQuestDatabase(hass)
|
||||
pre = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await pre.open(db_path)
|
||||
await apply_migrations(pre)
|
||||
child_id = await _seed_daily_child_and_definition(pre, _local_today(hass))
|
||||
|
||||
@@ -51,7 +51,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
@@ -1039,7 +1039,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight(
|
||||
clock["reads"] += 1
|
||||
return day0 if clock["reads"] == 1 else day1
|
||||
|
||||
import custom_components.nestquest.dao_instances as dao_instances_module
|
||||
import core.dao_instances as dao_instances_module
|
||||
|
||||
monkeypatch.setattr(dao_instances_module, "_today", _fake_today)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
@@ -707,7 +707,7 @@ def test_noop_run_logs_no_migration(caplog, tmp_path) -> None:
|
||||
try:
|
||||
_migrate(database)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.INFO, logger="custom_components.nestquest.migrations"):
|
||||
with caplog.at_level(logging.INFO, logger="core.migrations"):
|
||||
_migrate(database)
|
||||
migrated = [
|
||||
r for r in caplog.records if "migrated" in r.getMessage().lower()
|
||||
@@ -722,7 +722,7 @@ def test_noop_run_is_logged_at_debug_level(caplog, tmp_path) -> None:
|
||||
try:
|
||||
_migrate(database)
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.DEBUG, logger="custom_components.nestquest.migrations"):
|
||||
with caplog.at_level(logging.DEBUG, logger="core.migrations"):
|
||||
_migrate(database)
|
||||
assert any(
|
||||
f"already at version {len(MIGRATIONS)}" in r.getMessage()
|
||||
@@ -983,7 +983,7 @@ def test_concurrent_runners_serialize_and_apply_once(tmp_path) -> None:
|
||||
results: dict[str, int] = {}
|
||||
|
||||
async def _main(tmp_name: str) -> None:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(tmp_name)
|
||||
try:
|
||||
# Start A, yield so it acquires the lock and enters its
|
||||
|
||||
@@ -113,7 +113,7 @@ def _open_allowlist_db(tmp_path, name):
|
||||
|
||||
hass = MagicMock()
|
||||
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
_run(database.open(tmp_path / name))
|
||||
_run(apply_migrations(database))
|
||||
return database
|
||||
|
||||
@@ -50,7 +50,7 @@ NOW = "2026-09-14T12:00:00+00:00"
|
||||
|
||||
|
||||
async def _prepare(path) -> tuple:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
children = ChildrenDao(database)
|
||||
@@ -1396,7 +1396,7 @@ def test_set_quest_definition_active_concurrent_opposite_transitions_return_own(
|
||||
gate_open = asyncio.Event()
|
||||
a_started = asyncio.Event()
|
||||
hass = _gated_hass(armed, gate_open, a_started)
|
||||
database = NestQuestDatabase(hass)
|
||||
database = NestQuestDatabase(hass.async_add_executor_job)
|
||||
await database.open(tmp_path / "active-race.db")
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
@@ -37,7 +37,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
def _open_db(path) -> NestQuestDatabase:
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
_run(database.open(path))
|
||||
return database
|
||||
|
||||
@@ -44,7 +44,7 @@ def _open_db(path) -> NestQuestDatabase:
|
||||
async def _avalid_database(path) -> None:
|
||||
"""Create a valid, migrated NestQuest database file (async, on the
|
||||
running loop, for use inside pytest-asyncio tests)."""
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
try:
|
||||
await apply_migrations(database)
|
||||
|
||||
+30
-7
@@ -23,7 +23,11 @@ def _make_hass(config_dir: Path, execute: bool = True):
|
||||
return hass
|
||||
|
||||
|
||||
def test_returns_absolute_path_from_config_path() -> None:
|
||||
def test_returns_absolute_path_from_config_path(monkeypatch) -> None:
|
||||
# Path resolution only: the parent-dir mkdir is a side effect of
|
||||
# core.store and is exercised elsewhere; suppress it here so the
|
||||
# absolute-path assertion can run against /config (non-writable).
|
||||
monkeypatch.setattr("core.store._ensure_dir", lambda 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)
|
||||
@@ -75,18 +79,36 @@ def test_existing_directory_does_not_fail(tmp_path) -> None:
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_mkdir_runs_via_executor(tmp_path) -> None:
|
||||
def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None:
|
||||
"""The parent-directory mkdir runs off the event loop via asyncio.to_thread.
|
||||
|
||||
core.store is Home-Assistant-free, so it schedules the blocking
|
||||
``mkdir`` through :func:`asyncio.to_thread` rather than
|
||||
``hass.async_add_executor_job``; the integration shim delegates to it
|
||||
unchanged. The intent is preserved: the mkdir still runs off the
|
||||
event loop, with ``_ensure_dir`` on the parent directory, and the
|
||||
directory exists afterwards.
|
||||
"""
|
||||
nested = tmp_path / "deep" / "nested"
|
||||
hass = _make_hass(nested)
|
||||
to_thread_calls: list = []
|
||||
real_to_thread = asyncio.to_thread
|
||||
|
||||
def _trace_to_thread(fn, *args, **kwargs):
|
||||
to_thread_calls.append((fn, args, kwargs))
|
||||
return real_to_thread(fn, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread)
|
||||
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 len(to_thread_calls) == 1, (
|
||||
f"expected one asyncio.to_thread call, got {len(to_thread_calls)}"
|
||||
)
|
||||
fn, fn_args, fn_kwargs = to_thread_calls[0]
|
||||
assert callable(fn)
|
||||
assert fn is store._ensure_dir
|
||||
assert fn_args == (nested,)
|
||||
assert kwargs == {}
|
||||
assert fn_kwargs == {}
|
||||
assert db_path.parent == nested
|
||||
assert db_path.parent.is_dir()
|
||||
assert not db_path.exists()
|
||||
@@ -180,7 +202,8 @@ def test_no_file_creation_calls_in_store() -> None:
|
||||
assert "open(" not in source
|
||||
|
||||
|
||||
def test_container_style_path_resolves() -> None:
|
||||
def test_container_style_path_resolves(monkeypatch) -> None:
|
||||
monkeypatch.setattr("core.store._ensure_dir", lambda path: None)
|
||||
hass = _make_hass(Path("/config"), execute=False)
|
||||
assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ def _make_hass_mock():
|
||||
|
||||
|
||||
async def _prepare(path):
|
||||
database = NestQuestDatabase(_make_hass_mock())
|
||||
database = NestQuestDatabase(_make_hass_mock().async_add_executor_job)
|
||||
await database.open(path)
|
||||
await apply_migrations(database)
|
||||
return database
|
||||
@@ -313,7 +313,7 @@ def test_definition_business_regeneration_uses_pinned_today_not_host(
|
||||
business operations must regenerate against the pinned date and keep
|
||||
that first local day, proving the threaded ``today`` wins.
|
||||
"""
|
||||
import custom_components.nestquest.dao_instances as dao_instances_module
|
||||
import core.dao_instances as dao_instances_module
|
||||
|
||||
pinned = datetime.date(2026, 1, 14)
|
||||
host_today = datetime.date(2026, 1, 15)
|
||||
|
||||
Reference in New Issue
Block a user