"""Migration 008's data rewrite: realign already-materialised flexible series. `project_window` (`app/entities/leave_wish.py`) now snaps a `window_mode == 'flexible'` bound's start to the anchor year's start weekday too (Step 1 of the flexible-window realignment fix), holding the bound's day-span fixed — mirroring what `window_mode == 'fixed'` already did. But `project_window` only runs once, at materialisation time (`LeaveWishService`); nothing re-derives it at read time. Every flexible series row materialised before this fix therefore stays unsnapped forever unless something rewrites it once. Migration 008 is that one-time rewrite. Driven through the real Alembic chain (upgrade to 007, insert POST-004-shape rows directly — the one-row-per-year shape already exists by 007, so there is no pre-004 fixture to build here — then upgrade to head/008), mirroring `test_migration_004_backfill.py`'s style. """ from __future__ import annotations import sqlite3 import uuid from datetime import date, datetime from pathlib import Path from typing import Any 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_post_004_fixture(db_path: Path, *, rows: list[dict[str, object]]) -> str: """Seed a workspace and the given POST-004-shape `leave_wish` rows. `rows` are full leave_wish rows (one per year already) — the shape 004 produces and 007 leaves untouched — grouped by `series_id`. """ now = datetime(2026, 1, 1, 12, 0, 0).isoformat() ws_id = str(uuid.uuid4()) conn = sqlite3.connect(db_path) try: conn.execute( "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", (ws_id, "WS", now), ) for row in rows: 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, first_year, last_year, priority, parent_mode, " "window_mode, series_id, created_at, updated_at) " "VALUES (:id, :workspace_id, NULL, :name, :wsm, :wsd, :wem, :wed, " ":target_weeks, :first_year, :last_year, 1, 'either', " ":window_mode, :series_id, :now, :now)", {"workspace_id": ws_id, "now": now, **row}, ) conn.commit() finally: conn.close() return ws_id 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 _flexible_series( *, name: str, series_id: str, first_year: int, last_year: int, wsm: int, wsd: int ) -> list[dict[str, Any]]: """Rows in 004's shape for a flexible series whose bound was NAIVELY materialised (every year the identical month/day pair) — exactly what every flexible series looked like before this fix, and what 008 must rewrite. """ return [ { "id": str(uuid.uuid4()), "name": name, "wsm": wsm, "wsd": wsd, "wem": 5, "wed": 31, "target_weeks": 1, "first_year": year, "last_year": last_year, "window_mode": "flexible", "series_id": series_id, } for year in range(first_year, last_year + 1) ] def test_upgrade_realigns_a_flexible_series_onto_its_anchor_weekday(tmp_path: Path) -> None: db = tmp_path / "realign.db" command.upgrade(_alembic_config(db), "007") series_id = str(uuid.uuid4()) # 2027-03-01 is a Monday; naively every year got the same (3, 1). rows_in = _flexible_series( name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1 ) _insert_post_004_fixture(db, rows=rows_in) command.upgrade(_alembic_config(db), "head") rows = _rows(db, "Var") assert [r["first_year"] for r in rows] == [2027, 2028, 2029] # Anchor (2027) untouched; 2028/2029 snapped to Monday, span held fixed. stored = [(r["window_start_month"], r["window_start_day"]) for r in rows] assert stored == [(3, 1), (2, 28), (2, 26)] 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 - start).days == 91, row["first_year"] def test_downgrade_restores_the_naive_anchor_month_day(tmp_path: Path) -> None: db = tmp_path / "realign_roundtrip.db" command.upgrade(_alembic_config(db), "007") series_id = str(uuid.uuid4()) rows_in = _flexible_series( name="Var", series_id=series_id, first_year=2027, last_year=2029, wsm=3, wsd=1 ) _insert_post_004_fixture(db, rows=rows_in) command.upgrade(_alembic_config(db), "head") assert {(r["window_start_month"], r["window_start_day"]) for r in _rows(db, "Var")} != {(3, 1)} command.downgrade(_alembic_config(db), "007") rows = _rows(db, "Var") assert [r["first_year"] for r in rows] == [2027, 2028, 2029] assert {(r["window_start_month"], r["window_start_day"]) for r in rows} == {(3, 1)} assert {(r["window_end_month"], r["window_end_day"]) for r in rows} == {(5, 31)} def test_fixed_series_is_left_untouched(tmp_path: Path) -> None: """`project_window` already snapped a fixed series correctly at 004's materialisation time — 008 must be a no-op for it, not a costly re-run of an already-correct projection. """ db = tmp_path / "fixed_untouched.db" command.upgrade(_alembic_config(db), "007") series_id = str(uuid.uuid4()) rows_in = [ { "id": str(uuid.uuid4()), "name": "Sommar", "wsm": 10, "wsd": 5 if year == 2026 else (4 if year == 2027 else 2), "wem": 10, "wed": 11 if year == 2026 else (10 if year == 2027 else 8), "target_weeks": 1, "first_year": year, "last_year": 2028, "window_mode": "fixed", "series_id": series_id, } for year in (2026, 2027, 2028) ] _insert_post_004_fixture(db, rows=rows_in) before = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")} command.upgrade(_alembic_config(db), "head") after = {r["first_year"]: dict(r) for r in _rows(db, "Sommar")} for year, row in before.items(): assert after[year]["window_start_month"] == row["window_start_month"] assert after[year]["window_start_day"] == row["window_start_day"] assert after[year]["window_end_month"] == row["window_end_month"] assert after[year]["window_end_day"] == row["window_end_day"]