"""Migration 010's downgrade: reconstruct `is_pinned`/`planned_day_exclusion` from `day_assertion` instead of losing them. The naive downgrade — `ADD COLUMN is_pinned ... DEFAULT 0` plus a bare `CREATE TABLE planned_day_exclusion` — silently unpins every previously pinned day and drops every exclusion, then a further `downgrade` to 008 deletes `day_assertion` itself, at which point the facts are gone for good. This proves the fixed downgrade instead reconstructs both shapes from `day_assertion` (still present at the moment 010's `downgrade()` runs) for a full `upgrade head` -> `downgrade 008` round trip, and pins down exactly the two cases that are honestly NOT round-trippable (see 010's docstring): - a concrete assertion whose `wish_id IS NULL`-driven mirrored row was never pinned still comes back with `is_pinned = 1` (over-restored, not under-restored — `day_assertion` never recorded *why* a row was asserted). - a pinned row that collided with an exclusion at the same key loses its `is_pinned` fact at 009's *upgrade* already (`INSERT OR REPLACE`, exclusion wins), long before 010's downgrade runs, so there is nothing left to recover it from. """ 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, now: str, ) -> str: row_id = str(uuid.uuid4()) 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 (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)", ( row_id, workspace_id, child_id, parent_id, day.isoformat(), day_type, wish_id, int(is_pinned), now, now, ), ) return row_id 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 _planned_day(db_path: Path, row_id: str) -> sqlite3.Row: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row try: row = conn.execute("SELECT * FROM planned_leave_day WHERE id = ?", (row_id,)).fetchone() assert row is not None return row finally: conn.close() def _exclusion_exists( db_path: Path, *, workspace_id: str, child_id: str, parent_id: str, day: date ) -> bool: conn = sqlite3.connect(db_path) try: row = conn.execute( "SELECT 1 FROM planned_day_exclusion " "WHERE workspace_id = ? AND child_id = ? AND parent_personal_data_uuid = ? " "AND date = ?", (workspace_id, child_id, parent_id, day.isoformat()), ).fetchone() return row is not None finally: conn.close() def test_full_round_trip_reconstructs_pins_and_exclusions(tmp_path: Path) -> None: db = tmp_path / "roundtrip.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()) pinned_day = date(2027, 1, 10) pinned_id = _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=pinned_day, day_type="lagsta", is_pinned=True, wish_id=wish_id, now=now, ) adhoc_day = date(2027, 1, 11) adhoc_id = _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=adhoc_day, day_type="vacation", is_pinned=False, wish_id=None, now=now, ) pinned_and_adhoc_day = date(2027, 1, 12) pinned_and_adhoc_id = _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=pinned_and_adhoc_day, day_type="sjukpenning", is_pinned=True, wish_id=None, now=now, ) derived_day = date(2027, 1, 13) derived_id = _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=derived_day, day_type="sjukpenning", is_pinned=False, wish_id=wish_id, now=now, ) plain_exclusion_day = date(2027, 1, 14) _insert_exclusion( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=plain_exclusion_day, now=now, ) # A pinned row and a colliding exclusion at the same key (a historical # anomaly the runtime never creates going forward, but old data might # still hold) — 009's upgrade resolves this "exclusion wins". collision_day = date(2027, 1, 15) collision_id = _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=collision_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=collision_day, now=now, ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") command.downgrade(_alembic_config(db), "008") # -- pinned row: round-trips exactly ------------------------------------ pinned_row = _planned_day(db, pinned_id) assert bool(pinned_row["is_pinned"]) is True assert pinned_row["day_type"] == "lagsta" assert pinned_row["wish_id"] == wish_id # -- ad-hoc, never-pinned row: over-restored, documented and expected -- # `day_assertion` never recorded whether a concrete assertion came from # `is_pinned=1` or from `wish_id IS NULL` (009's own upgrade backfill # already conflates them: `WHERE is_pinned = 1 OR wish_id IS NULL`), so # this row comes back pinned even though it started unpinned. It is # still exempt from the allocator via `wish_id IS NULL`, unaffected by # this bit either way. adhoc_row = _planned_day(db, adhoc_id) assert bool(adhoc_row["is_pinned"]) is True assert adhoc_row["wish_id"] is None # -- pinned AND ad-hoc: round-trips exactly (was already pinned) ------- both_row = _planned_day(db, pinned_and_adhoc_id) assert bool(both_row["is_pinned"]) is True # -- ordinary planner-derived row: never asserted, stays unpinned ------- derived_row = _planned_day(db, derived_id) assert bool(derived_row["is_pinned"]) is False assert derived_row["wish_id"] == wish_id # -- plain exclusion: round-trips exactly -------------------------------- assert _exclusion_exists( db, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=plain_exclusion_day ) # -- collision: exclusion wins, is_pinned is a KNOWN, documented loss --- # 009's upgrade already discarded the pinned assertion at this key via # `INSERT OR REPLACE` (exclusion wins) before 010 ever runs, so there is # no assertion left for 010's downgrade to recover `is_pinned` from. # This is not a bug in the downgrade under test — it is a fact already # lost one migration earlier, and the exclusion itself is what survives. collision_row = _planned_day(db, collision_id) assert bool(collision_row["is_pinned"]) is False assert _exclusion_exists( db, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=collision_day ) def test_concrete_assertion_with_no_mirrored_row_does_not_error(tmp_path: Path) -> None: """An assertion can outlive its `planned_leave_day` mirror (e.g. a later plan sync re-derives the day away). Reconstruction must not crash on it — there is simply nothing to set `is_pinned` on. """ db = tmp_path / "orphan.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, 2, 1) row_id = _insert_planned_leave_day( conn, workspace_id=ws_id, child_id=child_id, parent_id=parent_id, day=day, day_type="vacation", is_pinned=True, wish_id=None, now=now, ) conn.commit() conn.close() command.upgrade(_alembic_config(db), "head") # Delete the mirrored row after the assertion was backfilled, simulating # a later re-derivation that removed it — the assertion survives. conn = sqlite3.connect(db) conn.execute("DELETE FROM planned_leave_day WHERE id = ?", (row_id,)) conn.commit() conn.close() # Must downgrade cleanly, not raise. command.downgrade(_alembic_config(db), "008") conn = sqlite3.connect(db) try: remaining = conn.execute( "SELECT COUNT(*) FROM planned_leave_day WHERE workspace_id = ?", (ws_id,) ).fetchone()[0] finally: conn.close() assert remaining == 0