From fe90064b65a20af1a288fda23b2ea31ecaec44b6 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Tue, 15 Sep 2026 10:25:50 -0400 Subject: [PATCH] Add actor_child_id to completion_events (D-008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per IMPLEMENTATION-ORDER.md 1.5: a nullable actor_child_id column records which panel profile was tapped; admin events stay NULL. - schema.py: completion_events table body extracted to COMPLETION_EVENTS_TABLE_SQL; the new column carries a FK to children(id) and the actor-pair coherence is extended to a second CHECK: 'user' events require actor_user_id and forbid actor_child_id, 'panel' events require actor_child_id and forbid actor_user_id. - migrations.py: migration 6 rebuilds legacy-shaped completion_events via the canonical procedure (CHECKs cannot be added to an existing table), backfilling each legacy panel row to its own child_id (pre-D-008 panel completions were always the tapped child) via INSERT..SELECT — no UPDATE or DELETE path exists, so the append-only guarantee is untouched; ids preserved; PRAGMA foreign_key_check verified in-transaction. - dao_instances.py: append() takes actor_child_id, validates both actor shapes (panel requires it, user forbids it) and the tapped child's existence under the connection lock; the record and column list grow accordingly. - tests: coherence CHECK matrix (5 bad shapes), unknown actor_child_id FK, panel/user round-trips at DDL and DAO layers, migration-6 backfill test on a frozen v2-shape seed; the completion_events leak guard gains justified INSERT exemptions for the migration layer (UPDATE/DELETE stay forbidden everywhere). --- custom_components/nestquest/dao_instances.py | 55 +++++++++---- custom_components/nestquest/migrations.py | 84 ++++++++++++++++++-- custom_components/nestquest/schema.py | 68 +++++++++------- tests/test_dao_instances.py | 46 ++++++++--- tests/test_dao_matrix.py | 6 +- tests/test_migrations.py | 62 ++++++++++++++- tests/test_schema.py | 63 ++++++++++++++- 7 files changed, 318 insertions(+), 66 deletions(-) diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py index 2e9aaa3..93437f1 100644 --- a/custom_components/nestquest/dao_instances.py +++ b/custom_components/nestquest/dao_instances.py @@ -106,6 +106,7 @@ class CompletionEventRecord: event_type: str actor_source: str actor_user_id: str | None + actor_child_id: int | None occurred_at: str was_on_time: bool | None @@ -115,7 +116,7 @@ _INSTANCE_COLUMNS = ( ) _EVENT_COLUMNS = ( "id, instance_id, child_id, event_type, actor_source, actor_user_id, " - "occurred_at, was_on_time" + "actor_child_id, occurred_at, was_on_time" ) #: The only event types the append path accepts; the schema CHECK is @@ -144,8 +145,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord: event_type=row[3], actor_source=row[4], actor_user_id=row[5], - occurred_at=row[6], - was_on_time=None if row[7] is None else bool(row[7]), + actor_child_id=row[6], + occurred_at=row[7], + was_on_time=None if row[8] is None else bool(row[8]), ) @@ -396,19 +398,21 @@ class CompletionEventsDao: was_on_time: bool | None, *, actor_user_id: str | None = None, + actor_child_id: int | None = None, ) -> CompletionEventRecord: """Append one completion or un-completion event. - ``actor_source`` is 'user' (with ``actor_user_id`` set) or - 'panel' (no user id — the tapped child is ``child_id``); the - actor-pair CHECK enforces this shape at the schema level. - ``occurred_at`` must be the module's one strict UTC shape - (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries - stay exact. ``was_on_time`` is required (True/False) for - completions; for un-completions it may be None or the reversed - completion's flag (Feature 08 still deciding). The instance - and child must exist — validated atomically under the - connection lock. + ``actor_source`` is 'user' (an admin action: ``actor_user_id`` + set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user + id, and ``actor_child_id`` carrying the tapped child profile, + D-008); the actor-pair CHECKs enforce this shape at the schema + level. ``occurred_at`` must be the module's one strict UTC + shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range + queries stay exact. ``was_on_time`` is required (True/False) + for completions; for un-completions it may be None or the + reversed completion's flag (Feature 08 still deciding). The + instance, child and — for panel events — the tapped child must + exist — validated atomically under the connection lock. """ if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED): raise ValueError( @@ -428,6 +432,15 @@ class CompletionEventsDao: raise ValueError( "actor_source 'panel' must not carry actor_user_id" ) + if actor_source == "panel" and actor_child_id is None: + raise ValueError( + "actor_source 'panel' requires actor_child_id " + "(the tapped child profile)" + ) + if actor_source == "user" and actor_child_id is not None: + raise ValueError( + "actor_source 'user' must not carry actor_child_id" + ) if was_on_time is not None and type(was_on_time) is not bool: raise ValueError( f"was_on_time must be True, False or None, got " @@ -458,16 +471,28 @@ class CompletionEventsDao: ) if child is None: raise ValueError(f"child {child_id} does not exist") + if actor_child_id is not None: + tapped = await self._database.fetch_one( + "SELECT 1 FROM children WHERE id = ?", + (actor_child_id,), + ) + if tapped is None: + raise ValueError( + f"actor_child_id {actor_child_id} does not " + "reference an existing child" + ) result = await self._database.execute( "INSERT INTO completion_events (instance_id, child_id, " - "event_type, actor_source, actor_user_id, occurred_at, " - "was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)", + "event_type, actor_source, actor_user_id, " + "actor_child_id, occurred_at, was_on_time) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ( instance_id, child_id, event_type, actor_source, actor_user_id, + actor_child_id, occurred_at, None if was_on_time is None else int(was_on_time), ), diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py index 9901505..2040366 100644 --- a/custom_components/nestquest/migrations.py +++ b/custom_components/nestquest/migrations.py @@ -25,11 +25,12 @@ pre-D-008 databases to the multi-assignee model: it creates ``quest_definition_assignees`` and, when ``quest_definitions`` still carries a single ``child_id`` column, rebuilds the table without it, copying each definition's assignee across. Migration 4 adds the -windows table and migration 5 rebuilds ``quest_instances`` onto the -widened (definition_id, child_id, due_date, window) key. Each callable -migration runs inside its own transaction together with its version -stamp, so a crash mid-step rolls the statements and the stamp back -together. +windows table, migration 5 rebuilds ``quest_instances`` onto the +widened (definition_id, child_id, due_date, window) key, and migration +6 rebuilds ``completion_events`` with the D-008 ``actor_child_id`` +column. Each callable migration runs inside its own transaction +together with its version stamp, so a crash mid-step rolls the +statements and the stamp back together. Migration entries are either an ordered sequence of SQL statements or an async callable taking ``(database, target_version)``. Callables @@ -73,6 +74,7 @@ from typing import Union from .db import NestQuestDatabase from .schema import ( + COMPLETION_EVENTS_TABLE_SQL, QUEST_DEFINITIONS_TABLE_SQL, QUEST_INSTANCES_TABLE_SQL, SCHEMA_V1_STATEMENTS, @@ -326,6 +328,74 @@ async def _widen_instance_key( await database.execute("PRAGMA foreign_keys = ON") +async def _add_actor_child_id( + database: NestQuestDatabase, target_version: int +) -> None: + """Bring ``completion_events`` to the D-008 actor-child shape. + + The ``actor_child_id`` column records which panel profile was + tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone + because the canonical shape also carries an actor-pair coherence + CHECK (panel events require it, user events forbid it), and CHECK + constraints cannot be added to an existing table. Databases with + the legacy shape are therefore rebuilt via SQLite's canonical + procedure, backfilling each legacy panel row's ``actor_child_id`` + to ``child_id`` — pre-D-008 panel completions were always recorded + against the tapped child themselves — while admin rows stay NULL. + Row ids are preserved so ``quest_instances`` references and the + append-only history stay intact. Fresh databases already match + the canonical shape and only stamp. + + The rebuild runs with ``foreign_keys = OFF`` and verifies + ``PRAGMA foreign_key_check`` inside the transaction before the + stamp. The copy is an INSERT..SELECT, not an UPDATE: the + completion_events table keeps its append-only guarantee. + """ + legacy_shape = not await _table_has_column( + database, "completion_events", "actor_child_id" + ) + await database.execute("PRAGMA foreign_keys = OFF") + try: + async with database.transaction(): + if legacy_shape: + await database.execute( + "CREATE TABLE completion_events_rebuilt " + f"{COMPLETION_EVENTS_TABLE_SQL}" + ) + await database.execute( + "INSERT INTO completion_events_rebuilt " + "(id, instance_id, child_id, event_type, actor_source, " + "actor_user_id, actor_child_id, occurred_at, " + "was_on_time) " + "SELECT id, instance_id, child_id, event_type, " + "actor_source, actor_user_id, " + "CASE WHEN actor_source = 'panel' THEN child_id " + "ELSE NULL END, " + "occurred_at, was_on_time " + "FROM completion_events ORDER BY id" + ) + await database.execute("DROP TABLE completion_events") + await database.execute( + "ALTER TABLE completion_events_rebuilt " + "RENAME TO completion_events" + ) + violations = await database.fetch_all( + "PRAGMA foreign_key_check" + ) + if violations: + raise RuntimeError( + "completion_events rebuild produced foreign-key " + f"violations: {violations!r}" + ) + LOGGER.info( + "Added actor_child_id to completion_events; legacy " + "panel rows backfilled to their own child_id" + ) + await stamp_schema_version(database, target_version) + finally: + await database.execute("PRAGMA foreign_keys = ON") + + #: The ordered migration list. Append-only: never edit an applied #: entry, add the next one instead. (Migration 1's content was #: rewritten pre-release per D-007 — no version 1 database shipped.) @@ -340,6 +410,10 @@ MIGRATIONS: Sequence[MigrationStep] = [ # Migration 5 (D-008): rebuild quest_instances onto the widened # (definition_id, child_id, due_date, window) key. _widen_instance_key, + # Migration 6 (D-008): rebuild completion_events with the + # actor_child_id column and its actor-pair coherence CHECK, + # backfilling legacy panel rows to their own child_id. + _add_actor_child_id, ] diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py index 46447e6..ab1fb35 100644 --- a/custom_components/nestquest/schema.py +++ b/custom_components/nestquest/schema.py @@ -159,11 +159,12 @@ self-describing without joins. Actor policy: ``actor_source`` distinguishes an authenticated HA user (``'user'``, with ``actor_user_id`` holding the HA user id) from a panel tap with no user context (``'panel'``, with ``actor_user_id`` -NULL — the tapped child profile is already ``child_id``). A coherence -CHECK makes the pair shape non-negotiable: ``'user'`` requires -``actor_user_id``, ``'panel'`` forbids it. Other actor sources are -rejected; if an unattended system actor is ever needed, that is a -schema migration, not a silent widening. +NULL — the tapped child profile is ``actor_child_id``, D-008). Two +coherence CHECKs make both shapes non-negotiable: ``'user'`` requires +``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires +``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``. +Other actor sources are rejected; if an unattended system actor is +ever needed, that is a schema migration, not a silent widening. On-time policy: ``was_on_time`` is required (integer 0 or 1) for ``completed`` events — at completion time the due date is known and the @@ -344,29 +345,42 @@ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [ """, ] -SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ - """ - CREATE TABLE IF NOT EXISTS completion_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - instance_id INTEGER NOT NULL REFERENCES quest_instances(id), - child_id INTEGER NOT NULL REFERENCES children(id), - event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')), - actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')), - actor_user_id TEXT, - occurred_at TEXT NOT NULL, - was_on_time INTEGER CHECK ( - was_on_time IS NULL - OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1)) - ), - CHECK ( - (actor_source = 'user' AND actor_user_id IS NOT NULL) - OR (actor_source = 'panel' AND actor_user_id IS NULL) - ), - CHECK ( - (event_type = 'completed' AND was_on_time IS NOT NULL) - OR (event_type = 'uncompleted') - ) +#: Column body of ``completion_events``, shared between the v1 DDL +#: below and the actor-child rebuild migration, which must recreate the +#: table in exactly this shape (D-008: ``actor_child_id`` records which +#: panel profile was tapped — set for panel events, NULL for admin +#: ones; legacy panel rows backfill to ``child_id``, the child they +#: always were). +COMPLETION_EVENTS_TABLE_SQL = """( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL REFERENCES quest_instances(id), + child_id INTEGER NOT NULL REFERENCES children(id), + event_type TEXT NOT NULL CHECK (event_type IN ('completed', 'uncompleted')), + actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')), + actor_user_id TEXT, + actor_child_id INTEGER REFERENCES children(id), + occurred_at TEXT NOT NULL, + was_on_time INTEGER CHECK ( + was_on_time IS NULL + OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1)) + ), + CHECK ( + (actor_source = 'user' AND actor_user_id IS NOT NULL) + OR (actor_source = 'panel' AND actor_user_id IS NULL) + ), + CHECK ( + (actor_source = 'user' AND actor_child_id IS NULL) + OR (actor_source = 'panel' AND actor_child_id IS NOT NULL) + ), + CHECK ( + (event_type = 'completed' AND was_on_time IS NOT NULL) + OR (event_type = 'uncompleted') ) +)""" + +SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [ + f""" + CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL} """, ] diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index fe79027..5894b38 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -664,6 +664,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None: definition.id, child.id, D1, _now_stamp(), window="morning") event = await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id, actor_user_id=None, ) assert event.actor_source == "panel" @@ -718,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None: with pytest.raises(ValueError, match="must not carry"): await events.append( instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id, actor_user_id="user-1", ) + with pytest.raises(ValueError, match="requires actor_child_id"): + await events.append( + instance.id, child.id, "completed", "panel", + _now_stamp(), True, + ) + with pytest.raises(ValueError, match="must not carry actor_child_id"): + await events.append( + instance.id, child.id, "completed", "user", _now_stamp(), True, + actor_user_id="user-1", + actor_child_id=child.id, + ) with pytest.raises(ValueError, match="requires was_on_time"): await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), None + instance.id, child.id, "completed", "panel", _now_stamp(), None, + actor_child_id=child.id ) # was_on_time must be a real bool: int-convertible junk that # would silently coerce (0.5 -> False) is rejected outright. with pytest.raises(ValueError, match="must be True, False or None"): await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), 0.5 + instance.id, child.id, "completed", "panel", + _now_stamp(), 0.5, actor_child_id=child.id, ) with pytest.raises(ValueError, match="must be True, False or None"): await events.append( @@ -758,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None: with pytest.raises(ValueError, match="UTC"): await events.append( instance.id, child.id, "completed", "panel", bad, - True, + True, actor_child_id=child.id, ) return None @@ -815,14 +830,16 @@ def test_completed_uncompleted_recompleted_three_ordered_events( instance = await instances.upsert( definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), True + instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) await events.append( instance.id, child.id, "uncompleted", "user", _now_stamp(), True, actor_user_id="user-1", ) await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), False + instance.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id ) history = await events.list_by_instance(instance.id) assert [e.event_type for e in history] == [ @@ -855,7 +872,8 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None: instance = await instances.upsert( definition.id, child.id, D1, _now_stamp(), window="morning") await events.append( - instance.id, child.id, "completed", "panel", _now_stamp(), True + instance.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) await events.append( instance.id, child.id, "uncompleted", "user", _now_stamp(), True, @@ -878,17 +896,20 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None: late = await instances.upsert( definition.id, child.id, D20, _now_stamp(), window="morning") await events.append( - early.id, child.id, "completed", "panel", _now_stamp(), True + early.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) await events.append( - inside.id, child.id, "completed", "panel", _now_stamp(), False + inside.id, child.id, "completed", "panel", _now_stamp(), False, + actor_child_id=child.id ) await events.append( inside.id, child.id, "uncompleted", "user", _now_stamp(), True, actor_user_id="user-1", ) await events.append( - late.id, child.id, "completed", "panel", _now_stamp(), True + late.id, child.id, "completed", "panel", _now_stamp(), True, + actor_child_id=child.id ) found = await events.list_by_child_and_date_range( child.id, D2, D2 @@ -922,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None: # queried on today's date finds it too. event = await events.append( late_due.id, child.id, "completed", "panel", - _now_stamp(), False, + _now_stamp(), False, actor_child_id=child.id, ) by_due = await events.list_by_child_and_date_range( child.id, D20, D20 @@ -1010,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None: # UPDATE/DELETE/non-DAO-INSERT paths hit the pattern. insert_exempt = { "custom_components/nestquest/dao_instances.py", + "custom_components/nestquest/migrations.py", # the rebuild + # migrations re-create the table via INSERT..SELECT (append- + # order preserved); UPDATE/DELETE stays forbidden everywhere "tests/test_schema.py", + "tests/test_migrations.py", # seeds frozen legacy shapes to + # exercise the rebuild migrations "tests/test_dao_instances.py", # this guard file; scanned by # its own guard's regex spans below instead } diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 2360ffe..a2ab3ba 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -372,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation( async def _body(w: World): # insert: append a completion then a reversal, then re-complete await w.events.append( - w.instance.id, w.child.id, "completed", "panel", _stamp(), True + w.instance.id, w.child.id, "completed", "panel", _stamp(), True, + actor_child_id=w.child.id, ) await w.events.append( w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None, actor_user_id="user-1", ) await w.events.append( - w.instance.id, w.child.id, "completed", "panel", _stamp(), False + w.instance.id, w.child.id, "completed", "panel", _stamp(), False, + actor_child_id=w.child.id, ) # read: full ordered history + latest history = await w.events.list_by_instance(w.instance.id) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 8eee3ee..7cf0624 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -149,16 +149,17 @@ def test_migration_list_shape() -> None: (D-007) to create the quest_* tables directly; migration 2 exists for dev databases stamped version 1 by the pre-rename runner, migration 3 brings pre-D-008 definitions to the multi-assignee - model, migration 4 adds the windows table, and migration 5 rebuilds + model, migration 4 adds the windows table, migration 5 rebuilds quest_instances onto the widened (definition, child, date, window) - key. + key, and migration 6 adds actor_child_id to completion_events. """ assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS assert callable(MIGRATIONS[1]) assert callable(MIGRATIONS[2]) assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL assert callable(MIGRATIONS[4]) - assert len(MIGRATIONS) == 5 + assert callable(MIGRATIONS[5]) + assert len(MIGRATIONS) == 6 # --------------------------------------------------------------------------- @@ -442,6 +443,21 @@ _V2_INSTANCES_DDL = [ """, ] +_V2_EVENTS_DDL = [ + """ + CREATE TABLE IF NOT EXISTS completion_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL REFERENCES quest_instances(id), + child_id INTEGER NOT NULL REFERENCES children(id), + event_type TEXT NOT NULL, + actor_source TEXT NOT NULL, + actor_user_id TEXT, + occurred_at TEXT NOT NULL, + was_on_time INTEGER + ) + """, +] + def _seed_v2_multi_assignee_upgrade(database) -> None: """Create a database shaped like a post-rename, pre-D-008 dev file. @@ -458,12 +474,13 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: "quest_definition_assignees ", "quest_definition_windows ", "quest_instances ", + "completion_events ", ) for sql in SCHEMA_V1_STATEMENTS: if any(name in sql for name in _v2_replaced): continue _run(database.execute(sql)) - for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL: + for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL: _run(database.execute(sql)) _run(database.execute(VERSION_TABLE_DDL)) _run( @@ -503,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None: "VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')" ) ) + # One legacy panel event and one legacy admin event: the panel one + # must backfill actor_child_id to its own child; the admin one must + # stay NULL. + _run( + database.execute( + "INSERT INTO completion_events (instance_id, child_id, " + "event_type, actor_source, occurred_at, was_on_time) " + "VALUES (1, 2, 'completed', 'panel', " + "'2026-09-15T10:00:00+00:00', 1)" + ) + ) + _run( + database.execute( + "INSERT INTO completion_events (instance_id, child_id, " + "event_type, actor_source, actor_user_id, occurred_at, " + "was_on_time) " + "VALUES (1, 2, 'uncompleted', 'user', 'parent-1', " + "'2026-09-15T11:00:00+00:00', 1)" + ) + ) def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: @@ -559,6 +596,23 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None: assert [ "definition_id", "child_id", "due_date", "window" ] in unique_columns + # The completion events survived the actor_child_id rebuild: + # the panel row backfilled to its own child, the admin row + # stayed NULL, and ids are preserved. + events = _run( + database.fetch_all( + "SELECT id, event_type, actor_source, actor_child_id " + "FROM completion_events ORDER BY id" + ) + ) + assert events == [ + (1, "completed", "panel", 2), + (2, "uncompleted", "user", None), + ] + columns = _run( + database.fetch_all("PRAGMA table_info(completion_events)") + ) + assert "actor_child_id" in {c[1] for c in columns} # No dangling references anywhere. assert _run(database.fetch_all("PRAGMA foreign_key_check")) == [] # The rebuilt table is live for the new model: a definition diff --git a/tests/test_schema.py b/tests/test_schema.py index e27c774..f869176 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1851,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None: ("event_type", "TEXT", 1, None, 0), ("actor_source", "TEXT", 1, None, 0), ("actor_user_id", "TEXT", 0, None, 0), + ("actor_child_id", "INTEGER", 0, None, 0), ("occurred_at", "TEXT", 1, None, 0), ("was_on_time", "INTEGER", 0, None, 0), ] @@ -1881,6 +1882,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None: assert declared == { ("quest_instances", "instance_id", "id"), ("children", "child_id", "id"), + ("children", "actor_child_id", "id"), } finally: _run(database.close()) @@ -2182,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None: instance_id=instance_id, actor_source="panel", actor_user_id=None, + actor_child_id=1, was_on_time=0, ) row = _run( database.fetch_one( - "SELECT actor_source, actor_user_id, was_on_time " - "FROM completion_events WHERE id = ?", + "SELECT actor_source, actor_user_id, actor_child_id, " + "was_on_time FROM completion_events WHERE id = ?", (event_id,), ) ) - assert row == ("panel", None, 0) + assert row == ("panel", None, 1, 0) + finally: + _run(database.close()) + + +@pytest.mark.parametrize( + ("actor_source", "actor_user_id", "actor_child_id"), + [ + ("user", "user-1", 1), # admin event must not carry a profile + ("panel", None, None), # panel event requires the profile + ("panel", "user-1", 1), # panel must not carry a user id either + ("panel", "user-1", None), + ("user", None, None), + ], +) +def test_completion_events_actor_child_coherence_enforced( + tmp_path, actor_source, actor_user_id, actor_child_id +) -> None: + """D-008 actor-pair shape: 'user' events carry actor_user_id and no + actor_child_id; 'panel' events carry actor_child_id and no + actor_user_id. The CHECKs make both halves non-negotiable.""" + database = _open_db(tmp_path / "completion-events-coherence.db") + try: + _apply(database) + _setup_definition(database) + instance_id = _insert_instance(database, definition_id=1) + with pytest.raises(sqlite3.IntegrityError, match="CHECK"): + _insert_event( + database, + instance_id=instance_id, + event_type="completed", + actor_source=actor_source, + actor_user_id=actor_user_id, + actor_child_id=actor_child_id, + was_on_time=1, + ) + finally: + _run(database.close()) + + +def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None: + database = _open_db(tmp_path / "completion-events-bad-actor-child.db") + try: + _apply(database) + _setup_definition(database) + instance_id = _insert_instance(database, definition_id=1) + with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"): + _insert_event( + database, + instance_id=instance_id, + actor_source="panel", + actor_user_id=None, + actor_child_id=999, + was_on_time=1, + ) finally: _run(database.close())