Files
F-r-ldraledigplannerare/backend/tests/integration/test_migration_010_downgrade_reconstruction.py
T
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:20:14 +02:00

326 lines
10 KiB
Python

"""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