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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user