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
This commit is contained in:
2026-09-19 15:53:25 +02:00
parent c6593062e3
commit 6d92b87bf3
43 changed files with 2735 additions and 638 deletions
+502 -34
View File
@@ -5,6 +5,7 @@ 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
@@ -20,6 +21,10 @@ 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(
@@ -33,7 +38,6 @@ def _make(workspace_id=None, **overrides) -> LeaveWish:
window_end_day=15,
target_weeks=6,
target_days=None,
recurrence="yearly",
first_year=2027,
last_year=None,
priority=1,
@@ -47,9 +51,9 @@ def _make(workspace_id=None, **overrides) -> LeaveWish:
class TestLeaveWishWindowLogic:
"""Direct tests of LeaveWish's recurring-window helpers.
"""Direct tests of LeaveWish's window helpers.
These are the rules the allocator (Phase B) depends on most — especially
These are the rules the allocator depends on most — especially
year-wrapping, which is the easiest thing in this feature to get backwards.
"""
@@ -70,40 +74,60 @@ class TestLeaveWishWindowLogic:
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_for_year_no_wrap_stays_in_same_year(self) -> None:
def test_window_no_wrap_stays_in_same_year(self) -> None:
wish = _make(
window_start_month=6, window_start_day=15, window_end_month=8, window_end_day=15
first_year=2027,
window_start_month=6,
window_start_day=15,
window_end_month=8,
window_end_day=15,
)
start, end = wish.window_for_year(2027)
start, end = wish.window()
assert start == date(2027, 6, 15)
assert end == date(2027, 8, 15)
def test_window_for_year_wraps_end_into_next_year(self) -> None:
def test_window_wraps_end_into_next_year(self) -> None:
wish = _make(
window_start_month=12, window_start_day=20, window_end_month=1, window_end_day=7
first_year=2027,
window_start_month=12,
window_start_day=20,
window_end_month=1,
window_end_day=7,
)
start, end = wish.window_for_year(2027)
start, end = wish.window()
assert start == date(2027, 12, 20)
assert end == date(2028, 1, 7)
def test_applies_to_year_before_first_year_is_false(self) -> None:
wish = _make(recurrence="yearly", first_year=2027, last_year=None)
assert wish.applies_to_year(2026) is False
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_applies_to_year_once_matches_only_first_year(self) -> None:
wish = _make(recurrence="once", first_year=2027, last_year=2027)
assert wish.applies_to_year(2027) is True
assert wish.applies_to_year(2028) is False
def test_applies_to_year_yearly_with_no_last_year_recurs_indefinitely(self) -> None:
wish = _make(recurrence="yearly", first_year=2027, last_year=None)
assert wish.applies_to_year(2027) is True
assert wish.applies_to_year(2050) is True
def test_applies_to_year_yearly_stops_after_last_year(self) -> None:
wish = _make(recurrence="yearly", first_year=2027, last_year=2029)
assert wish.applies_to_year(2029) is True
assert wish.applies_to_year(2030) is False
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) ---------
@@ -134,7 +158,7 @@ class TestSQLiteLeaveWishRepository:
assert fetched.id == created.id
assert fetched.name == "Sommar"
assert fetched.target_weeks == 6
assert fetched.recurrence == "yearly"
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
@@ -211,7 +235,7 @@ class TestSQLiteLeaveWishRepository:
window_end_month=1,
window_end_day=7,
target_weeks=4,
recurrence="yearly",
series_id=_SERIES_ID,
first_year=2027,
last_year=2031,
priority=2,
@@ -227,7 +251,7 @@ class TestSQLiteLeaveWishRepository:
assert created.window_end_month == 1
assert created.window_end_day == 7
assert created.target_weeks == 4
assert created.recurrence == "yearly"
assert created.series_id == _SERIES_ID
assert created.first_year == 2027
assert created.last_year == 2031
assert created.priority == 2
@@ -239,6 +263,7 @@ class TestSQLiteLeaveWishRepository:
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(
@@ -480,7 +505,7 @@ class TestLeaveWishService:
)
assert result.name == "Sommar"
assert result.target_weeks == 6
assert result.recurrence == "once" # default
assert result.series_id is None # default: a standalone occurrence
assert result.parent_mode == "either" # default
assert result.wants_dubbeldag is False # default
@@ -491,6 +516,10 @@ class TestLeaveWishService:
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",
@@ -500,9 +529,10 @@ class TestLeaveWishService:
window_end_day=10,
target_weeks=1,
first_year=2027,
last_year=2029 if recurrence == "yearly" else None,
recurrence=recurrence,
)
assert result.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.
@@ -864,8 +894,15 @@ class TestLeaveWishService:
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",
@@ -874,10 +911,13 @@ class TestLeaveWishService:
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,
@@ -957,7 +997,7 @@ class TestLeaveWishService:
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, recurrence="once", last_year=None)
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(
@@ -982,7 +1022,7 @@ class TestLeaveWishService:
assert result.window_end_month == 4
assert result.window_end_day == 20
assert result.target_weeks == 9
assert result.recurrence == "yearly"
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
@@ -1007,11 +1047,19 @@ class TestLeaveWishService:
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(workspace_id=seeded_ids.workspace_id, last_year=2030)
"""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
@@ -1296,3 +1344,423 @@ class TestLeaveWishServiceDeleteCascade:
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,
)