Files
F-r-ldraledigplannerare/backend/tests/unit/test_leave_wish_repository.py
T
jacobh2 6d92b87bf3 Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:53:25 +02:00

1767 lines
65 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_one_bounding_range_per_year_unshifted(
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, so
materialising gives each year the SAME month/day range — the per-year
placement search inside it stays the allocator's job.
"""
_, rows = await self._series(
leave_wish_service,
leave_wish_repo,
seeded_ids,
window_start_month=3,
window_start_day=1,
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, r.window_end_month, r.window_end_day)
for r in rows
} == {(3, 1, 5, 31)}
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,
)