Files
F-r-ldraledigplannerare/backend/tests/unit/test_leave_wish_repository.py
T
jacobh2 782415122d 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
2026-09-21 07:20:14 +02:00

1937 lines
73 KiB
Python

"""Tests for the LeaveWish repository and service.
Demonstrates the testing pattern for entity-specific repos and services.
Aim for >=90% coverage on new code (ARCHITECTURE.md §10).
"""
from datetime import date, datetime
from typing import Any
from uuid import UUID, uuid4
import pytest
from app.entities.day_assertion import DayAssertion
from app.entities.leave_wish import LeaveWish
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.workspace import Workspace
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.leave_wish_service import LeaveWishService
from app.utils.errors import NotFoundError, ValidationError
from tests.conftest import SeededIds
# A stable series id for tests that only need "this row belongs to a series".
_SERIES_ID = UUID("11111111-2222-3333-4444-555555555555")
def _make(workspace_id=None, **overrides) -> LeaveWish:
"""Helper to create a test LeaveWish entity."""
base = dict(
id=uuid4(),
workspace_id=workspace_id or uuid4(),
child_id=None,
name="Sommar",
window_start_month=6,
window_start_day=15,
window_end_month=8,
window_end_day=15,
target_weeks=6,
target_days=None,
first_year=2027,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
base.update(overrides)
return LeaveWish(**base)
class TestLeaveWishWindowLogic:
"""Direct tests of LeaveWish's window helpers.
These are the rules the allocator depends on most — especially
year-wrapping, which is the easiest thing in this feature to get backwards.
"""
def test_window_wraps_false_when_end_after_start(self) -> None:
wish = _make(
window_start_month=6, window_start_day=15, window_end_month=8, window_end_day=15
)
assert wish.window_wraps() is False
def test_window_wraps_true_when_end_before_start(self) -> None:
# Jullov: Dec 20 -> Jan 7 (end sorts before start -> wraps)
wish = _make(
window_start_month=12, window_start_day=20, window_end_month=1, window_end_day=7
)
assert wish.window_wraps() is True
def test_window_wraps_false_when_start_equals_end(self) -> None:
wish = _make(window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=1)
assert wish.window_wraps() is False
def test_window_no_wrap_stays_in_same_year(self) -> None:
wish = _make(
first_year=2027,
window_start_month=6,
window_start_day=15,
window_end_month=8,
window_end_day=15,
)
start, end = wish.window()
assert start == date(2027, 6, 15)
assert end == date(2027, 8, 15)
def test_window_wraps_end_into_next_year(self) -> None:
wish = _make(
first_year=2027,
window_start_month=12,
window_start_day=20,
window_end_month=1,
window_end_day=7,
)
start, end = wish.window()
assert start == date(2027, 12, 20)
assert end == date(2028, 1, 7)
def test_window_reads_the_row_back_verbatim_with_no_projection(self) -> None:
"""Two rows of one series that diverged (e.g. a per-year Easter shift)
each report their OWN dates. The old shape re-derived every later year
from `first_year`, so this could not be expressed at all.
"""
series = uuid4()
a = _make(first_year=2027, series_id=series)
b = _make(
first_year=2028,
series_id=series,
window_start_month=7,
window_start_day=1,
window_end_month=8,
window_end_day=31,
)
assert a.window() == (date(2027, 6, 15), date(2027, 8, 15))
assert b.window() == (date(2028, 7, 1), date(2028, 8, 31))
def test_window_clamps_a_feb_29_anchor_in_a_non_leap_year(self) -> None:
"""A Feb-29 window in a non-leap year used to raise ValueError out of
the allocator; a whole-day planner has to answer Feb 28 instead.
"""
wish = _make(
first_year=2027,
window_start_month=2,
window_start_day=29,
window_end_month=3,
window_end_day=5,
)
assert wish.window() == (date(2027, 2, 28), date(2027, 3, 5))
# -- target_span_days() (calendar-drawn periods, day-granular) ---------
def test_target_span_days_falls_back_to_weeks_times_seven_when_unset(self) -> None:
wish = _make(target_weeks=6, target_days=None)
assert wish.target_span_days() == 42
def test_target_span_days_prefers_target_days_when_set(self) -> None:
# A calendar-drawn 10-day band, even though target_weeks still holds
# a stale/unrelated whole-week value.
wish = _make(target_weeks=6, target_days=10)
assert wish.target_span_days() == 10
class TestSQLiteLeaveWishRepository:
"""Test LeaveWishRepository SQLite implementation."""
async def test_create_and_get_by_id(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id)
created = await leave_wish_repo.create(entity)
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.id == created.id
assert fetched.name == "Sommar"
assert fetched.target_weeks == 6
assert fetched.series_id is None
assert fetched.parent_mode == "either"
assert fetched.child_id is None
assert fetched.parent_personal_data_uuid is None
async def test_get_by_id_missing(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
result = await leave_wish_repo.get_by_id(uuid4())
assert result is None
async def test_create_and_get_round_trip_name_message_key(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
assert created.name_message_key == "wishEditor.seasonJul"
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key == "wishEditor.seasonJul"
async def test_create_without_name_message_key_defaults_to_none(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Existing-row simulation: a wish created before this field existed
(or a free-typed custom name) has no key and must read back `None`,
not a synthesized or empty-string value.
"""
entity = _make(workspace_id=seeded_ids.workspace_id, name="Custom trip")
created = await leave_wish_repo.create(entity)
assert created.name_message_key is None
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key is None
async def test_update_round_trips_name_message_key(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, name="Jul", name_message_key=None)
created = await leave_wish_repo.create(entity)
created.name_message_key = "wishEditor.seasonJul"
updated = await leave_wish_repo.update(created.id, created)
assert updated.name_message_key == "wishEditor.seasonJul"
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.name_message_key == "wishEditor.seasonJul"
async def test_create_preserves_all_fields(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
name="Jullov",
window_start_month=12,
window_start_day=20,
window_end_month=1,
window_end_day=7,
target_weeks=4,
series_id=_SERIES_ID,
first_year=2027,
last_year=2031,
priority=2,
parent_mode="specific",
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
)
created = await leave_wish_repo.create(entity)
assert created.name == "Jullov"
assert created.child_id == seeded_ids.child_id
assert created.window_start_month == 12
assert created.window_start_day == 20
assert created.window_end_month == 1
assert created.window_end_day == 7
assert created.target_weeks == 4
assert created.series_id == _SERIES_ID
assert created.first_year == 2027
assert created.last_year == 2031
assert created.priority == 2
assert created.parent_mode == "specific"
assert created.parent_personal_data_uuid == seeded_ids.personal_data_uuid
# Round-trip through the DB, not just the in-memory object.
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.child_id == seeded_ids.child_id
assert fetched.last_year == 2031
assert fetched.series_id == _SERIES_ID
assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid
async def test_create_and_update_round_trip_target_days(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, target_weeks=6, target_days=10)
created = await leave_wish_repo.create(entity)
assert created.target_days == 10
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.target_days == 10
fetched.target_days = None
updated = await leave_wish_repo.update(fetched.id, fetched)
assert updated.target_days is None
refetched = await leave_wish_repo.get_by_id(created.id)
assert refetched is not None
assert refetched.target_days is None
async def test_create_without_target_days_defaults_to_none(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, target_days=None)
created = await leave_wish_repo.create(entity)
assert created.target_days is None
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.target_days is None
async def test_create_defaults_wants_dubbeldag_to_false(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id)
created = await leave_wish_repo.create(entity)
assert created.wants_dubbeldag is False
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.wants_dubbeldag is False
async def test_create_and_update_round_trip_wants_dubbeldag(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id, parent_mode="both", wants_dubbeldag=True
)
created = await leave_wish_repo.create(entity)
assert created.wants_dubbeldag is True
fetched = await leave_wish_repo.get_by_id(created.id)
assert fetched is not None
assert fetched.wants_dubbeldag is True
fetched.wants_dubbeldag = False
updated = await leave_wish_repo.update(fetched.id, fetched)
assert updated.wants_dubbeldag is False
refetched = await leave_wish_repo.get_by_id(created.id)
assert refetched is not None
assert refetched.wants_dubbeldag is False
async def test_get_by_workspace(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
ws1 = seeded_ids.workspace_id
ws2 = await workspace_repo.create(
Workspace(id=uuid4(), name="WS2", created_at=datetime.now())
)
w1 = await leave_wish_repo.create(_make(workspace_id=ws1, name="W1"))
w2 = await leave_wish_repo.create(_make(workspace_id=ws1, name="W2"))
w3 = await leave_wish_repo.create(_make(workspace_id=ws2.id, name="W3"))
rows = await leave_wish_repo.get_by_workspace(ws1)
assert len(rows) == 2
ids = {r.id for r in rows}
assert w1.id in ids
assert w2.id in ids
assert w3.id not in ids
async def test_get_by_workspace_empty(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
result = await leave_wish_repo.get_by_workspace(uuid4())
assert result == []
async def test_update(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, name="Sommar", target_weeks=6)
created = await leave_wish_repo.create(entity)
created.name = "Sommarlov"
created.target_weeks = 8
created.priority = 3
updated = await leave_wish_repo.update(created.id, created)
assert updated.name == "Sommarlov"
assert updated.target_weeks == 8
assert updated.priority == 3
assert updated.id == created.id
async def test_update_missing_raises(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
with pytest.raises(NotFoundError):
await leave_wish_repo.update(uuid4(), _make())
async def test_delete(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id)
created = await leave_wish_repo.create(entity)
await leave_wish_repo.delete(created.id)
result = await leave_wish_repo.get_by_id(created.id)
assert result is None
async def test_delete_missing_raises(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
) -> None:
with pytest.raises(NotFoundError):
await leave_wish_repo.delete(uuid4())
async def test_list(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
w1 = await leave_wish_repo.create(_make(workspace_id=ws, name="W1"))
w2 = await leave_wish_repo.create(_make(workspace_id=ws, name="W2"))
rows = await leave_wish_repo.list()
assert len(rows) >= 2
ids = {r.id for r in rows}
assert w1.id in ids
assert w2.id in ids
async def test_list_filters_by_child_id(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
tied = await leave_wish_repo.create(
_make(workspace_id=ws, child_id=seeded_ids.child_id, name="Tied")
)
family_wide = await leave_wish_repo.create(
_make(workspace_id=ws, child_id=None, name="FamilyWide")
)
rows = await leave_wish_repo.list(child_id=seeded_ids.child_id)
ids = {r.id for r in rows}
assert tied.id in ids
assert family_wide.id not in ids
async def test_list_rejects_unsupported_filter(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Passing an unsupported filter (like a typo) must raise ValueError, not return all rows."""
with pytest.raises(ValueError, match="parent_personal_data_uuid"):
await leave_wish_repo.list(parent_personal_data_uuid=seeded_ids.personal_data_uuid)
# Verify the error message lists what filters ARE supported
with pytest.raises(ValueError, match="child_id.*workspace_id"):
await leave_wish_repo.list(parent_personal_data_uuid=seeded_ids.personal_data_uuid)
async def test_list_with_supported_filter_still_works(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Verify filter validation doesn't break existing filtering behavior."""
ws = seeded_ids.workspace_id
created_1 = await leave_wish_repo.create(_make(workspace_id=ws, name="W1"))
created_2 = await leave_wish_repo.create(_make(workspace_id=ws, name="W2"))
rows = await leave_wish_repo.list(workspace_id=ws)
assert len(rows) >= 2
ids = {r.id for r in rows}
assert created_1.id in ids
assert created_2.id in ids
async def test_list_rejects_multiple_unsupported_filters(
self,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Error message should list all unsupported filters."""
with pytest.raises(ValueError, match="parent_personal_data_uuid.*typo_filter"):
await leave_wish_repo.list(
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
typo_filter="value",
)
class TestLeaveWishService:
"""Test LeaveWishService business logic (validation)."""
async def test_create_with_valid_fields(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Sommar",
window_start_month=6,
window_start_day=15,
window_end_month=8,
window_end_day=15,
target_weeks=6,
first_year=2027,
)
assert result.name == "Sommar"
assert result.target_weeks == 6
assert result.series_id is None # default: a standalone occurrence
assert result.parent_mode == "either" # default
assert result.wants_dubbeldag is False # default
@pytest.mark.parametrize("recurrence", ["once", "yearly"])
async def test_create_accepts_valid_recurrence(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
recurrence: str,
) -> None:
"""`recurrence` is an instruction now, not a stored field: 'yearly'
materialises a series (so the returned row carries a `series_id`),
'once' leaves a standalone occurrence.
"""
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
last_year=2029 if recurrence == "yearly" else None,
recurrence=recurrence,
)
assert (result.series_id is not None) is (recurrence == "yearly")
# Placeholder resolved to seeded_ids.personal_data_uuid at test time, since
# parametrize case values are collected before fixtures exist.
_MISSING_UUID = object()
_REJECTION_BASE_KWARGS: dict[str, object] = dict(
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
)
_REJECTION_CASES = [
pytest.param({"recurrence": "monthly"}, "recurrence", id="invalid_recurrence"),
pytest.param({"parent_mode": "everyone"}, "parent_mode", id="invalid_parent_mode"),
pytest.param(
{"parent_mode": "either", "parent_personal_data_uuid": _MISSING_UUID},
"parent_personal_data_uuid",
id="parent_uuid_forbidden_unless_specific",
),
pytest.param({"window_mode": "whenever"}, "window_mode", id="invalid_window_mode"),
pytest.param(
{
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 3,
"window_end_day": 10,
"target_weeks": 2,
"window_mode": "flexible",
},
"flexible",
id="flexible_bound_shorter_than_target_weeks",
),
*[
pytest.param(
{
"window_start_month": month,
"window_start_day": day,
"window_end_month": 6,
"window_end_day": 20,
},
None,
id=f"invalid_month_day_{month}_{day}",
)
for month, day in [(0, 15), (13, 15), (6, 0), (6, 32), (2, 30), (4, 31)]
],
pytest.param({"target_days": 0}, "target_days", id="target_days_below_one"),
pytest.param({"target_weeks": 0}, "target_weeks", id="zero_target_weeks"),
pytest.param(
{"first_year": 2030, "last_year": 2029},
"last_year",
id="last_year_before_first_year",
),
pytest.param(
{"parent_mode": "either", "wants_dubbeldag": True},
"wants_dubbeldag",
id="wants_dubbeldag_forbidden_unless_both",
),
]
@pytest.mark.parametrize("overrides,match", _REJECTION_CASES)
async def test_create_rejects_invalid_input(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
overrides: dict[str, object],
match: str | None,
) -> None:
kwargs = {**self._REJECTION_BASE_KWARGS, **overrides}
if kwargs.get("parent_personal_data_uuid") is self._MISSING_UUID:
kwargs["parent_personal_data_uuid"] = seeded_ids.personal_data_uuid
with pytest.raises(ValidationError, match=match):
await leave_wish_service.create(workspace_id=seeded_ids.workspace_id, **kwargs)
async def test_create_specific_parent_mode_requires_uuid(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
with pytest.raises(ValidationError, match="parent_personal_data_uuid"):
await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
parent_mode="specific",
parent_personal_data_uuid=None,
)
async def test_create_specific_parent_mode_with_uuid_succeeds(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
parent_mode="specific",
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
)
assert result.parent_mode == "specific"
assert result.parent_personal_data_uuid == seeded_ids.personal_data_uuid
# -- wants_dubbeldag (§Decision A, opt-in co-leave) ---------------------
async def test_create_both_parent_mode_can_opt_into_dubbeldagar(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
parent_mode="both",
wants_dubbeldag=True,
)
assert result.parent_mode == "both"
assert result.wants_dubbeldag is True
async def test_update_can_toggle_wants_dubbeldag(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
parent_mode="both",
)
assert created.wants_dubbeldag is False
updated = await leave_wish_service.update(leave_wish_id=created.id, wants_dubbeldag=True)
assert updated.wants_dubbeldag is True
# -- window_mode (§10.1) ------------------------------------------------
async def test_create_defaults_to_fixed_window_mode(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
)
assert result.window_mode == "fixed"
async def test_create_accepts_flexible_window_mode_with_wide_enough_bound(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
# Bound = 2027-03-01 -> 2027-05-31 (92 days), target_weeks=1 (7 days).
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Vårlov",
window_start_month=3,
window_start_day=1,
window_end_month=5,
window_end_day=31,
target_weeks=1,
first_year=2027,
window_mode="flexible",
)
assert result.window_mode == "flexible"
async def test_update_can_switch_window_mode_to_flexible(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
window_start_month=3,
window_start_day=1,
window_end_month=5,
window_end_day=31,
target_weeks=1,
)
created = await leave_wish_service._repo.create(entity)
assert created.window_mode == "fixed"
updated = await leave_wish_service.update(created.id, window_mode="flexible")
assert updated.window_mode == "flexible"
async def test_update_to_flexible_still_validates_bound_length(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
window_start_month=3,
window_start_day=1,
window_end_month=3,
window_end_day=10, # 10-day bound
target_weeks=2, # needs 14 days
)
created = await leave_wish_service._repo.create(entity)
with pytest.raises(ValidationError, match="flexible"):
await leave_wish_service.update(created.id, window_mode="flexible")
async def test_create_accepts_leap_day(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=2,
window_start_day=29,
window_end_month=3,
window_end_day=1,
target_weeks=1,
first_year=2027,
)
assert result.window_start_day == 29
# -- target_days (calendar-drawn periods, day-granular) ----------------
async def test_create_with_target_days_persists_it(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=31,
target_weeks=1,
target_days=10,
first_year=2027,
)
assert result.target_days == 10
assert result.target_span_days() == 10
async def test_create_without_target_days_defaults_to_none(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=2,
first_year=2027,
)
assert result.target_days is None
assert result.target_span_days() == 14
async def test_create_flexible_wish_sized_by_target_days_rejects_short_bound(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
# Bound = 2027-03-01 -> 2027-03-10 (10 days); target_days=11 doesn't
# fit even though target_weeks=1 (7 days) would. target_days must be
# the value actually checked, not silently ignored in favor of weeks.
with pytest.raises(ValidationError, match="flexible"):
await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Vårlov",
window_start_month=3,
window_start_day=1,
window_end_month=3,
window_end_day=10,
target_weeks=1,
target_days=11,
first_year=2027,
window_mode="flexible",
)
async def test_create_flexible_wish_sized_by_target_days_accepts_matching_bound(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
# Same 10-day bound, target_days=10 fits exactly.
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Vårlov",
window_start_month=3,
window_start_day=1,
window_end_month=3,
window_end_day=10,
target_weeks=1,
target_days=10,
first_year=2027,
window_mode="flexible",
)
assert result.target_days == 10
async def test_update_can_set_target_days(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, target_days=None)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, target_days=10)
assert result.target_days == 10
async def test_update_can_clear_target_days(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, target_days=10)
created = await leave_wish_repo.create(entity)
assert created.target_days == 10
result = await leave_wish_service.update(leave_wish_id=created.id, _clear_target_days=True)
assert result.target_days is None
async def test_create_allows_last_year_equal_first_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""A one-year series is legal and produces exactly one row.
(`recurrence='yearly'` is required for `last_year` to survive at all
now — it is the series' end-year intent, and a standalone occurrence
has no series to end. See `LeaveWishService._validate`.)
"""
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
recurrence="yearly",
first_year=2027,
last_year=2027,
)
assert result.last_year == 2027
assert result.series_id is not None
assert len(await leave_wish_repo.get_by_series(result.series_id)) == 1
async def test_create_allows_null_last_year(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Wish",
window_start_month=1,
window_start_day=1,
window_end_month=1,
window_end_day=10,
target_weeks=1,
first_year=2027,
last_year=None,
)
assert result.last_year is None
async def test_get(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id)
created = await leave_wish_repo.create(entity)
fetched = await leave_wish_service.get(created.id)
assert fetched.id == created.id
assert fetched.name == "Sommar"
async def test_get_missing_raises(
self,
leave_wish_service: LeaveWishService,
) -> None:
with pytest.raises(NotFoundError):
await leave_wish_service.get(uuid4())
async def test_list_for_workspace(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws = seeded_ids.workspace_id
w1 = await leave_wish_repo.create(_make(workspace_id=ws, name="W1"))
w2 = await leave_wish_repo.create(_make(workspace_id=ws, name="W2"))
result = await leave_wish_service.list_for_workspace(ws)
assert len(result) == 2
ids = {r.id for r in result}
assert w1.id in ids
assert w2.id in ids
async def test_update_partial_only_touches_given_fields(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, name="Sommar", target_weeks=6)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id,
name="Sommarlov",
)
assert result.name == "Sommarlov"
assert result.target_weeks == 6 # untouched
assert result.window_start_month == 6 # untouched
async def test_update_can_set_all_optional_fields(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Every non-clear-flag optional field, set to a real value in one call."""
entity = _make(workspace_id=seeded_ids.workspace_id, series_id=None, last_year=None)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id,
child_id=seeded_ids.child_id,
window_start_month=3,
window_start_day=10,
window_end_month=4,
window_end_day=20,
target_weeks=9,
recurrence="yearly",
first_year=2028,
last_year=2030,
priority=5,
parent_mode="specific",
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
)
assert result.child_id == seeded_ids.child_id
assert result.window_start_month == 3
assert result.window_start_day == 10
assert result.window_end_month == 4
assert result.window_end_day == 20
assert result.target_weeks == 9
assert result.series_id is not None # 'yearly' materialised a series
assert result.first_year == 2028
assert result.last_year == 2030
assert result.priority == 5
assert result.parent_mode == "specific"
assert result.parent_personal_data_uuid == seeded_ids.personal_data_uuid
async def test_update_rejects_invalid_values(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id)
created = await leave_wish_repo.create(entity)
with pytest.raises(ValidationError):
await leave_wish_service.update(leave_wish_id=created.id, target_weeks=0)
async def test_update_can_clear_last_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Clearing a series' end year re-opens it to the child's 4th birthday."""
entity = _make(
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
series_id=_SERIES_ID,
first_year=2027,
last_year=2030,
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id,
scope="series",
_clear_last_year=True,
)
assert result.last_year is None
async def test_update_can_clear_child_id(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id,
_clear_child_id=True,
)
assert result.child_id is None
async def test_update_can_clear_parent_personal_data_uuid(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
parent_mode="specific",
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id,
parent_mode="either",
_clear_parent_personal_data_uuid=True,
)
assert result.parent_mode == "either"
assert result.parent_personal_data_uuid is None
# -- name_message_key (fixes the "Jul"/"Höstlov" always-Swedish bug) ---
async def test_create_passes_through_name_message_key(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
result = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
window_start_month=12,
window_start_day=1,
window_end_month=1,
window_end_day=31,
target_weeks=4,
first_year=2027,
)
assert result.name_message_key == "wishEditor.seasonJul"
async def test_update_without_changing_name_preserves_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, target_weeks=5)
assert result.name_message_key == "wishEditor.seasonJul"
assert result.name == "Jul"
async def test_update_that_resends_the_same_name_preserves_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The auto-save flow resends the unchanged `name` on every edit —
only an *actual* change to the text may clear the key.
"""
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, name="Jul")
assert result.name_message_key == "wishEditor.seasonJul"
async def test_update_renaming_clears_name_message_key_even_without_being_told_to(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Backend enforcement, not caller-trust: renaming clears the key
even if the caller never mentions `name_message_key` at all — this
is the critical guard against a user's custom name silently
reverting to a stale preset label.
"""
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(leave_wish_id=created.id, name="Vinterresa")
assert result.name == "Vinterresa"
assert result.name_message_key is None
async def test_update_can_explicitly_clear_name_message_key(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(
workspace_id=seeded_ids.workspace_id,
name="Jul",
name_message_key="wishEditor.seasonJul",
)
created = await leave_wish_repo.create(entity)
result = await leave_wish_service.update(
leave_wish_id=created.id, _clear_name_message_key=True
)
assert result.name_message_key is None
assert result.name == "Jul" # unrelated to the clear
async def test_update_missing_raises(
self,
leave_wish_service: LeaveWishService,
) -> None:
with pytest.raises(NotFoundError):
await leave_wish_service.update(leave_wish_id=uuid4(), name="Name")
async def test_delete(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id)
created = await leave_wish_repo.create(entity)
await leave_wish_service.delete(created.id)
with pytest.raises(NotFoundError):
await leave_wish_service.get(created.id)
async def test_delete_missing_raises(
self,
leave_wish_service: LeaveWishService,
) -> None:
with pytest.raises(NotFoundError):
await leave_wish_service.delete(uuid4())
class TestLeaveWishServiceDeleteCascade:
"""`LeaveWishService.delete()` must remove the `planned_leave_day` rows
the wish produced, not just null their `wish_id` out from under them
(the schema's `ON DELETE SET NULL` would otherwise leave them behind as
permanent, unattributable ad-hoc rows — see the docstring on
`LeaveWishService.delete()` and `PlannedLeaveRepository.delete_by_wish`).
"""
def _make_day(
self,
seeded_ids: SeededIds,
*,
wish_id: UUID | None = None,
day: str = "2027-02-03",
) -> PlannedLeaveDay:
return PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date.fromisoformat(day),
day_type="sjukpenning",
is_dubbeldag=False,
wish_id=wish_id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
async def test_delete_removes_the_planned_leave_days_it_produced(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id))
day1 = await planned_leave_repo.create(
self._make_day(seeded_ids, wish_id=wish.id, day="2027-02-03")
)
day2 = await planned_leave_repo.create(
self._make_day(seeded_ids, wish_id=wish.id, day="2027-02-04")
)
await leave_wish_service.delete(wish.id)
assert await planned_leave_repo.get_by_id(day1.id) is None
assert await planned_leave_repo.get_by_id(day2.id) is None
async def test_delete_removes_a_pinned_day_too(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
seeded_ids: SeededIds,
) -> None:
"""A pinned day dies with its wish rather than surviving as ad-hoc —
and so does its `DayAssertion` (restructure step 3b). Pinning only
protects a day from being silently retyped/reassigned by a later
`sync_plan` re-plan — it is not a signal that the day should outlive
a period the user explicitly chose to delete. This also matches the
ad-hoc-period delete path in `Calendar.tsx` (`handleDeletePeriod`'s
no-`wishId` branch), which already deletes every day in the band
regardless of pin state.
`LeaveWishRepository.delete_by_wish` itself never looked at
`is_pinned` (it deleted by `wish_id` alone even before that column
existed) — the row-level cascade was never in doubt. What step 3b
adds is `LeaveWishService._clear_assertions_for`, which is what this
test's `day_assertion_repo` assertion actually exercises: without
it, this assertion would survive with no row left to attach to, see
`TestWishDeleteClearsAssertions` in
test_services/test_allocation_day_assertion.py for the full
stray-slot scenario this prevents.
"""
wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id))
pinned = await planned_leave_repo.create(self._make_day(seeded_ids, wish_id=wish.id))
await day_assertion_repo.create(
DayAssertion(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=pinned.date,
day_type=pinned.day_type,
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await leave_wish_service.delete(wish.id)
assert await planned_leave_repo.get_by_id(pinned.id) is None
assert (
await day_assertion_repo.get_by_natural_key(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
pinned.date,
)
is None
)
async def test_delete_leaves_a_different_wishs_days_untouched(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
wish_a = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id, name="A"))
wish_b = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id, name="B"))
day_a = await planned_leave_repo.create(
self._make_day(seeded_ids, wish_id=wish_a.id, day="2027-02-03")
)
day_b = await planned_leave_repo.create(
self._make_day(seeded_ids, wish_id=wish_b.id, day="2027-02-04")
)
await leave_wish_service.delete(wish_a.id)
assert await planned_leave_repo.get_by_id(day_a.id) is None
survivor = await planned_leave_repo.get_by_id(day_b.id)
assert survivor is not None
assert survivor.wish_id == wish_b.id
async def test_delete_leaves_genuine_ad_hoc_days_untouched(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id))
ad_hoc = await planned_leave_repo.create(
self._make_day(seeded_ids, wish_id=None, day="2027-02-03")
)
await leave_wish_service.delete(wish.id)
survivor = await planned_leave_repo.get_by_id(ad_hoc.id)
assert survivor is not None
assert survivor.wish_id is None
class TestLeaveWishServiceAtomicity:
"""Bug 3 (adversarial-review-adjacent) — `LeaveWishService` previously had
NO unit of work at all, so `delete()`'s multi-row loop (clear assertions
-> delete `planned_leave_day` rows -> delete the `LeaveWish` row, once
per targeted occurrence) committed each statement immediately. A crash
partway through a `scope='series'` delete left some years fully deleted
and others untouched. `leave_wish_service_with_uow` wires a real
`SQLiteUnitOfWork`, matching `PlanningService`'s established pattern —
this proves a failure mid-loop now rolls the WHOLE delete back.
"""
async def test_failure_mid_series_delete_leaves_every_row_untouched(
self,
leave_wish_service_with_uow: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
monkeypatch: pytest.MonkeyPatch,
) -> None:
series_id = uuid4()
year1 = await leave_wish_repo.create(
_make(workspace_id=seeded_ids.workspace_id, first_year=2027, series_id=series_id)
)
year2 = await leave_wish_repo.create(
_make(workspace_id=seeded_ids.workspace_id, first_year=2028, series_id=series_id)
)
day1 = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2027, 6, 15),
day_type="sjukpenning",
wish_id=year1.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
real_delete = leave_wish_repo.delete
call_count = 0
async def flaky_delete(wish_id: UUID) -> None:
nonlocal call_count
call_count += 1
if call_count == 2:
raise RuntimeError("simulated crash mid-series-delete")
await real_delete(wish_id)
monkeypatch.setattr(leave_wish_repo, "delete", flaky_delete)
with pytest.raises(RuntimeError):
await leave_wish_service_with_uow.delete(year1.id, scope="series")
# Neither year's LeaveWish row nor its planned_leave_day row was
# actually removed — the first successful delete was rolled back
# along with the second (failed) one.
assert await leave_wish_repo.get_by_id(year1.id) is not None
assert await leave_wish_repo.get_by_id(year2.id) is not None
assert await planned_leave_repo.get_by_id(day1.id) is not None
class TestLeaveWishSeriesMaterialisation:
"""A repeating wish is stored as one row per year, sharing a `series_id`.
This is the shape that makes "edit/remove just this year" possible at all:
before it, a later year had no row to address, so the frontend had to
rewrite the shared month/day pattern for every year (edit) or narrow
`first_year`/`last_year` — and split the wish in two for a middle year
(remove). Both contortions are gone.
"""
_BASE: dict[str, Any] = dict(
name="Sommar",
window_start_month=10,
window_start_day=5, # 2026-10-05 is a Monday
window_end_month=10,
window_end_day=11, # ...10-11 the Sunday after
target_weeks=1,
)
async def _series(
self,
service: LeaveWishService,
repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
**overrides: Any,
) -> tuple[LeaveWish, list[LeaveWish]]:
kwargs: dict[str, Any] = {
**self._BASE,
"recurrence": "yearly",
"first_year": 2026,
"last_year": 2029,
**overrides,
}
anchor = await service.create(workspace_id=seeded_ids.workspace_id, **kwargs)
assert anchor.series_id is not None
return anchor, await repo.get_by_series(anchor.series_id)
async def test_create_yearly_writes_one_row_per_year_sharing_a_series_id(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
assert {r.series_id for r in rows} == {anchor.series_id}
assert anchor.first_year == 2026 # create() returns the first occurrence
# Every row is addressable on its own — distinct ids, one per year.
assert len({r.id for r in rows}) == 4
async def test_create_once_writes_a_single_standalone_row(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
assert created.series_id is None
assert created.last_year is None
assert len(await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)) == 1
async def test_the_weekday_snap_lands_in_the_stored_rows(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""Mon-Sun stays Mon-Sun, and the shifted dates are the row's own
columns — nothing re-derives a later year from the first one.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
(10, 5),
(10, 4),
(10, 2),
(10, 8),
]
for row in rows:
start, end = row.window()
assert start.weekday() == 0, row.first_year
assert end.weekday() == 6, row.first_year
assert (end - start).days == 6
async def test_flexible_series_keeps_its_bounding_ranges_weekday_aligned(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""A flexible wish's fields are a bounding range, not a placement, but
the range must still keep its weekday alignment year over year (the
reported bug: a week-shaped bound silently drifting onto a different
weekday every non-anchor year) — same snap `window_mode == 'fixed'`
gets, with the bound's day-*span* held fixed so the search space
`resolve_flexible_window` explores never shrinks.
"""
_, rows = await self._series(
leave_wish_service,
leave_wish_repo,
seeded_ids,
window_start_month=3,
window_start_day=1, # 2026-03-01 is a Sunday
window_end_month=5,
window_end_day=31,
window_mode="flexible",
)
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
(3, 1),
(2, 28),
(2, 27),
(3, 4),
]
for row in rows:
start, end = row.window()
assert start.weekday() == 6, row.first_year # Sunday, like the anchor
assert (end - start).days == 91, row.first_year # span held fixed
async def test_open_ended_series_stops_at_the_childs_fourth_birthday_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""`last_year=None` means "until the child turns 4" — the app's whole
premise. The seeded child is born 2024-01-01, so 2028 is the last year.
"""
_, rows = await self._series(
leave_wish_service,
leave_wish_repo,
seeded_ids,
child_id=seeded_ids.child_id,
first_year=2026,
last_year=None,
)
assert [r.first_year for r in rows] == [2026, 2027, 2028]
async def test_editing_one_year_leaves_its_siblings_alone(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The reason this shape exists: 2028's Easter is not 2027's, so one
year must be able to move without dragging the others with it.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
before = {r.first_year: r.window() for r in rows}
target = next(r for r in rows if r.first_year == 2028)
updated = await leave_wish_service.update(
leave_wish_id=target.id,
window_start_month=11,
window_start_day=6,
window_end_month=11,
window_end_day=12,
scope="occurrence",
)
assert updated.window() == (date(2028, 11, 6), date(2028, 11, 12))
assert updated.series_id == target.series_id # still part of the series
after = {
r.first_year: r.window()
for r in await leave_wish_repo.get_by_series(target.series_id)
if r.first_year != 2028
}
assert after == {y: w for y, w in before.items() if y != 2028}
async def test_editing_the_series_reprojects_every_year_from_the_edited_row(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
""" "Every year" makes the clicked occurrence the new anchor; the other
years follow it with the same weekday-preserving projection the old
single-row shape ran at read time.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
target = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.update(
leave_wish_id=target.id,
name="Höstlov",
window_start_month=11,
window_start_day=6, # 2028-11-06 is a Monday
window_end_month=11,
window_end_day=12,
scope="series",
)
assert target.series_id is not None
after = await leave_wish_repo.get_by_series(target.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029]
assert {r.name for r in after} == {"Höstlov"}
for row in after:
start, end = row.window()
assert start.weekday() == 0, row.first_year
assert end.weekday() == 6, row.first_year
assert start.month == 11
assert (end - start).days == 6
async def test_extending_last_year_adds_the_missing_rows(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert len(rows) == 4
await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2031, scope="series")
assert anchor.series_id is not None
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029, 2030, 2031]
assert {r.last_year for r in after} == {2031}
async def test_shortening_last_year_deletes_the_rows_past_it(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
dropped = next(r for r in rows if r.first_year == 2029)
orphan_candidate = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=date(2029, 10, 9),
day_type="sjukpenning",
is_dubbeldag=False,
wish_id=dropped.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2027, scope="series")
assert anchor.series_id is not None
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027]
# The dropped years take their planned days with them, exactly like
# deleting a period does.
assert await planned_leave_repo.get_by_id(orphan_candidate.id) is None
async def test_changing_last_year_on_one_occurrence_is_rejected(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""`last_year` belongs to the series, not to one year of it — silently
desyncing the mirror would be worse than a 422.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
target = next(r for r in rows if r.first_year == 2028)
with pytest.raises(ValidationError, match="series"):
await leave_wish_service.update(
leave_wish_id=target.id, last_year=2031, scope="occurrence"
)
async def test_making_a_standalone_wish_repeat_materialises_around_it(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
assert created.series_id is None
updated = await leave_wish_service.update(
leave_wish_id=created.id, recurrence="yearly", last_year=2028
)
assert updated.series_id is not None
rows = await leave_wish_repo.get_by_series(updated.series_id)
assert [r.first_year for r in rows] == [2026, 2027, 2028]
assert rows[0].id == created.id # the original row anchors the series
async def test_stopping_a_series_keeps_the_clicked_year_and_drops_the_rest(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
keeper = next(r for r in rows if r.first_year == 2028)
updated = await leave_wish_service.update(
leave_wish_id=keeper.id, recurrence="once", scope="series"
)
assert updated.series_id is None
assert updated.last_year is None
remaining = await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)
assert [r.id for r in remaining] == [keeper.id]
async def test_delete_occurrence_removes_only_that_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""What used to require narrowing the range — or splitting the wish in
two around a middle year — is now one plain row delete.
"""
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
middle = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.delete(middle.id, scope="occurrence")
assert anchor.series_id is not None
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2029]
async def test_delete_series_removes_every_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
middle = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.delete(middle.id, scope="series")
assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == []
async def test_delete_series_clears_every_occurrences_assertion_not_just_the_clicked_year(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
seeded_ids: SeededIds,
) -> None:
"""`scope='series'` deletes every occurrence's rows (restructure
step 3b: and their assertions), not just the clicked year's — the
cleanup must not accidentally narrow to a single occurrence the way
the pre-materialisation single-row shape used to force.
"""
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
middle = next(r for r in rows if r.first_year == 2028)
planned_by_year: dict[int, PlannedLeaveDay] = {}
for occurrence in rows:
day = date(
occurrence.first_year, occurrence.window_start_month, occurrence.window_start_day
)
planned = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=day,
day_type="vacation",
wish_id=occurrence.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
planned_by_year[occurrence.first_year] = planned
await day_assertion_repo.create(
DayAssertion(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=day,
day_type="vacation",
wish_id=occurrence.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await leave_wish_service.delete(middle.id, scope="series")
for occurrence in rows:
planned = planned_by_year[occurrence.first_year]
assert await planned_leave_repo.get_by_id(planned.id) is None
assert (
await day_assertion_repo.get_by_natural_key(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
planned.date,
)
is None
), f"assertion for {occurrence.first_year} survived a series-scoped delete"
async def test_delete_series_on_a_standalone_wish_removes_just_it(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
""" "This year" and "every year" coincide on a one-off occurrence."""
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
await leave_wish_service.delete(created.id, scope="series")
assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == []
async def test_an_unknown_scope_is_rejected(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
created = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
)
with pytest.raises(ValidationError, match="scope"):
await leave_wish_service.update(leave_wish_id=created.id, scope="everything")
with pytest.raises(ValidationError, match="scope"):
await leave_wish_service.delete(created.id, scope="everything")
async def test_a_removed_year_can_be_put_back_into_its_series(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""The calendar's undo of "remove this year" must restore the year as
part of the repeating wish, not as a detached one-off a later "every
year" edit would miss.
"""
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
assert anchor.series_id is not None
removed = next(r for r in rows if r.first_year == 2028)
await leave_wish_service.delete(removed.id, scope="occurrence")
restored = await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
first_year=removed.first_year,
window_start_month=removed.window_start_month,
window_start_day=removed.window_start_day,
window_end_month=removed.window_end_month,
window_end_day=removed.window_end_day,
target_weeks=removed.target_weeks,
name=removed.name,
series_id=anchor.series_id,
)
assert restored.series_id == anchor.series_id
assert restored.last_year == anchor.last_year # the mirror stays consistent
after = await leave_wish_repo.get_by_series(anchor.series_id)
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029]
async def test_an_unknown_series_id_on_create_is_rejected(
self,
leave_wish_service: LeaveWishService,
seeded_ids: SeededIds,
) -> None:
with pytest.raises(ValidationError, match="series_id"):
await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
first_year=2026,
series_id=uuid4(),
**self._BASE,
)
async def test_series_id_cannot_be_combined_with_recurrence_yearly(
self,
leave_wish_service: LeaveWishService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
anchor, _ = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
with pytest.raises(ValidationError, match="series_id"):
await leave_wish_service.create(
workspace_id=seeded_ids.workspace_id,
first_year=2035,
recurrence="yearly",
last_year=2036,
series_id=anchor.series_id,
**self._BASE,
)