706 lines
26 KiB
Python
706 lines
26 KiB
Python
"""Tests for dao_instances.py: task_instances + append-only completion_events."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sqlite3
|
|
|
|
import pytest
|
|
|
|
from custom_components.nestquest.dao_children import ChildrenDao
|
|
from custom_components.nestquest.dao_instances import (
|
|
CompletionEventRecord,
|
|
CompletionEventsDao,
|
|
TaskInstanceRecord,
|
|
TaskInstancesDao,
|
|
)
|
|
from custom_components.nestquest.dao_rules import ScheduleRulesDao, TaskDefinitionsDao
|
|
from custom_components.nestquest.db import NestQuestDatabase
|
|
from custom_components.nestquest.migrations import apply_migrations
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.new_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def _make_hass_mock():
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
hass = MagicMock()
|
|
hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a)))
|
|
return hass
|
|
|
|
|
|
NOW = "2026-09-14T12:00:00+00:00"
|
|
|
|
|
|
async def _prepare(path) -> tuple:
|
|
database = NestQuestDatabase(_make_hass_mock())
|
|
await database.open(path)
|
|
await apply_migrations(database)
|
|
children = ChildrenDao(database)
|
|
rules = ScheduleRulesDao(database)
|
|
definitions = TaskDefinitionsDao(database)
|
|
instances = TaskInstancesDao(database)
|
|
events = CompletionEventsDao(database)
|
|
child = await children.create("Ada", NOW)
|
|
rule = await rules.create("daily", "2026-09-01")
|
|
definition = await definitions.create("Brush teeth", child.id, rule.id, NOW)
|
|
return (
|
|
database,
|
|
children,
|
|
rules,
|
|
definitions,
|
|
instances,
|
|
events,
|
|
child,
|
|
definition,
|
|
)
|
|
|
|
|
|
def _with_db(tmp_path, name):
|
|
def _run_test(body):
|
|
async def _main():
|
|
prepared = await _prepare(tmp_path / name)
|
|
try:
|
|
return await body(*prepared)
|
|
finally:
|
|
await prepared[0].close()
|
|
|
|
return _run(_main())
|
|
|
|
return _run_test
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# task_instances: upsert idempotency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_upsert_creates_then_is_idempotent(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
first = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
assert isinstance(first, TaskInstanceRecord)
|
|
second = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
assert second.id == first.id
|
|
rows = await database.fetch_one(
|
|
"SELECT COUNT(*) FROM task_instances"
|
|
)
|
|
assert rows == (1,)
|
|
return second
|
|
|
|
_with_db(tmp_path, "upsert-idempotent.db")(_body)
|
|
|
|
|
|
def test_upsert_different_dates_create_distinct_rows(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
a = await instances.upsert(definition.id, child.id, "2026-09-15", NOW)
|
|
b = await instances.upsert(definition.id, child.id, "2026-09-16", NOW)
|
|
assert a.id != b.id
|
|
return (a, b)
|
|
|
|
_with_db(tmp_path, "upsert-multi-date.db")(_body)
|
|
|
|
|
|
def test_upsert_refreshes_snapshot_columns_on_conflict(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
first = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW, due_time="08:00"
|
|
)
|
|
second = await instances.upsert(
|
|
definition.id,
|
|
child.id,
|
|
"2026-09-15",
|
|
"2026-09-14T13:00:00+00:00",
|
|
due_time="08:30",
|
|
)
|
|
assert second.id == first.id
|
|
assert second.due_time == "08:30"
|
|
assert second.generated_at == "2026-09-14T13:00:00+00:00"
|
|
return second
|
|
|
|
_with_db(tmp_path, "upsert-refresh.db")(_body)
|
|
|
|
|
|
def test_upsert_rejects_wrong_child_for_definition(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
other = await children.create("Bo", NOW)
|
|
with pytest.raises(ValueError, match="assigned to"):
|
|
await instances.upsert(definition.id, other.id, "2026-09-15", NOW)
|
|
return None
|
|
|
|
_with_db(tmp_path, "upsert-wrong-child.db")(_body)
|
|
|
|
|
|
def test_upsert_rejects_unknown_definition_and_child(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
with pytest.raises(ValueError, match="definition 999"):
|
|
await instances.upsert(999, child.id, "2026-09-15", NOW)
|
|
# Unknown child: the definition-assignee check fires first and
|
|
# is the one that matters (an instance must carry its
|
|
# definition's assignee). To reach the child-existence check
|
|
# at all, the definition would have to claim child 999 — an
|
|
# FK-impossible state — so the child check only guards direct
|
|
# callers with a matching fake assignee; assert the guard
|
|
# order instead of forcing the FK-impossible state.
|
|
rule = await rules.create("daily", "2026-09-01")
|
|
other_definition = await definitions.create(
|
|
"X", child.id, rule.id, NOW
|
|
)
|
|
with pytest.raises(ValueError, match="not 999"):
|
|
await instances.upsert(other_definition.id, 999, "2026-09-15", NOW)
|
|
return None
|
|
|
|
_with_db(tmp_path, "upsert-unknown.db")(_body)
|
|
|
|
|
|
def test_upsert_malformed_due_date_raises(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
for bad in ("not-a-date", "2026-9-15", ""):
|
|
with pytest.raises(ValueError, match="YYYY-MM-DD"):
|
|
await instances.upsert(definition.id, child.id, bad, NOW)
|
|
return None
|
|
|
|
_with_db(tmp_path, "upsert-bad-date.db")(_body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# task_instances: get / list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_get_by_definition_and_date_round_trip(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
created = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW, due_time="17:30"
|
|
)
|
|
fetched = await instances.get(definition.id, "2026-09-15")
|
|
assert fetched == created
|
|
assert fetched.due_time == "17:30"
|
|
assert await instances.get(definition.id, "2026-09-16") is None
|
|
return fetched
|
|
|
|
_with_db(tmp_path, "get-round-trip.db")(_body)
|
|
|
|
|
|
def test_get_by_id_round_trip(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
created = await instances.upsert(definition.id, child.id, "2026-09-15", NOW)
|
|
fetched = await instances.get_by_id(created.id)
|
|
assert fetched == created
|
|
assert await instances.get_by_id(999) is None
|
|
return fetched
|
|
|
|
_with_db(tmp_path, "get-by-id.db")(_body)
|
|
|
|
|
|
def test_list_by_child_and_date_filters(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
other = await children.create("Bo", NOW)
|
|
rule = await rules.create("daily", "2026-09-01")
|
|
other_definition = await definitions.create(
|
|
"Make bed", other.id, rule.id, NOW
|
|
)
|
|
await instances.upsert(definition.id, child.id, "2026-09-15", NOW)
|
|
await instances.upsert(definition.id, child.id, "2026-09-16", NOW)
|
|
await instances.upsert(
|
|
other_definition.id, other.id, "2026-09-15", NOW
|
|
)
|
|
mine = await instances.list_by_child_and_date(child.id, "2026-09-15")
|
|
assert len(mine) == 1
|
|
assert mine[0].child_id == child.id
|
|
assert mine[0].due_date == "2026-09-15"
|
|
return mine
|
|
|
|
_with_db(tmp_path, "list-by-child-date.db")(_body)
|
|
|
|
|
|
def test_list_by_date_range_filters_and_orders(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
for day in ("2026-09-10", "2026-09-12", "2026-09-14", "2026-09-20"):
|
|
await instances.upsert(definition.id, child.id, day, NOW)
|
|
found = await instances.list_by_date_range(
|
|
child.id, "2026-09-11", "2026-09-15"
|
|
)
|
|
assert [i.due_date for i in found] == ["2026-09-12", "2026-09-14"]
|
|
edge = await instances.list_by_date_range(
|
|
child.id, "2026-09-12", "2026-09-12"
|
|
)
|
|
assert [i.due_date for i in edge] == ["2026-09-12"]
|
|
return found
|
|
|
|
_with_db(tmp_path, "list-by-range.db")(_body)
|
|
|
|
|
|
def test_list_by_date_range_rejects_inverted_range(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
with pytest.raises(ValueError, match="on or after"):
|
|
await instances.list_by_date_range(
|
|
child.id, "2026-09-15", "2026-09-10"
|
|
)
|
|
return None
|
|
|
|
_with_db(tmp_path, "list-inverted.db")(_body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# task_instances: delete_future_uncompleted
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_delete_future_uncompleted_removes_only_eligible(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
await instances.upsert(definition.id, child.id, "2026-09-10", NOW)
|
|
future_open = await instances.upsert(
|
|
definition.id, child.id, "2026-09-20", NOW
|
|
)
|
|
await instances.upsert(definition.id, child.id, "2026-09-21", NOW)
|
|
deleted = await instances.delete_future_uncompleted(
|
|
definition.id, "2026-09-20"
|
|
)
|
|
assert deleted == 2
|
|
assert await instances.get(definition.id, "2026-09-10") is not None
|
|
assert await instances.get(definition.id, "2026-09-20") is None
|
|
assert await instances.get(definition.id, "2026-09-21") is None
|
|
return deleted
|
|
|
|
_with_db(tmp_path, "delete-future.db")(_body)
|
|
|
|
|
|
def test_delete_future_uncompleted_spares_completed(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
completed = await instances.upsert(
|
|
definition.id, child.id, "2026-09-20", NOW
|
|
)
|
|
open_one = await instances.upsert(
|
|
definition.id, child.id, "2026-09-21", NOW
|
|
)
|
|
await events.append(
|
|
completed.id, child.id, "completed", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
deleted = await instances.delete_future_uncompleted(
|
|
definition.id, "2026-09-20"
|
|
)
|
|
assert deleted == 1
|
|
assert await instances.get_by_id(completed.id) is not None, (
|
|
"an instance with a completion event must never be deleted"
|
|
)
|
|
assert await instances.get_by_id(open_one.id) is None
|
|
return deleted
|
|
|
|
_with_db(tmp_path, "delete-spares-completed.db")(_body)
|
|
|
|
|
|
def test_delete_future_uncompleted_spares_past(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
await instances.upsert(definition.id, child.id, "2026-09-10", NOW)
|
|
await instances.upsert(definition.id, child.id, "2026-09-11", NOW)
|
|
deleted = await instances.delete_future_uncompleted(
|
|
definition.id, "2026-09-11"
|
|
)
|
|
assert deleted == 1
|
|
assert await instances.get(definition.id, "2026-09-10") is not None
|
|
return deleted
|
|
|
|
_with_db(tmp_path, "delete-spares-past.db")(_body)
|
|
|
|
|
|
def test_delete_future_uncompleted_other_definitions_untouched(
|
|
tmp_path,
|
|
) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
other = await definitions.create(
|
|
"Make bed", child.id, (await rules.create("daily", "2026-09-01")).id,
|
|
NOW,
|
|
)
|
|
await instances.upsert(definition.id, child.id, "2026-09-20", NOW)
|
|
await instances.upsert(other.id, child.id, "2026-09-20", NOW)
|
|
deleted = await instances.delete_future_uncompleted(
|
|
definition.id, "2026-09-20"
|
|
)
|
|
assert deleted == 1
|
|
assert await instances.get(other.id, "2026-09-20") is not None
|
|
return deleted
|
|
|
|
_with_db(tmp_path, "delete-scoped.db")(_body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# completion_events: append
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_append_completion_round_trips(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
event = await events.append(
|
|
instance.id, child.id, "completed", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
assert isinstance(event, CompletionEventRecord)
|
|
assert event.event_type == "completed"
|
|
assert event.actor_source == "user"
|
|
assert event.actor_user_id == "user-1"
|
|
assert event.was_on_time is True
|
|
return event
|
|
|
|
_with_db(tmp_path, "append-completion.db")(_body)
|
|
|
|
|
|
def test_append_panel_event_round_trips(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
event = await events.append(
|
|
instance.id, child.id, "completed", "panel", NOW, False,
|
|
actor_user_id=None,
|
|
)
|
|
assert event.actor_source == "panel"
|
|
assert event.actor_user_id is None
|
|
assert event.was_on_time is False
|
|
return event
|
|
|
|
_with_db(tmp_path, "append-panel.db")(_body)
|
|
|
|
|
|
def test_append_uncompleted_with_optional_on_time(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
reversal = await events.append(
|
|
instance.id, child.id, "uncompleted", "user", NOW, None,
|
|
actor_user_id="user-1",
|
|
)
|
|
assert reversal.event_type == "uncompleted"
|
|
assert reversal.was_on_time is None
|
|
carried = await events.append(
|
|
instance.id, child.id, "uncompleted", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
assert carried.was_on_time is True
|
|
return (reversal, carried)
|
|
|
|
_with_db(tmp_path, "append-uncompleted.db")(_body)
|
|
|
|
|
|
def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
with pytest.raises(ValueError, match="event_type"):
|
|
await events.append(
|
|
instance.id, child.id, "missed", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
with pytest.raises(ValueError, match="actor_source"):
|
|
await events.append(
|
|
instance.id, child.id, "completed", "kiosk", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
with pytest.raises(ValueError, match="requires actor_user_id"):
|
|
await events.append(
|
|
instance.id, child.id, "completed", "user", NOW, True,
|
|
actor_user_id=None,
|
|
)
|
|
with pytest.raises(ValueError, match="must not carry"):
|
|
await events.append(
|
|
instance.id, child.id, "completed", "panel", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
with pytest.raises(ValueError, match="requires was_on_time"):
|
|
await events.append(
|
|
instance.id, child.id, "completed", "panel", NOW, None
|
|
)
|
|
return None
|
|
|
|
_with_db(tmp_path, "append-validation.db")(_body)
|
|
|
|
|
|
def test_append_rejects_unknown_instance_or_child(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
with pytest.raises(ValueError, match="instance 999"):
|
|
await events.append(
|
|
999, child.id, "completed", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
with pytest.raises(ValueError, match="belongs to child"):
|
|
await events.append(
|
|
instance.id, 999, "completed", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
return None
|
|
|
|
_with_db(tmp_path, "append-unknown.db")(_body)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# completion_events: reads and append-only semantics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_completed_uncompleted_recompleted_three_ordered_events(
|
|
tmp_path,
|
|
) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
await events.append(
|
|
instance.id, child.id, "completed", "panel", NOW, True
|
|
)
|
|
await events.append(
|
|
instance.id, child.id, "uncompleted", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
await events.append(
|
|
instance.id, child.id, "completed", "panel", NOW, False
|
|
)
|
|
history = await events.list_by_instance(instance.id)
|
|
assert [e.event_type for e in history] == [
|
|
"completed",
|
|
"uncompleted",
|
|
"completed",
|
|
]
|
|
assert [e.id for e in history] == sorted(e.id for e in history)
|
|
latest = await events.get_latest_for_instance(instance.id)
|
|
assert latest.event_type == "completed"
|
|
assert latest.was_on_time is False
|
|
return history
|
|
|
|
_with_db(tmp_path, "three-events.db")(_body)
|
|
|
|
|
|
def test_get_latest_for_instance_none_when_untouched(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
return await events.get_latest_for_instance(instance.id)
|
|
|
|
assert _with_db(tmp_path, "latest-none.db")(_body) is None
|
|
|
|
|
|
def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
await events.append(
|
|
instance.id, child.id, "completed", "panel", NOW, True
|
|
)
|
|
await events.append(
|
|
instance.id, child.id, "uncompleted", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
latest = await events.get_latest_for_instance(instance.id)
|
|
assert latest.event_type == "uncompleted"
|
|
return latest
|
|
|
|
_with_db(tmp_path, "latest-reversed.db")(_body)
|
|
|
|
|
|
def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
early = await instances.upsert(
|
|
definition.id, child.id, "2026-09-10", NOW
|
|
)
|
|
inside = await instances.upsert(
|
|
definition.id, child.id, "2026-09-12", NOW
|
|
)
|
|
late = await instances.upsert(
|
|
definition.id, child.id, "2026-09-20", NOW
|
|
)
|
|
await events.append(
|
|
early.id, child.id, "completed", "panel", NOW, True
|
|
)
|
|
await events.append(
|
|
inside.id, child.id, "completed", "panel", NOW, False
|
|
)
|
|
await events.append(
|
|
inside.id, child.id, "uncompleted", "user", NOW, True,
|
|
actor_user_id="user-1",
|
|
)
|
|
await events.append(
|
|
late.id, child.id, "completed", "panel", NOW, True
|
|
)
|
|
found = await events.list_by_child_and_date_range(
|
|
child.id, "2026-09-11", "2026-09-15"
|
|
)
|
|
assert [e.id for e in found] == [2, 3] # inside's two events
|
|
return found
|
|
|
|
_with_db(tmp_path, "events-by-range.db")(_body)
|
|
|
|
|
|
def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
late_due = await instances.upsert(
|
|
definition.id, child.id, "2026-09-20", NOW
|
|
)
|
|
# Completed long after its due date: due-date scope misses it,
|
|
# occurred-at scope catches it.
|
|
await events.append(
|
|
late_due.id, child.id, "completed", "panel", NOW, False
|
|
)
|
|
by_due = await events.list_by_child_and_date_range(
|
|
child.id, "2026-09-01", "2026-09-15"
|
|
)
|
|
assert by_due == []
|
|
by_occurrence = await events.list_by_child_and_date_range(
|
|
child.id, "2026-09-14", "2026-09-14", due_date_scope=False
|
|
)
|
|
assert len(by_occurrence) == 1
|
|
assert by_occurrence[0].instance_id == late_due.id
|
|
return by_occurrence
|
|
|
|
_with_db(tmp_path, "events-occurred-scope.db")(_body)
|
|
|
|
|
|
def test_completion_events_dao_has_no_update_or_delete(tmp_path) -> None:
|
|
"""Done-condition: NO update or delete method exists at all."""
|
|
async def _body(database, children, rules, definitions, instances,
|
|
events, child, definition):
|
|
import inspect
|
|
|
|
methods = {
|
|
name
|
|
for name, _ in inspect.getmembers(
|
|
CompletionEventsDao, inspect.isfunction
|
|
)
|
|
if not name.startswith("__")
|
|
}
|
|
forbidden = {
|
|
name for name in methods
|
|
if name.startswith(("update", "delete", "set", "clear",
|
|
"remove", "modify", "edit", "upsert"))
|
|
}
|
|
assert forbidden == set(), (
|
|
f"append-only DAO exposed mutation methods: {forbidden}"
|
|
)
|
|
assert methods == {
|
|
"append",
|
|
"list_by_instance",
|
|
"list_by_child_and_date_range",
|
|
"get_latest_for_instance",
|
|
}
|
|
return methods
|
|
|
|
_with_db(tmp_path, "events-no-mutation.db")(_body)
|
|
|
|
|
|
def test_no_mutation_sql_for_completion_events_anywhere() -> None:
|
|
"""Package-wide guardrail: UPDATE/DELETE ... completion_events must
|
|
not appear in ANY module or test — the append-only property is
|
|
enforced here, not just in the DAO class docstring.
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
package = Path(
|
|
__import__(
|
|
"custom_components.nestquest", fromlist=["__file__"]
|
|
).__file__
|
|
).parent
|
|
repo_root = package.parent.parent
|
|
mutation_pattern = re.compile(
|
|
r"(UPDATE|DELETE\s+FROM)\s+[`'\"]*(\[)?completion_events\b",
|
|
re.IGNORECASE,
|
|
)
|
|
offenders: list[str] = []
|
|
for root in (package, repo_root / "tests"):
|
|
for py in sorted(root.rglob("*.py")):
|
|
if mutation_pattern.search(py.read_text()):
|
|
offenders.append(py.relative_to(repo_root).as_posix())
|
|
assert offenders == [], (
|
|
f"completion_events mutation SQL found in: {offenders}"
|
|
)
|
|
|
|
|
|
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())
|
|
await database.open(tmp_path / "append-race.db")
|
|
try:
|
|
await apply_migrations(database)
|
|
prepared = await _prepare_in(database)
|
|
_, children, _, _, instances, events, child, definition = (
|
|
prepared
|
|
)
|
|
instance = await instances.upsert(
|
|
definition.id, child.id, "2026-09-15", NOW
|
|
)
|
|
tasks = [
|
|
asyncio.ensure_future(
|
|
events.append(
|
|
instance.id, child.id, "completed", "user",
|
|
f"2026-09-15T08:0{index}:00+00:00", True,
|
|
actor_user_id=f"user-{index}",
|
|
)
|
|
)
|
|
for index in range(5)
|
|
]
|
|
await asyncio.sleep(0)
|
|
appended = await asyncio.gather(*tasks)
|
|
history = await events.list_by_instance(instance.id)
|
|
assert [e.id for e in history] == [
|
|
e.id for e in sorted(appended, key=lambda x: x.id)
|
|
]
|
|
assert len(history) == 5
|
|
return history
|
|
finally:
|
|
await database.close()
|
|
|
|
async def _prepare_in(database):
|
|
children = ChildrenDao(database)
|
|
rules = ScheduleRulesDao(database)
|
|
definitions = TaskDefinitionsDao(database)
|
|
instances = TaskInstancesDao(database)
|
|
events = CompletionEventsDao(database)
|
|
child = await children.create("Ada", NOW)
|
|
rule = await rules.create("daily", "2026-09-01")
|
|
definition = await definitions.create(
|
|
"Brush teeth", child.id, rule.id, NOW
|
|
)
|
|
return (database, children, rules, definitions, instances,
|
|
events, child, definition)
|
|
|
|
history = _run(_main())
|
|
assert history[-1].occurred_at == "2026-09-15T08:04:00+00:00" |