Persist the wish and pinned flag a planned day is created with
The create and update handlers for a planned day accepted both fields in their request models and passed neither to the service, so a day hand-edited in the calendar was never marked as pinned and never kept its link back to the period that produced it. The allocator would then feel free to retype it on the next sync, which is precisely what pinning exists to prevent. Two investigations alongside this found no defect. A period that occurs once rather than yearly does get placed, and a window that wraps past New Year keeps the half that falls after today; both are now covered by tests, since neither case had any. Where those looked broken, the wish had simply run out of budget for that year, which the interface does not yet explain.
This commit is contained in:
@@ -193,6 +193,24 @@ async def _apply(
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _sync(client: AsyncClient, headers: dict[str, str], workspace_id: UUID) -> dict[str, Any]:
|
||||
resp = await client.post(
|
||||
"/api/plan/sync", headers=headers, json={"workspace_id": str(workspace_id)}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _list_planned_days(
|
||||
client: AsyncClient, headers: dict[str, str], child_id: UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
resp = await client.get(
|
||||
"/api/planned-days", headers=headers, params={"child_id": str(child_id)}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _paid_days_for_wish(proposal: dict[str, Any], wish_id: str) -> list[dict[str, Any]]:
|
||||
"""Proposed days belonging to `wish_id` that would actually cost a
|
||||
|
||||
@@ -942,3 +960,141 @@ class TestLeaveWishNameMessageKeyRoundTrip:
|
||||
body = resp.json()
|
||||
assert body["name"] == "Vinterresa"
|
||||
assert body["name_message_key"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
|
||||
"""`PlannedLeaveDayCreate`/`Update` both carry `is_pinned`/`wish_id`, and
|
||||
|
||||
`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.
|
||||
"""
|
||||
|
||||
async def test_is_pinned_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")
|
||||
birth_date = date.today() - relativedelta(months=6)
|
||||
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
||||
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
||||
|
||||
next_year = date.today().year + 1
|
||||
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
||||
|
||||
wish = await _create_wish(
|
||||
client,
|
||||
headers,
|
||||
workspace_id=str(ws_id),
|
||||
child_id=str(child_id),
|
||||
name="Pin Week",
|
||||
window_start_month=9,
|
||||
window_start_day=1,
|
||||
window_end_month=9,
|
||||
window_end_day=7,
|
||||
target_weeks=1,
|
||||
recurrence="once",
|
||||
first_year=next_year,
|
||||
)
|
||||
wish_id = wish["id"]
|
||||
|
||||
# --- part 1: raw POST/PUT round trip -----------------------------
|
||||
create_resp = await client.post(
|
||||
"/api/planned-days",
|
||||
headers=headers,
|
||||
json={
|
||||
"workspace_id": str(ws_id),
|
||||
"child_id": str(child_id),
|
||||
"parent_personal_data_uuid": str(pd_uuid),
|
||||
"date": date.today().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["wish_id"] == wish_id
|
||||
|
||||
day_id = created["id"]
|
||||
|
||||
# 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}",
|
||||
headers=headers,
|
||||
json={"is_pinned": False},
|
||||
)
|
||||
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"
|
||||
)
|
||||
# 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:
|
||||
# "changing a day's funding type pins it so the allocator won't
|
||||
# retype it".
|
||||
|
||||
# First sync places the wish's days as ordinary (unpinned,
|
||||
# wish-attributed) rows.
|
||||
await _sync(client, headers, ws_id)
|
||||
planned = await _list_planned_days(client, headers, child_id)
|
||||
wish_days = [d for d in planned if d["wish_id"] == wish_id]
|
||||
assert wish_days, "expected the wish to have been placed by the first sync"
|
||||
|
||||
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.
|
||||
new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation"
|
||||
|
||||
pin_resp = await client.put(
|
||||
f"/api/planned-days/{pinned_day['id']}",
|
||||
headers=headers,
|
||||
json={"day_type": new_day_type, "is_pinned": True},
|
||||
)
|
||||
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
|
||||
|
||||
# Re-sync: a fresh propose would want this date back at
|
||||
# `original_day_type` (the wish's natural funding choice) — the
|
||||
# pinned row must survive untouched instead.
|
||||
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"])
|
||||
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 "
|
||||
"retyped back by the allocator"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user