"""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, )