Files
F-r-ldraledigplannerare/backend/tests/integration/test_migration_004_backfill.py
T
jacobh2 6d92b87bf3 Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:53:25 +02:00

286 lines
9.6 KiB
Python

"""Migration 004's backfill: one `recurrence='yearly'` row -> one row per year.
`test_schema_parity.py` proves the migration chain and `_FULL_SCHEMA` describe
the same *shape*. This proves 004 moves the *data* correctly: an existing
repeating wish must come out the other side as a real row per year, sharing a
`series_id`, each carrying the dates the pre-004 `window_for_year()` would
have produced for that year — including the fixed-mode weekday snap. Getting
that wrong would silently move every recurring family's summer.
Driven through the real Alembic chain (upgrade to 003, insert pre-004 rows,
upgrade to head), not through `_FULL_SCHEMA`, because the pre-004 shape no
longer exists anywhere else.
"""
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 _insert_pre_004_fixture(
db_path: Path,
*,
child_birth_date: date | None,
wishes: list[dict[str, object]],
) -> dict[str, str]:
"""Seed a workspace (+ optional child) and the given pre-004 leave_wish rows."""
now = datetime(2026, 1, 1, 12, 0, 0).isoformat()
ws_id = str(uuid.uuid4())
child_id = str(uuid.uuid4()) if child_birth_date is not None else None
conn = sqlite3.connect(db_path)
try:
conn.execute(
"INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)",
(ws_id, "WS", now),
)
if child_id is not None:
assert child_birth_date is not None
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", child_birth_date.isoformat(), now),
)
for wish in wishes:
conn.execute(
"INSERT INTO leave_wish (id, workspace_id, child_id, name, "
"window_start_month, window_start_day, window_end_month, window_end_day, "
"target_weeks, recurrence, first_year, last_year, priority, parent_mode, "
"window_mode, created_at, updated_at) "
"VALUES (:id, :workspace_id, :child_id, :name, :wsm, :wsd, :wem, :wed, "
":target_weeks, :recurrence, :first_year, :last_year, 1, 'either', "
":window_mode, :now, :now)",
{
"workspace_id": ws_id,
"child_id": child_id if wish.get("tied_to_child", True) else None,
"now": now,
**{k: v for k, v in wish.items() if k != "tied_to_child"},
},
)
conn.commit()
finally:
conn.close()
return {"workspace_id": ws_id, "child_id": child_id or ""}
def _rows(db_path: Path, name: str) -> list[sqlite3.Row]:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
return conn.execute(
"SELECT * FROM leave_wish WHERE name = ? ORDER BY first_year", (name,)
).fetchall()
finally:
conn.close()
def test_yearly_wish_is_expanded_into_one_row_per_year(tmp_path: Path) -> None:
db = tmp_path / "backfill.db"
command.upgrade(_alembic_config(db), "003")
anchor_id = str(uuid.uuid4())
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": anchor_id,
"name": "Sommar",
# 2026-10-05 is a Monday; 10-11 the Sunday after.
"wsm": 10,
"wsd": 5,
"wem": 10,
"wed": 11,
"target_weeks": 1,
"recurrence": "yearly",
"first_year": 2026,
"last_year": 2029,
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Sommar")
assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029]
# One series, and the original row survives as the first year's occurrence
# (so the `planned_leave_day.wish_id` rows pointing at it stay attached).
series_ids = {r["series_id"] for r in rows}
assert len(series_ids) == 1
assert None not in series_ids
assert rows[0]["id"] == anchor_id
# The weekday snap is now IN the rows: each year keeps Monday->Sunday
# rather than drifting to the naive 10-05 (a Tuesday in 2027).
stored = [(r["window_start_month"], r["window_start_day"]) for r in rows]
assert stored == [(10, 5), (10, 4), (10, 2), (10, 8)]
for row in rows:
start = date(row["first_year"], row["window_start_month"], row["window_start_day"])
end = date(row["first_year"], row["window_end_month"], row["window_end_day"])
assert start.weekday() == 0, row["first_year"]
assert end.weekday() == 6, row["first_year"]
assert (end - start).days == 6
# Series-level intent is mirrored onto every row.
assert {r["last_year"] for r in rows} == {2029}
# And the dropped `recurrence` column really is gone.
assert "recurrence" not in rows[0].keys()
def test_open_ended_yearly_wish_stops_at_the_childs_fourth_birthday_year(tmp_path: Path) -> None:
db = tmp_path / "age4.db"
command.upgrade(_alembic_config(db), "003")
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1), # 4th birthday in 2030
wishes=[
{
"id": str(uuid.uuid4()),
"name": "Jul",
"wsm": 12,
"wsd": 20,
"wem": 1,
"wed": 6, # wraps into January
"target_weeks": 3,
"recurrence": "yearly",
"first_year": 2026,
"last_year": None, # "until the child turns 4"
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Jul")
assert [r["first_year"] for r in rows] == [2026, 2027, 2028, 2029, 2030]
assert {r["last_year"] for r in rows} == {None}
# Still a wrapping window on every row: end sorts before start.
for row in rows:
assert (row["window_end_month"], row["window_end_day"]) < (
row["window_start_month"],
row["window_start_day"],
)
def test_flexible_yearly_wish_keeps_the_same_bounding_range_every_year(tmp_path: Path) -> None:
db = tmp_path / "flexible.db"
command.upgrade(_alembic_config(db), "003")
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": str(uuid.uuid4()),
"name": "Var",
"wsm": 3,
"wsd": 1,
"wem": 5,
"wed": 31,
"target_weeks": 1,
"recurrence": "yearly",
"first_year": 2027,
"last_year": 2029,
"window_mode": "flexible",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Var")
assert [r["first_year"] for r in rows] == [2027, 2028, 2029]
# A flexible wish's four fields are a bounding range, never a placement —
# no weekday snap, so every year's range is the identical month/day pair.
assert {
(r["window_start_month"], r["window_start_day"], r["window_end_month"], r["window_end_day"])
for r in rows
} == {(3, 1, 5, 31)}
def test_once_wish_is_left_alone_with_no_series(tmp_path: Path) -> None:
db = tmp_path / "once.db"
command.upgrade(_alembic_config(db), "003")
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": str(uuid.uuid4()),
"name": "Engang",
"wsm": 4,
"wsd": 1,
"wem": 4,
"wed": 7,
"target_weeks": 1,
"recurrence": "once",
"first_year": 2027,
"last_year": None,
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
rows = _rows(db, "Engang")
assert len(rows) == 1
assert rows[0]["series_id"] is None
assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (4, 1)
def test_downgrade_collapses_a_series_back_to_its_first_year(tmp_path: Path) -> None:
db = tmp_path / "roundtrip.db"
command.upgrade(_alembic_config(db), "003")
anchor_id = str(uuid.uuid4())
_insert_pre_004_fixture(
db,
child_birth_date=date(2026, 5, 1),
wishes=[
{
"id": anchor_id,
"name": "Sommar",
"wsm": 6,
"wsd": 15,
"wem": 7,
"wed": 26,
"target_weeks": 6,
"recurrence": "yearly",
"first_year": 2026,
"last_year": 2029,
"window_mode": "fixed",
}
],
)
command.upgrade(_alembic_config(db), "head")
assert len(_rows(db, "Sommar")) == 4
command.downgrade(_alembic_config(db), "003")
rows = _rows(db, "Sommar")
assert len(rows) == 1
assert rows[0]["id"] == anchor_id
assert rows[0]["recurrence"] == "yearly"
assert rows[0]["first_year"] == 2026
assert (rows[0]["window_start_month"], rows[0]["window_start_day"]) == (6, 15)