ad0e3c0b33
A yearly-repeating period kept the same calendar date instead of the same
weekday: 2026-10-05..10-11 (Mon-Sun) recurred as 2027-10-05..10-11 (Tue-Mon)
rather than 2027-10-04..10-10. The snap added in 224fe7a only ever covered
window_mode == 'fixed'; 'flexible' wishes were unaligned at both stages.
project_window now snaps both modes. A flexible bound is still a bounding
range, but it keeps its weekday alignment year over year with its span held
fixed, so the search space never shrinks and a bound that is exactly as long
as the target (its only candidate) repeats week-shaped.
_rank_candidate gains a Monday-start term between cost and start, so equal-cost
candidates break on week alignment instead of "earliest". It sits after cost
deliberately: a cheaper non-Monday start still wins, because red days outrank
alignment.
_reuse_already_funded_window would otherwise freeze a pre-existing misaligned
placement forever, so it now releases a year whose reuse winner is misaligned
when a fresh search ranks strictly better -- never when one of its days is
pinned or excluded, so a deliberate per-day override always survives.
Migration 008 realigns flexible series already materialised under the old rule;
projection runs once, at create/update, so nothing repairs them on read.
Alongside this, the planner now says when it moved a period to catch red days.
WindowOutcome carries shift_gain_days (the saving over the naive bound-start
placement) and the calendar toasts it, once, on the sync that decided it.
A fixed window is never auto-moved -- its "exact requested placement,
unconditionally" guarantee stands -- but its shift suggestion is no longer
hidden behind a shortfall, so a fully-funded one can surface a cheaper week too.
Editing a period in the popover now also sends window_mode: 'fixed' with a
matching target. Preset chips create flexible wishes; dragging one down to an
exact week left it flexible with a week-long bound, which is the degenerate
single-candidate case that produced the reported dates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
1777 lines
66 KiB
Python
1777 lines
66 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.leave_wish import LeaveWish
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.entities.workspace import Workspace
|
|
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,
|
|
is_pinned: bool = False,
|
|
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,
|
|
is_pinned=is_pinned,
|
|
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,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A pinned day dies with its wish rather than surviving as ad-hoc.
|
|
|
|
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 `is_pinned`.
|
|
"""
|
|
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, is_pinned=True)
|
|
)
|
|
|
|
await leave_wish_service.delete(wish.id)
|
|
|
|
assert await planned_leave_repo.get_by_id(pinned.id) 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 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,
|
|
is_pinned=False,
|
|
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_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,
|
|
)
|