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
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""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
|
||||
@@ -0,0 +1,325 @@
|
||||
"""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
|
||||
@@ -1024,30 +1024,26 @@ class TestLeaveWishNameMessageKeyRoundTrip:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9) is_pinned / wish_id ROUND-TRIP, AND SURVIVAL THROUGH /api/plan/sync
|
||||
# (routers/planned_days.py bug — both fields were accepted by the request
|
||||
# schema and understood by PlanningService, but silently dropped by the
|
||||
# router before ever reaching the service, so "pin this day" never
|
||||
# persisted). One shared registration for both scenarios — see
|
||||
# `TestLeaveWishNameMessageKeyRoundTrip` above for why: `/api/auth/register`
|
||||
# is rate-limited to 10/minute and this module already registers a user per
|
||||
# test class.
|
||||
# 9) DayAssertion round-trip AND survival through /api/plan/sync
|
||||
# (restructure step 3b — "pinned" moved off `planned_leave_day.is_pinned`,
|
||||
# now dropped, onto `POST /api/day-assertions`; there is no
|
||||
# `PUT /api/planned-days/{id}` any more, only reads). One shared
|
||||
# registration for both scenarios — see `TestLeaveWishNameMessageKeyRoundTrip`
|
||||
# above for why: `/api/auth/register` is rate-limited to 10/minute and this
|
||||
# module already registers a user per test class.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
|
||||
"""`PlannedLeaveDayCreate`/`Update` both carry `is_pinned`/`wish_id`, and
|
||||
class TestDayAssertionRoundTripAndSyncSurvival:
|
||||
"""`POST /api/day-assertions` is the sole way to pin a day now (there is
|
||||
|
||||
`PlanningService.add_planned_day`/`.update` both accept them — but the
|
||||
router never forwarded either field, so a client setting `is_pinned:
|
||||
true` (the "pin this day so the allocator won't retype it" gesture) had
|
||||
it silently discarded. Both scenarios (the raw POST/PUT round trip, and
|
||||
the end-to-end "survives a re-sync" behaviour it exists for) share a
|
||||
single registration, same reasoning as `TestLeaveWishNameMessageKeyRoundTrip`
|
||||
above.
|
||||
no `PUT /api/planned-days/{id}` any more). Both scenarios (the raw
|
||||
create/clear round trip, and the end-to-end "survives a re-sync"
|
||||
behaviour pinning exists for) share a single registration, same
|
||||
reasoning as `TestLeaveWishNameMessageKeyRoundTrip` above.
|
||||
"""
|
||||
|
||||
async def test_is_pinned_round_trips_and_survives_a_subsequent_sync(
|
||||
async def test_day_assertion_round_trips_and_survives_a_subsequent_sync(
|
||||
self, client: AsyncClient, connection: aiosqlite.Connection
|
||||
) -> None:
|
||||
headers, ws_id, pd_uuid = await _register(client, "pin-roundtrip@example.com", "Alex")
|
||||
@@ -1074,57 +1070,61 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
|
||||
)
|
||||
wish_id = wish["id"]
|
||||
|
||||
# --- part 1: raw POST/PUT round trip -----------------------------
|
||||
# --- part 1: raw POST/DELETE round trip --------------------------
|
||||
probe_date = _next_workday(date.today())
|
||||
create_resp = await client.post(
|
||||
"/api/planned-days",
|
||||
"/api/day-assertions",
|
||||
headers=headers,
|
||||
json={
|
||||
"workspace_id": str(ws_id),
|
||||
"child_id": str(child_id),
|
||||
"parent_personal_data_uuid": str(pd_uuid),
|
||||
# An ad-hoc probe row for the is_pinned round trip below —
|
||||
# the exact date is irrelevant except that it must be legal
|
||||
# to plan as `vacation` (never a free day; see
|
||||
# `PlanningService._reject_illegal_free_day`).
|
||||
"date": _next_workday(date.today()).isoformat(),
|
||||
# An ad-hoc probe assertion — the exact date is irrelevant
|
||||
# except that it must be legal to plan as `vacation` (never
|
||||
# a free day; see `app.services.day_guards.
|
||||
# reject_illegal_free_day`).
|
||||
"date": probe_date.isoformat(),
|
||||
"day_type": "vacation",
|
||||
"is_dubbeldag": False,
|
||||
"wish_id": wish_id,
|
||||
"is_pinned": True,
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
created = create_resp.json()
|
||||
assert created["is_pinned"] is True, (
|
||||
"is_pinned must round-trip through POST /api/planned-days, not be silently "
|
||||
"dropped by the router"
|
||||
)
|
||||
assert created["day_type"] == "vacation"
|
||||
assert created["wish_id"] == wish_id
|
||||
|
||||
day_id = created["id"]
|
||||
# The assertion mirrors immediately into `planned_leave_day` — no
|
||||
# sync required to see it.
|
||||
planned = await _list_planned_days(client, headers, child_id)
|
||||
mirrored = next(d for d in planned if d["date"] == probe_date.isoformat())
|
||||
assert mirrored["is_pinned"] is True, (
|
||||
"a concrete DayAssertion must mirror into planned_leave_day immediately, "
|
||||
"and the row must report is_pinned=true for it"
|
||||
)
|
||||
|
||||
# Flip both fields via PUT — a partial update — and confirm the new
|
||||
# values, not the create-time ones, come back.
|
||||
update_resp = await client.put(
|
||||
f"/api/planned-days/{day_id}",
|
||||
# Clear it (by natural key — DELETE /api/day-assertions takes no id)
|
||||
# before part 2 so it can't be mistaken for one of the wish's own
|
||||
# placed days below.
|
||||
clear_resp = await client.request(
|
||||
"DELETE",
|
||||
"/api/day-assertions",
|
||||
headers=headers,
|
||||
json={"is_pinned": False},
|
||||
params={
|
||||
"workspace_id": str(ws_id),
|
||||
"child_id": str(child_id),
|
||||
"parent_personal_data_uuid": str(pd_uuid),
|
||||
"date": probe_date.isoformat(),
|
||||
},
|
||||
)
|
||||
assert update_resp.status_code == 200, update_resp.text
|
||||
updated = update_resp.json()
|
||||
assert updated["is_pinned"] is False, (
|
||||
"is_pinned must round-trip through PUT /api/planned-days/{id} too"
|
||||
assert clear_resp.status_code == 204, clear_resp.text
|
||||
after_clear = await _list_planned_days(client, headers, child_id)
|
||||
assert not any(d["date"] == probe_date.isoformat() for d in after_clear), (
|
||||
"clearing the assertion must also remove the mirrored planned_leave_day row"
|
||||
)
|
||||
# wish_id was not mentioned in this PUT — must be left untouched.
|
||||
assert updated["wish_id"] == wish_id
|
||||
|
||||
# Delete the ad-hoc probe row before part 2 so it can't be mistaken
|
||||
# for one of the wish's own placed days below.
|
||||
del_resp = await client.delete(f"/api/planned-days/{day_id}", headers=headers)
|
||||
assert del_resp.status_code == 204, del_resp.text
|
||||
|
||||
# --- part 2: a pinned day survives a subsequent /api/plan/sync --
|
||||
# the end-to-end behaviour users actually rely on `is_pinned` for:
|
||||
# the end-to-end behaviour users actually rely on pinning for:
|
||||
# "changing a day's funding type pins it so the allocator won't
|
||||
# retype it".
|
||||
|
||||
@@ -1138,18 +1138,28 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
|
||||
pinned_day = wish_days[0]
|
||||
original_day_type = pinned_day["day_type"]
|
||||
# Retype it to whatever it currently is NOT, mirroring "the user
|
||||
# hand-edited this day's funding type" — then pin it so a re-sync
|
||||
# must leave it alone.
|
||||
# hand-edited this day's funding type" — then assert it so a
|
||||
# re-sync must leave it alone.
|
||||
new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation"
|
||||
|
||||
pin_resp = await client.put(
|
||||
f"/api/planned-days/{pinned_day['id']}",
|
||||
pin_resp = await client.post(
|
||||
"/api/day-assertions",
|
||||
headers=headers,
|
||||
json={"day_type": new_day_type, "is_pinned": True},
|
||||
json={
|
||||
"workspace_id": str(ws_id),
|
||||
"child_id": str(child_id),
|
||||
"parent_personal_data_uuid": str(pd_uuid),
|
||||
"date": pinned_day["date"],
|
||||
"day_type": new_day_type,
|
||||
"wish_id": wish_id,
|
||||
},
|
||||
)
|
||||
assert pin_resp.status_code == 200, pin_resp.text
|
||||
assert pin_resp.json()["is_pinned"] is True
|
||||
assert pin_resp.json()["day_type"] == new_day_type
|
||||
assert pin_resp.status_code == 201, pin_resp.text
|
||||
|
||||
pinned_after_assert = await _list_planned_days(client, headers, child_id)
|
||||
pinned_row = next(d for d in pinned_after_assert if d["date"] == pinned_day["date"])
|
||||
assert pinned_row["is_pinned"] is True
|
||||
assert pinned_row["day_type"] == new_day_type
|
||||
|
||||
# Re-sync: a fresh propose would want this date back at
|
||||
# `original_day_type` (the wish's natural funding choice) — the
|
||||
@@ -1157,7 +1167,7 @@ class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
|
||||
await _sync(client, headers, ws_id)
|
||||
|
||||
after = await _list_planned_days(client, headers, child_id)
|
||||
after_row = next(d for d in after if d["id"] == pinned_day["id"])
|
||||
after_row = next(d for d in after if d["date"] == pinned_day["date"])
|
||||
assert after_row["is_pinned"] is True
|
||||
assert after_row["day_type"] == new_day_type, (
|
||||
"a pinned day must survive a subsequent /api/plan/sync without being "
|
||||
|
||||
Reference in New Issue
Block a user