"""Migration 009's backfill: three legacy shapes -> one `day_assertion` row each. `test_schema_parity.py` proves the migration chain and `_FULL_SCHEMA` describe the same *shape*. This proves 009 moves the *data* correctly — restructure step 3 (see `~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`): - a pinned `planned_leave_day` row (`is_pinned=1`) -> an assertion carrying its `day_type`/`is_dubbeldag`/`wish_id`. - an ad-hoc `planned_leave_day` row (`wish_id IS NULL`) -> an assertion the same way, even when NOT pinned. - an ordinary planner-derived row (`is_pinned=0`, `wish_id` set) -> nothing: it is exactly what the new table must NOT treat as an assertion. - every `planned_day_exclusion` row -> a `day_type='none'` assertion. - when a legacy exclusion and a pinned/ad-hoc row coincide at the same key, the exclusion wins (`day_type='none'` survives, not the pin). Driven through the real Alembic chain (upgrade to 008, insert pre-009 rows, upgrade to head), not through `_FULL_SCHEMA`, because the pre-009 backfill source rows must exist in their pre-migration shape. """ from __future__ import annotations import sqlite3 import uuid from datetime import date, datetime from pathlib import Path from alembic import command from alembic.config import Config def _alembic_config(db_path: Path) -> Config: cfg = Config("alembic.ini") cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") return cfg def _seed_workspace_and_child(conn: sqlite3.Connection, now: str) -> tuple[str, str]: ws_id = str(uuid.uuid4()) child_id = str(uuid.uuid4()) conn.execute( "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", (ws_id, "WS", now) ) conn.execute( "INSERT INTO child (id, workspace_id, name, birth_date, " "preschool_start_date, preschool_planning_days_per_year, created_at) " "VALUES (?, ?, ?, ?, NULL, 0, ?)", (child_id, ws_id, "Barn", date(2026, 5, 1).isoformat(), now), ) return ws_id, child_id def _insert_planned_leave_day( conn: sqlite3.Connection, *, workspace_id: str, child_id: str, parent_id: str, day: date, day_type: str, is_pinned: bool, wish_id: str | None, is_dubbeldag: bool = False, now: str, ) -> None: conn.execute( "INSERT INTO planned_leave_day " "(id, workspace_id, child_id, parent_personal_data_uuid, date, day_type, " " is_dubbeldag, wish_id, is_pinned, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( str(uuid.uuid4()), workspace_id, child_id, parent_id, day.isoformat(), day_type, int(is_dubbeldag), wish_id, int(is_pinned), now, now, ), ) def _insert_exclusion( conn: sqlite3.Connection, *, workspace_id: str, child_id: str, parent_id: str, day: date, now: str, ) -> None: conn.execute( "INSERT INTO planned_day_exclusion " "(id, workspace_id, child_id, parent_personal_data_uuid, date, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (str(uuid.uuid4()), workspace_id, child_id, parent_id, day.isoformat(), now, now), ) def _assertions(db_path: Path, workspace_id: str) -> list[sqlite3.Row]: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row try: return conn.execute( "SELECT * FROM day_assertion WHERE workspace_id = ? ORDER BY date", (workspace_id,) ).fetchall() finally: conn.close() def test_pinned_row_becomes_a_concrete_assertion(tmp_path: Path) -> None: db = tmp_path / "pinned.db" command.upgrade(_alembic_config(db), "008") now = datetime(2026, 1, 1, 12, 0, 0).isoformat() conn = sqlite3.connect(db) ws_id, child_id = _seed_workspace_and_child(conn, now) parent_id = str(uuid.uuid4()) wish_id = str(uuid.uuid4()) _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=date(2027, 2, 3), day_type="lagsta", is_pinned=True, wish_id=wish_id, is_dubbeldag=True, now=now, ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") rows = _assertions(db, ws_id) assert len(rows) == 1 assert rows[0]["day_type"] == "lagsta" assert bool(rows[0]["is_dubbeldag"]) is True assert rows[0]["wish_id"] == wish_id assert rows[0]["parent_personal_data_uuid"] == parent_id def test_ad_hoc_row_becomes_an_assertion_even_when_not_pinned(tmp_path: Path) -> None: db = tmp_path / "adhoc.db" command.upgrade(_alembic_config(db), "008") now = datetime(2026, 1, 1, 12, 0, 0).isoformat() conn = sqlite3.connect(db) ws_id, child_id = _seed_workspace_and_child(conn, now) parent_id = str(uuid.uuid4()) _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=date(2027, 3, 1), day_type="vacation", is_pinned=False, wish_id=None, now=now, ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") rows = _assertions(db, ws_id) assert len(rows) == 1 assert rows[0]["day_type"] == "vacation" assert rows[0]["wish_id"] is None def test_ordinary_planner_derived_row_is_not_backfilled(tmp_path: Path) -> None: """The whole point of the new table: a derived (unpinned, wish-attributed) row is NOT a user assertion and must not become one. """ db = tmp_path / "derived.db" command.upgrade(_alembic_config(db), "008") now = datetime(2026, 1, 1, 12, 0, 0).isoformat() conn = sqlite3.connect(db) ws_id, child_id = _seed_workspace_and_child(conn, now) parent_id = str(uuid.uuid4()) wish_id = str(uuid.uuid4()) _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=date(2027, 4, 1), day_type="sjukpenning", is_pinned=False, wish_id=wish_id, now=now, ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") assert _assertions(db, ws_id) == [] def test_exclusion_row_becomes_a_none_assertion(tmp_path: Path) -> None: db = tmp_path / "exclusion.db" command.upgrade(_alembic_config(db), "008") now = datetime(2026, 1, 1, 12, 0, 0).isoformat() conn = sqlite3.connect(db) ws_id, child_id = _seed_workspace_and_child(conn, now) parent_id = str(uuid.uuid4()) _insert_exclusion( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=date(2027, 2, 3), now=now, ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") rows = _assertions(db, ws_id) assert len(rows) == 1 assert rows[0]["day_type"] == "none" assert rows[0]["wish_id"] is None assert bool(rows[0]["is_dubbeldag"]) is False def test_exclusion_wins_over_a_coinciding_pinned_row(tmp_path: Path) -> None: """Historical data where an exclusion and a pinned row share a key — shouldn't happen going forward (`PlannedDayExclusionService.exclude()` always deletes the `planned_leave_day` row first), but the migration must resolve it the same way at rest: the exclusion (a stronger, later instruction) wins. """ db = tmp_path / "conflict.db" command.upgrade(_alembic_config(db), "008") now = datetime(2026, 1, 1, 12, 0, 0).isoformat() conn = sqlite3.connect(db) ws_id, child_id = _seed_workspace_and_child(conn, now) parent_id = str(uuid.uuid4()) day = date(2027, 5, 1) _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=day, day_type="sjukpenning", is_pinned=True, wish_id=None, now=now, ) _insert_exclusion( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=day, now=now ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") rows = _assertions(db, ws_id) assert len(rows) == 1 assert rows[0]["day_type"] == "none" def test_downgrade_drops_the_table(tmp_path: Path) -> None: db = tmp_path / "downgrade.db" command.upgrade(_alembic_config(db), "head") conn = sqlite3.connect(db) tables = { row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() } conn.close() assert "day_assertion" in tables command.downgrade(_alembic_config(db), "008") conn = sqlite3.connect(db) tables = { row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall() } # is_pinned / planned_day_exclusion are untouched by 009 either direction — # step 3a is additive, see the migration's module docstring. assert "planned_day_exclusion" in tables conn.close() assert "day_assertion" not in tables