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:
2026-09-21 07:20:14 +02:00
parent fc90cf9a5f
commit 782415122d
101 changed files with 9542 additions and 3930 deletions
@@ -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
+67 -57
View File
@@ -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 "