782415122d
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
1937 lines
73 KiB
Python
1937 lines
73 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.day_assertion import DayAssertion
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.entities.workspace import Workspace
|
|
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
|
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
|
|
from app.services.leave_wish_service import LeaveWishService
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
# A stable series id for tests that only need "this row belongs to a series".
|
|
_SERIES_ID = UUID("11111111-2222-3333-4444-555555555555")
|
|
|
|
|
|
def _make(workspace_id=None, **overrides) -> LeaveWish:
|
|
"""Helper to create a test LeaveWish entity."""
|
|
base = dict(
|
|
id=uuid4(),
|
|
workspace_id=workspace_id or uuid4(),
|
|
child_id=None,
|
|
name="Sommar",
|
|
window_start_month=6,
|
|
window_start_day=15,
|
|
window_end_month=8,
|
|
window_end_day=15,
|
|
target_weeks=6,
|
|
target_days=None,
|
|
first_year=2027,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
base.update(overrides)
|
|
return LeaveWish(**base)
|
|
|
|
|
|
class TestLeaveWishWindowLogic:
|
|
"""Direct tests of LeaveWish's window helpers.
|
|
|
|
These are the rules the allocator depends on most — especially
|
|
year-wrapping, which is the easiest thing in this feature to get backwards.
|
|
"""
|
|
|
|
def test_window_wraps_false_when_end_after_start(self) -> None:
|
|
wish = _make(
|
|
window_start_month=6, window_start_day=15, window_end_month=8, window_end_day=15
|
|
)
|
|
assert wish.window_wraps() is False
|
|
|
|
def test_window_wraps_true_when_end_before_start(self) -> None:
|
|
# Jullov: Dec 20 -> Jan 7 (end sorts before start -> wraps)
|
|
wish = _make(
|
|
window_start_month=12, window_start_day=20, window_end_month=1, window_end_day=7
|
|
)
|
|
assert wish.window_wraps() is True
|
|
|
|
def test_window_wraps_false_when_start_equals_end(self) -> None:
|
|
wish = _make(window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=1)
|
|
assert wish.window_wraps() is False
|
|
|
|
def test_window_no_wrap_stays_in_same_year(self) -> None:
|
|
wish = _make(
|
|
first_year=2027,
|
|
window_start_month=6,
|
|
window_start_day=15,
|
|
window_end_month=8,
|
|
window_end_day=15,
|
|
)
|
|
start, end = wish.window()
|
|
assert start == date(2027, 6, 15)
|
|
assert end == date(2027, 8, 15)
|
|
|
|
def test_window_wraps_end_into_next_year(self) -> None:
|
|
wish = _make(
|
|
first_year=2027,
|
|
window_start_month=12,
|
|
window_start_day=20,
|
|
window_end_month=1,
|
|
window_end_day=7,
|
|
)
|
|
start, end = wish.window()
|
|
assert start == date(2027, 12, 20)
|
|
assert end == date(2028, 1, 7)
|
|
|
|
def test_window_reads_the_row_back_verbatim_with_no_projection(self) -> None:
|
|
"""Two rows of one series that diverged (e.g. a per-year Easter shift)
|
|
each report their OWN dates. The old shape re-derived every later year
|
|
from `first_year`, so this could not be expressed at all.
|
|
"""
|
|
series = uuid4()
|
|
a = _make(first_year=2027, series_id=series)
|
|
b = _make(
|
|
first_year=2028,
|
|
series_id=series,
|
|
window_start_month=7,
|
|
window_start_day=1,
|
|
window_end_month=8,
|
|
window_end_day=31,
|
|
)
|
|
assert a.window() == (date(2027, 6, 15), date(2027, 8, 15))
|
|
assert b.window() == (date(2028, 7, 1), date(2028, 8, 31))
|
|
|
|
def test_window_clamps_a_feb_29_anchor_in_a_non_leap_year(self) -> None:
|
|
"""A Feb-29 window in a non-leap year used to raise ValueError out of
|
|
the allocator; a whole-day planner has to answer Feb 28 instead.
|
|
"""
|
|
wish = _make(
|
|
first_year=2027,
|
|
window_start_month=2,
|
|
window_start_day=29,
|
|
window_end_month=3,
|
|
window_end_day=5,
|
|
)
|
|
assert wish.window() == (date(2027, 2, 28), date(2027, 3, 5))
|
|
|
|
# -- target_span_days() (calendar-drawn periods, day-granular) ---------
|
|
|
|
def test_target_span_days_falls_back_to_weeks_times_seven_when_unset(self) -> None:
|
|
wish = _make(target_weeks=6, target_days=None)
|
|
assert wish.target_span_days() == 42
|
|
|
|
def test_target_span_days_prefers_target_days_when_set(self) -> None:
|
|
# A calendar-drawn 10-day band, even though target_weeks still holds
|
|
# a stale/unrelated whole-week value.
|
|
wish = _make(target_weeks=6, target_days=10)
|
|
assert wish.target_span_days() == 10
|
|
|
|
|
|
class TestSQLiteLeaveWishRepository:
|
|
"""Test LeaveWishRepository SQLite implementation."""
|
|
|
|
async def test_create_and_get_by_id(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.id == created.id
|
|
assert fetched.name == "Sommar"
|
|
assert fetched.target_weeks == 6
|
|
assert fetched.series_id is None
|
|
assert fetched.parent_mode == "either"
|
|
assert fetched.child_id is None
|
|
assert fetched.parent_personal_data_uuid is None
|
|
|
|
async def test_get_by_id_missing(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
) -> None:
|
|
result = await leave_wish_repo.get_by_id(uuid4())
|
|
assert result is None
|
|
|
|
async def test_create_and_get_round_trip_name_message_key(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.name_message_key == "wishEditor.seasonJul"
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.name_message_key == "wishEditor.seasonJul"
|
|
|
|
async def test_create_without_name_message_key_defaults_to_none(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Existing-row simulation: a wish created before this field existed
|
|
|
|
(or a free-typed custom name) has no key and must read back `None`,
|
|
not a synthesized or empty-string value.
|
|
"""
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, name="Custom trip")
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.name_message_key is None
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.name_message_key is None
|
|
|
|
async def test_update_round_trips_name_message_key(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, name="Jul", name_message_key=None)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
created.name_message_key = "wishEditor.seasonJul"
|
|
updated = await leave_wish_repo.update(created.id, created)
|
|
assert updated.name_message_key == "wishEditor.seasonJul"
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.name_message_key == "wishEditor.seasonJul"
|
|
|
|
async def test_create_preserves_all_fields(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
name="Jullov",
|
|
window_start_month=12,
|
|
window_start_day=20,
|
|
window_end_month=1,
|
|
window_end_day=7,
|
|
target_weeks=4,
|
|
series_id=_SERIES_ID,
|
|
first_year=2027,
|
|
last_year=2031,
|
|
priority=2,
|
|
parent_mode="specific",
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
assert created.name == "Jullov"
|
|
assert created.child_id == seeded_ids.child_id
|
|
assert created.window_start_month == 12
|
|
assert created.window_start_day == 20
|
|
assert created.window_end_month == 1
|
|
assert created.window_end_day == 7
|
|
assert created.target_weeks == 4
|
|
assert created.series_id == _SERIES_ID
|
|
assert created.first_year == 2027
|
|
assert created.last_year == 2031
|
|
assert created.priority == 2
|
|
assert created.parent_mode == "specific"
|
|
assert created.parent_personal_data_uuid == seeded_ids.personal_data_uuid
|
|
|
|
# Round-trip through the DB, not just the in-memory object.
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.child_id == seeded_ids.child_id
|
|
assert fetched.last_year == 2031
|
|
assert fetched.series_id == _SERIES_ID
|
|
assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid
|
|
|
|
async def test_create_and_update_round_trip_target_days(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, target_weeks=6, target_days=10)
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.target_days == 10
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.target_days == 10
|
|
|
|
fetched.target_days = None
|
|
updated = await leave_wish_repo.update(fetched.id, fetched)
|
|
assert updated.target_days is None
|
|
|
|
refetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert refetched is not None
|
|
assert refetched.target_days is None
|
|
|
|
async def test_create_without_target_days_defaults_to_none(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, target_days=None)
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.target_days is None
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.target_days is None
|
|
|
|
async def test_create_defaults_wants_dubbeldag_to_false(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.wants_dubbeldag is False
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.wants_dubbeldag is False
|
|
|
|
async def test_create_and_update_round_trip_wants_dubbeldag(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id, parent_mode="both", wants_dubbeldag=True
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.wants_dubbeldag is True
|
|
|
|
fetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert fetched is not None
|
|
assert fetched.wants_dubbeldag is True
|
|
|
|
fetched.wants_dubbeldag = False
|
|
updated = await leave_wish_repo.update(fetched.id, fetched)
|
|
assert updated.wants_dubbeldag is False
|
|
|
|
refetched = await leave_wish_repo.get_by_id(created.id)
|
|
assert refetched is not None
|
|
assert refetched.wants_dubbeldag is False
|
|
|
|
async def test_get_by_workspace(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
workspace_repo: SQLiteWorkspaceRepository,
|
|
) -> None:
|
|
ws1 = seeded_ids.workspace_id
|
|
ws2 = await workspace_repo.create(
|
|
Workspace(id=uuid4(), name="WS2", created_at=datetime.now())
|
|
)
|
|
|
|
w1 = await leave_wish_repo.create(_make(workspace_id=ws1, name="W1"))
|
|
w2 = await leave_wish_repo.create(_make(workspace_id=ws1, name="W2"))
|
|
w3 = await leave_wish_repo.create(_make(workspace_id=ws2.id, name="W3"))
|
|
|
|
rows = await leave_wish_repo.get_by_workspace(ws1)
|
|
assert len(rows) == 2
|
|
ids = {r.id for r in rows}
|
|
assert w1.id in ids
|
|
assert w2.id in ids
|
|
assert w3.id not in ids
|
|
|
|
async def test_get_by_workspace_empty(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
) -> None:
|
|
result = await leave_wish_repo.get_by_workspace(uuid4())
|
|
assert result == []
|
|
|
|
async def test_update(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, name="Sommar", target_weeks=6)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
created.name = "Sommarlov"
|
|
created.target_weeks = 8
|
|
created.priority = 3
|
|
updated = await leave_wish_repo.update(created.id, created)
|
|
|
|
assert updated.name == "Sommarlov"
|
|
assert updated.target_weeks == 8
|
|
assert updated.priority == 3
|
|
assert updated.id == created.id
|
|
|
|
async def test_update_missing_raises(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await leave_wish_repo.update(uuid4(), _make())
|
|
|
|
async def test_delete(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
await leave_wish_repo.delete(created.id)
|
|
|
|
result = await leave_wish_repo.get_by_id(created.id)
|
|
assert result is None
|
|
|
|
async def test_delete_missing_raises(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await leave_wish_repo.delete(uuid4())
|
|
|
|
async def test_list(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws = seeded_ids.workspace_id
|
|
w1 = await leave_wish_repo.create(_make(workspace_id=ws, name="W1"))
|
|
w2 = await leave_wish_repo.create(_make(workspace_id=ws, name="W2"))
|
|
|
|
rows = await leave_wish_repo.list()
|
|
assert len(rows) >= 2
|
|
ids = {r.id for r in rows}
|
|
assert w1.id in ids
|
|
assert w2.id in ids
|
|
|
|
async def test_list_filters_by_child_id(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws = seeded_ids.workspace_id
|
|
tied = await leave_wish_repo.create(
|
|
_make(workspace_id=ws, child_id=seeded_ids.child_id, name="Tied")
|
|
)
|
|
family_wide = await leave_wish_repo.create(
|
|
_make(workspace_id=ws, child_id=None, name="FamilyWide")
|
|
)
|
|
|
|
rows = await leave_wish_repo.list(child_id=seeded_ids.child_id)
|
|
ids = {r.id for r in rows}
|
|
assert tied.id in ids
|
|
assert family_wide.id not in ids
|
|
|
|
async def test_list_rejects_unsupported_filter(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Passing an unsupported filter (like a typo) must raise ValueError, not return all rows."""
|
|
with pytest.raises(ValueError, match="parent_personal_data_uuid"):
|
|
await leave_wish_repo.list(parent_personal_data_uuid=seeded_ids.personal_data_uuid)
|
|
|
|
# Verify the error message lists what filters ARE supported
|
|
with pytest.raises(ValueError, match="child_id.*workspace_id"):
|
|
await leave_wish_repo.list(parent_personal_data_uuid=seeded_ids.personal_data_uuid)
|
|
|
|
async def test_list_with_supported_filter_still_works(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Verify filter validation doesn't break existing filtering behavior."""
|
|
ws = seeded_ids.workspace_id
|
|
created_1 = await leave_wish_repo.create(_make(workspace_id=ws, name="W1"))
|
|
created_2 = await leave_wish_repo.create(_make(workspace_id=ws, name="W2"))
|
|
|
|
rows = await leave_wish_repo.list(workspace_id=ws)
|
|
assert len(rows) >= 2
|
|
ids = {r.id for r in rows}
|
|
assert created_1.id in ids
|
|
assert created_2.id in ids
|
|
|
|
async def test_list_rejects_multiple_unsupported_filters(
|
|
self,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Error message should list all unsupported filters."""
|
|
with pytest.raises(ValueError, match="parent_personal_data_uuid.*typo_filter"):
|
|
await leave_wish_repo.list(
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
typo_filter="value",
|
|
)
|
|
|
|
|
|
class TestLeaveWishService:
|
|
"""Test LeaveWishService business logic (validation)."""
|
|
|
|
async def test_create_with_valid_fields(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Sommar",
|
|
window_start_month=6,
|
|
window_start_day=15,
|
|
window_end_month=8,
|
|
window_end_day=15,
|
|
target_weeks=6,
|
|
first_year=2027,
|
|
)
|
|
assert result.name == "Sommar"
|
|
assert result.target_weeks == 6
|
|
assert result.series_id is None # default: a standalone occurrence
|
|
assert result.parent_mode == "either" # default
|
|
assert result.wants_dubbeldag is False # default
|
|
|
|
@pytest.mark.parametrize("recurrence", ["once", "yearly"])
|
|
async def test_create_accepts_valid_recurrence(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
recurrence: str,
|
|
) -> None:
|
|
"""`recurrence` is an instruction now, not a stored field: 'yearly'
|
|
materialises a series (so the returned row carries a `series_id`),
|
|
'once' leaves a standalone occurrence.
|
|
"""
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
last_year=2029 if recurrence == "yearly" else None,
|
|
recurrence=recurrence,
|
|
)
|
|
assert (result.series_id is not None) is (recurrence == "yearly")
|
|
|
|
# Placeholder resolved to seeded_ids.personal_data_uuid at test time, since
|
|
# parametrize case values are collected before fixtures exist.
|
|
_MISSING_UUID = object()
|
|
|
|
_REJECTION_BASE_KWARGS: dict[str, object] = dict(
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
)
|
|
|
|
_REJECTION_CASES = [
|
|
pytest.param({"recurrence": "monthly"}, "recurrence", id="invalid_recurrence"),
|
|
pytest.param({"parent_mode": "everyone"}, "parent_mode", id="invalid_parent_mode"),
|
|
pytest.param(
|
|
{"parent_mode": "either", "parent_personal_data_uuid": _MISSING_UUID},
|
|
"parent_personal_data_uuid",
|
|
id="parent_uuid_forbidden_unless_specific",
|
|
),
|
|
pytest.param({"window_mode": "whenever"}, "window_mode", id="invalid_window_mode"),
|
|
pytest.param(
|
|
{
|
|
"window_start_month": 3,
|
|
"window_start_day": 1,
|
|
"window_end_month": 3,
|
|
"window_end_day": 10,
|
|
"target_weeks": 2,
|
|
"window_mode": "flexible",
|
|
},
|
|
"flexible",
|
|
id="flexible_bound_shorter_than_target_weeks",
|
|
),
|
|
*[
|
|
pytest.param(
|
|
{
|
|
"window_start_month": month,
|
|
"window_start_day": day,
|
|
"window_end_month": 6,
|
|
"window_end_day": 20,
|
|
},
|
|
None,
|
|
id=f"invalid_month_day_{month}_{day}",
|
|
)
|
|
for month, day in [(0, 15), (13, 15), (6, 0), (6, 32), (2, 30), (4, 31)]
|
|
],
|
|
pytest.param({"target_days": 0}, "target_days", id="target_days_below_one"),
|
|
pytest.param({"target_weeks": 0}, "target_weeks", id="zero_target_weeks"),
|
|
pytest.param(
|
|
{"first_year": 2030, "last_year": 2029},
|
|
"last_year",
|
|
id="last_year_before_first_year",
|
|
),
|
|
pytest.param(
|
|
{"parent_mode": "either", "wants_dubbeldag": True},
|
|
"wants_dubbeldag",
|
|
id="wants_dubbeldag_forbidden_unless_both",
|
|
),
|
|
]
|
|
|
|
@pytest.mark.parametrize("overrides,match", _REJECTION_CASES)
|
|
async def test_create_rejects_invalid_input(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
overrides: dict[str, object],
|
|
match: str | None,
|
|
) -> None:
|
|
kwargs = {**self._REJECTION_BASE_KWARGS, **overrides}
|
|
if kwargs.get("parent_personal_data_uuid") is self._MISSING_UUID:
|
|
kwargs["parent_personal_data_uuid"] = seeded_ids.personal_data_uuid
|
|
|
|
with pytest.raises(ValidationError, match=match):
|
|
await leave_wish_service.create(workspace_id=seeded_ids.workspace_id, **kwargs)
|
|
|
|
async def test_create_specific_parent_mode_requires_uuid(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
with pytest.raises(ValidationError, match="parent_personal_data_uuid"):
|
|
await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
parent_mode="specific",
|
|
parent_personal_data_uuid=None,
|
|
)
|
|
|
|
async def test_create_specific_parent_mode_with_uuid_succeeds(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
parent_mode="specific",
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
)
|
|
assert result.parent_mode == "specific"
|
|
assert result.parent_personal_data_uuid == seeded_ids.personal_data_uuid
|
|
|
|
# -- wants_dubbeldag (§Decision A, opt-in co-leave) ---------------------
|
|
|
|
async def test_create_both_parent_mode_can_opt_into_dubbeldagar(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
parent_mode="both",
|
|
wants_dubbeldag=True,
|
|
)
|
|
assert result.parent_mode == "both"
|
|
assert result.wants_dubbeldag is True
|
|
|
|
async def test_update_can_toggle_wants_dubbeldag(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
created = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
parent_mode="both",
|
|
)
|
|
assert created.wants_dubbeldag is False
|
|
|
|
updated = await leave_wish_service.update(leave_wish_id=created.id, wants_dubbeldag=True)
|
|
assert updated.wants_dubbeldag is True
|
|
|
|
# -- window_mode (§10.1) ------------------------------------------------
|
|
|
|
async def test_create_defaults_to_fixed_window_mode(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
)
|
|
assert result.window_mode == "fixed"
|
|
|
|
async def test_create_accepts_flexible_window_mode_with_wide_enough_bound(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
# Bound = 2027-03-01 -> 2027-05-31 (92 days), target_weeks=1 (7 days).
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Vårlov",
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=5,
|
|
window_end_day=31,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
window_mode="flexible",
|
|
)
|
|
assert result.window_mode == "flexible"
|
|
|
|
async def test_update_can_switch_window_mode_to_flexible(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=5,
|
|
window_end_day=31,
|
|
target_weeks=1,
|
|
)
|
|
created = await leave_wish_service._repo.create(entity)
|
|
assert created.window_mode == "fixed"
|
|
|
|
updated = await leave_wish_service.update(created.id, window_mode="flexible")
|
|
assert updated.window_mode == "flexible"
|
|
|
|
async def test_update_to_flexible_still_validates_bound_length(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=3,
|
|
window_end_day=10, # 10-day bound
|
|
target_weeks=2, # needs 14 days
|
|
)
|
|
created = await leave_wish_service._repo.create(entity)
|
|
|
|
with pytest.raises(ValidationError, match="flexible"):
|
|
await leave_wish_service.update(created.id, window_mode="flexible")
|
|
|
|
async def test_create_accepts_leap_day(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=2,
|
|
window_start_day=29,
|
|
window_end_month=3,
|
|
window_end_day=1,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
)
|
|
assert result.window_start_day == 29
|
|
|
|
# -- target_days (calendar-drawn periods, day-granular) ----------------
|
|
|
|
async def test_create_with_target_days_persists_it(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=31,
|
|
target_weeks=1,
|
|
target_days=10,
|
|
first_year=2027,
|
|
)
|
|
assert result.target_days == 10
|
|
assert result.target_span_days() == 10
|
|
|
|
async def test_create_without_target_days_defaults_to_none(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=2,
|
|
first_year=2027,
|
|
)
|
|
assert result.target_days is None
|
|
assert result.target_span_days() == 14
|
|
|
|
async def test_create_flexible_wish_sized_by_target_days_rejects_short_bound(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
# Bound = 2027-03-01 -> 2027-03-10 (10 days); target_days=11 doesn't
|
|
# fit even though target_weeks=1 (7 days) would. target_days must be
|
|
# the value actually checked, not silently ignored in favor of weeks.
|
|
with pytest.raises(ValidationError, match="flexible"):
|
|
await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Vårlov",
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=3,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
target_days=11,
|
|
first_year=2027,
|
|
window_mode="flexible",
|
|
)
|
|
|
|
async def test_create_flexible_wish_sized_by_target_days_accepts_matching_bound(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
# Same 10-day bound, target_days=10 fits exactly.
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Vårlov",
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=3,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
target_days=10,
|
|
first_year=2027,
|
|
window_mode="flexible",
|
|
)
|
|
assert result.target_days == 10
|
|
|
|
async def test_update_can_set_target_days(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, target_days=None)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(leave_wish_id=created.id, target_days=10)
|
|
assert result.target_days == 10
|
|
|
|
async def test_update_can_clear_target_days(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, target_days=10)
|
|
created = await leave_wish_repo.create(entity)
|
|
assert created.target_days == 10
|
|
|
|
result = await leave_wish_service.update(leave_wish_id=created.id, _clear_target_days=True)
|
|
assert result.target_days is None
|
|
|
|
async def test_create_allows_last_year_equal_first_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A one-year series is legal and produces exactly one row.
|
|
|
|
(`recurrence='yearly'` is required for `last_year` to survive at all
|
|
now — it is the series' end-year intent, and a standalone occurrence
|
|
has no series to end. See `LeaveWishService._validate`.)
|
|
"""
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
recurrence="yearly",
|
|
first_year=2027,
|
|
last_year=2027,
|
|
)
|
|
assert result.last_year == 2027
|
|
assert result.series_id is not None
|
|
assert len(await leave_wish_repo.get_by_series(result.series_id)) == 1
|
|
|
|
async def test_create_allows_null_last_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Wish",
|
|
window_start_month=1,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=10,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
last_year=None,
|
|
)
|
|
assert result.last_year is None
|
|
|
|
async def test_get(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
fetched = await leave_wish_service.get(created.id)
|
|
assert fetched.id == created.id
|
|
assert fetched.name == "Sommar"
|
|
|
|
async def test_get_missing_raises(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await leave_wish_service.get(uuid4())
|
|
|
|
async def test_list_for_workspace(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws = seeded_ids.workspace_id
|
|
w1 = await leave_wish_repo.create(_make(workspace_id=ws, name="W1"))
|
|
w2 = await leave_wish_repo.create(_make(workspace_id=ws, name="W2"))
|
|
|
|
result = await leave_wish_service.list_for_workspace(ws)
|
|
assert len(result) == 2
|
|
ids = {r.id for r in result}
|
|
assert w1.id in ids
|
|
assert w2.id in ids
|
|
|
|
async def test_update_partial_only_touches_given_fields(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, name="Sommar", target_weeks=6)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(
|
|
leave_wish_id=created.id,
|
|
name="Sommarlov",
|
|
)
|
|
assert result.name == "Sommarlov"
|
|
assert result.target_weeks == 6 # untouched
|
|
assert result.window_start_month == 6 # untouched
|
|
|
|
async def test_update_can_set_all_optional_fields(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Every non-clear-flag optional field, set to a real value in one call."""
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, series_id=None, last_year=None)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(
|
|
leave_wish_id=created.id,
|
|
child_id=seeded_ids.child_id,
|
|
window_start_month=3,
|
|
window_start_day=10,
|
|
window_end_month=4,
|
|
window_end_day=20,
|
|
target_weeks=9,
|
|
recurrence="yearly",
|
|
first_year=2028,
|
|
last_year=2030,
|
|
priority=5,
|
|
parent_mode="specific",
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
)
|
|
|
|
assert result.child_id == seeded_ids.child_id
|
|
assert result.window_start_month == 3
|
|
assert result.window_start_day == 10
|
|
assert result.window_end_month == 4
|
|
assert result.window_end_day == 20
|
|
assert result.target_weeks == 9
|
|
assert result.series_id is not None # 'yearly' materialised a series
|
|
assert result.first_year == 2028
|
|
assert result.last_year == 2030
|
|
assert result.priority == 5
|
|
assert result.parent_mode == "specific"
|
|
assert result.parent_personal_data_uuid == seeded_ids.personal_data_uuid
|
|
|
|
async def test_update_rejects_invalid_values(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
with pytest.raises(ValidationError):
|
|
await leave_wish_service.update(leave_wish_id=created.id, target_weeks=0)
|
|
|
|
async def test_update_can_clear_last_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Clearing a series' end year re-opens it to the child's 4th birthday."""
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
series_id=_SERIES_ID,
|
|
first_year=2027,
|
|
last_year=2030,
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(
|
|
leave_wish_id=created.id,
|
|
scope="series",
|
|
_clear_last_year=True,
|
|
)
|
|
assert result.last_year is None
|
|
|
|
async def test_update_can_clear_child_id(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(
|
|
leave_wish_id=created.id,
|
|
_clear_child_id=True,
|
|
)
|
|
assert result.child_id is None
|
|
|
|
async def test_update_can_clear_parent_personal_data_uuid(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
parent_mode="specific",
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(
|
|
leave_wish_id=created.id,
|
|
parent_mode="either",
|
|
_clear_parent_personal_data_uuid=True,
|
|
)
|
|
assert result.parent_mode == "either"
|
|
assert result.parent_personal_data_uuid is None
|
|
|
|
# -- name_message_key (fixes the "Jul"/"Höstlov" always-Swedish bug) ---
|
|
|
|
async def test_create_passes_through_name_message_key(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
window_start_month=12,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=31,
|
|
target_weeks=4,
|
|
first_year=2027,
|
|
)
|
|
assert result.name_message_key == "wishEditor.seasonJul"
|
|
|
|
async def test_update_without_changing_name_preserves_name_message_key(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(leave_wish_id=created.id, target_weeks=5)
|
|
assert result.name_message_key == "wishEditor.seasonJul"
|
|
assert result.name == "Jul"
|
|
|
|
async def test_update_that_resends_the_same_name_preserves_name_message_key(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""The auto-save flow resends the unchanged `name` on every edit —
|
|
|
|
only an *actual* change to the text may clear the key.
|
|
"""
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(leave_wish_id=created.id, name="Jul")
|
|
assert result.name_message_key == "wishEditor.seasonJul"
|
|
|
|
async def test_update_renaming_clears_name_message_key_even_without_being_told_to(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Backend enforcement, not caller-trust: renaming clears the key
|
|
|
|
even if the caller never mentions `name_message_key` at all — this
|
|
is the critical guard against a user's custom name silently
|
|
reverting to a stale preset label.
|
|
"""
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(leave_wish_id=created.id, name="Vinterresa")
|
|
assert result.name == "Vinterresa"
|
|
assert result.name_message_key is None
|
|
|
|
async def test_update_can_explicitly_clear_name_message_key(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
result = await leave_wish_service.update(
|
|
leave_wish_id=created.id, _clear_name_message_key=True
|
|
)
|
|
assert result.name_message_key is None
|
|
assert result.name == "Jul" # unrelated to the clear
|
|
|
|
async def test_update_missing_raises(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await leave_wish_service.update(leave_wish_id=uuid4(), name="Name")
|
|
|
|
async def test_delete(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(workspace_id=seeded_ids.workspace_id)
|
|
created = await leave_wish_repo.create(entity)
|
|
|
|
await leave_wish_service.delete(created.id)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
await leave_wish_service.get(created.id)
|
|
|
|
async def test_delete_missing_raises(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await leave_wish_service.delete(uuid4())
|
|
|
|
|
|
class TestLeaveWishServiceDeleteCascade:
|
|
"""`LeaveWishService.delete()` must remove the `planned_leave_day` rows
|
|
|
|
the wish produced, not just null their `wish_id` out from under them
|
|
(the schema's `ON DELETE SET NULL` would otherwise leave them behind as
|
|
permanent, unattributable ad-hoc rows — see the docstring on
|
|
`LeaveWishService.delete()` and `PlannedLeaveRepository.delete_by_wish`).
|
|
"""
|
|
|
|
def _make_day(
|
|
self,
|
|
seeded_ids: SeededIds,
|
|
*,
|
|
wish_id: UUID | None = None,
|
|
day: str = "2027-02-03",
|
|
) -> PlannedLeaveDay:
|
|
return PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date.fromisoformat(day),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
wish_id=wish_id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
|
|
async def test_delete_removes_the_planned_leave_days_it_produced(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id))
|
|
day1 = await planned_leave_repo.create(
|
|
self._make_day(seeded_ids, wish_id=wish.id, day="2027-02-03")
|
|
)
|
|
day2 = await planned_leave_repo.create(
|
|
self._make_day(seeded_ids, wish_id=wish.id, day="2027-02-04")
|
|
)
|
|
|
|
await leave_wish_service.delete(wish.id)
|
|
|
|
assert await planned_leave_repo.get_by_id(day1.id) is None
|
|
assert await planned_leave_repo.get_by_id(day2.id) is None
|
|
|
|
async def test_delete_removes_a_pinned_day_too(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
day_assertion_repo: SQLiteDayAssertionRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A pinned day dies with its wish rather than surviving as ad-hoc —
|
|
|
|
and so does its `DayAssertion` (restructure step 3b). Pinning only
|
|
protects a day from being silently retyped/reassigned by a later
|
|
`sync_plan` re-plan — it is not a signal that the day should outlive
|
|
a period the user explicitly chose to delete. This also matches the
|
|
ad-hoc-period delete path in `Calendar.tsx` (`handleDeletePeriod`'s
|
|
no-`wishId` branch), which already deletes every day in the band
|
|
regardless of pin state.
|
|
|
|
`LeaveWishRepository.delete_by_wish` itself never looked at
|
|
`is_pinned` (it deleted by `wish_id` alone even before that column
|
|
existed) — the row-level cascade was never in doubt. What step 3b
|
|
adds is `LeaveWishService._clear_assertions_for`, which is what this
|
|
test's `day_assertion_repo` assertion actually exercises: without
|
|
it, this assertion would survive with no row left to attach to, see
|
|
`TestWishDeleteClearsAssertions` in
|
|
test_services/test_allocation_day_assertion.py for the full
|
|
stray-slot scenario this prevents.
|
|
"""
|
|
wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id))
|
|
pinned = await planned_leave_repo.create(self._make_day(seeded_ids, wish_id=wish.id))
|
|
await day_assertion_repo.create(
|
|
DayAssertion(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=pinned.date,
|
|
day_type=pinned.day_type,
|
|
wish_id=wish.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
await leave_wish_service.delete(wish.id)
|
|
|
|
assert await planned_leave_repo.get_by_id(pinned.id) is None
|
|
assert (
|
|
await day_assertion_repo.get_by_natural_key(
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
pinned.date,
|
|
)
|
|
is None
|
|
)
|
|
|
|
async def test_delete_leaves_a_different_wishs_days_untouched(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
wish_a = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id, name="A"))
|
|
wish_b = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id, name="B"))
|
|
day_a = await planned_leave_repo.create(
|
|
self._make_day(seeded_ids, wish_id=wish_a.id, day="2027-02-03")
|
|
)
|
|
day_b = await planned_leave_repo.create(
|
|
self._make_day(seeded_ids, wish_id=wish_b.id, day="2027-02-04")
|
|
)
|
|
|
|
await leave_wish_service.delete(wish_a.id)
|
|
|
|
assert await planned_leave_repo.get_by_id(day_a.id) is None
|
|
survivor = await planned_leave_repo.get_by_id(day_b.id)
|
|
assert survivor is not None
|
|
assert survivor.wish_id == wish_b.id
|
|
|
|
async def test_delete_leaves_genuine_ad_hoc_days_untouched(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
wish = await leave_wish_repo.create(_make(workspace_id=seeded_ids.workspace_id))
|
|
ad_hoc = await planned_leave_repo.create(
|
|
self._make_day(seeded_ids, wish_id=None, day="2027-02-03")
|
|
)
|
|
|
|
await leave_wish_service.delete(wish.id)
|
|
|
|
survivor = await planned_leave_repo.get_by_id(ad_hoc.id)
|
|
assert survivor is not None
|
|
assert survivor.wish_id is None
|
|
|
|
|
|
class TestLeaveWishServiceAtomicity:
|
|
"""Bug 3 (adversarial-review-adjacent) — `LeaveWishService` previously had
|
|
|
|
NO unit of work at all, so `delete()`'s multi-row loop (clear assertions
|
|
-> delete `planned_leave_day` rows -> delete the `LeaveWish` row, once
|
|
per targeted occurrence) committed each statement immediately. A crash
|
|
partway through a `scope='series'` delete left some years fully deleted
|
|
and others untouched. `leave_wish_service_with_uow` wires a real
|
|
`SQLiteUnitOfWork`, matching `PlanningService`'s established pattern —
|
|
this proves a failure mid-loop now rolls the WHOLE delete back.
|
|
"""
|
|
|
|
async def test_failure_mid_series_delete_leaves_every_row_untouched(
|
|
self,
|
|
leave_wish_service_with_uow: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
series_id = uuid4()
|
|
year1 = await leave_wish_repo.create(
|
|
_make(workspace_id=seeded_ids.workspace_id, first_year=2027, series_id=series_id)
|
|
)
|
|
year2 = await leave_wish_repo.create(
|
|
_make(workspace_id=seeded_ids.workspace_id, first_year=2028, series_id=series_id)
|
|
)
|
|
day1 = await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2027, 6, 15),
|
|
day_type="sjukpenning",
|
|
wish_id=year1.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
real_delete = leave_wish_repo.delete
|
|
call_count = 0
|
|
|
|
async def flaky_delete(wish_id: UUID) -> None:
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 2:
|
|
raise RuntimeError("simulated crash mid-series-delete")
|
|
await real_delete(wish_id)
|
|
|
|
monkeypatch.setattr(leave_wish_repo, "delete", flaky_delete)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
await leave_wish_service_with_uow.delete(year1.id, scope="series")
|
|
|
|
# Neither year's LeaveWish row nor its planned_leave_day row was
|
|
# actually removed — the first successful delete was rolled back
|
|
# along with the second (failed) one.
|
|
assert await leave_wish_repo.get_by_id(year1.id) is not None
|
|
assert await leave_wish_repo.get_by_id(year2.id) is not None
|
|
assert await planned_leave_repo.get_by_id(day1.id) is not None
|
|
|
|
|
|
class TestLeaveWishSeriesMaterialisation:
|
|
"""A repeating wish is stored as one row per year, sharing a `series_id`.
|
|
|
|
This is the shape that makes "edit/remove just this year" possible at all:
|
|
before it, a later year had no row to address, so the frontend had to
|
|
rewrite the shared month/day pattern for every year (edit) or narrow
|
|
`first_year`/`last_year` — and split the wish in two for a middle year
|
|
(remove). Both contortions are gone.
|
|
"""
|
|
|
|
_BASE: dict[str, Any] = dict(
|
|
name="Sommar",
|
|
window_start_month=10,
|
|
window_start_day=5, # 2026-10-05 is a Monday
|
|
window_end_month=10,
|
|
window_end_day=11, # ...10-11 the Sunday after
|
|
target_weeks=1,
|
|
)
|
|
|
|
async def _series(
|
|
self,
|
|
service: LeaveWishService,
|
|
repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
**overrides: Any,
|
|
) -> tuple[LeaveWish, list[LeaveWish]]:
|
|
kwargs: dict[str, Any] = {
|
|
**self._BASE,
|
|
"recurrence": "yearly",
|
|
"first_year": 2026,
|
|
"last_year": 2029,
|
|
**overrides,
|
|
}
|
|
anchor = await service.create(workspace_id=seeded_ids.workspace_id, **kwargs)
|
|
assert anchor.series_id is not None
|
|
return anchor, await repo.get_by_series(anchor.series_id)
|
|
|
|
async def test_create_yearly_writes_one_row_per_year_sharing_a_series_id(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
|
|
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
|
|
assert {r.series_id for r in rows} == {anchor.series_id}
|
|
assert anchor.first_year == 2026 # create() returns the first occurrence
|
|
# Every row is addressable on its own — distinct ids, one per year.
|
|
assert len({r.id for r in rows}) == 4
|
|
|
|
async def test_create_once_writes_a_single_standalone_row(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
created = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
|
|
)
|
|
assert created.series_id is None
|
|
assert created.last_year is None
|
|
assert len(await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)) == 1
|
|
|
|
async def test_the_weekday_snap_lands_in_the_stored_rows(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Mon-Sun stays Mon-Sun, and the shifted dates are the row's own
|
|
columns — nothing re-derives a later year from the first one.
|
|
"""
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
|
|
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
|
|
(10, 5),
|
|
(10, 4),
|
|
(10, 2),
|
|
(10, 8),
|
|
]
|
|
for row in rows:
|
|
start, end = row.window()
|
|
assert start.weekday() == 0, row.first_year
|
|
assert end.weekday() == 6, row.first_year
|
|
assert (end - start).days == 6
|
|
|
|
async def test_flexible_series_keeps_its_bounding_ranges_weekday_aligned(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A flexible wish's fields are a bounding range, not a placement, but
|
|
|
|
the range must still keep its weekday alignment year over year (the
|
|
reported bug: a week-shaped bound silently drifting onto a different
|
|
weekday every non-anchor year) — same snap `window_mode == 'fixed'`
|
|
gets, with the bound's day-*span* held fixed so the search space
|
|
`resolve_flexible_window` explores never shrinks.
|
|
"""
|
|
_, rows = await self._series(
|
|
leave_wish_service,
|
|
leave_wish_repo,
|
|
seeded_ids,
|
|
window_start_month=3,
|
|
window_start_day=1, # 2026-03-01 is a Sunday
|
|
window_end_month=5,
|
|
window_end_day=31,
|
|
window_mode="flexible",
|
|
)
|
|
assert [r.first_year for r in rows] == [2026, 2027, 2028, 2029]
|
|
assert [(r.window_start_month, r.window_start_day) for r in rows] == [
|
|
(3, 1),
|
|
(2, 28),
|
|
(2, 27),
|
|
(3, 4),
|
|
]
|
|
for row in rows:
|
|
start, end = row.window()
|
|
assert start.weekday() == 6, row.first_year # Sunday, like the anchor
|
|
assert (end - start).days == 91, row.first_year # span held fixed
|
|
|
|
async def test_open_ended_series_stops_at_the_childs_fourth_birthday_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""`last_year=None` means "until the child turns 4" — the app's whole
|
|
premise. The seeded child is born 2024-01-01, so 2028 is the last year.
|
|
"""
|
|
_, rows = await self._series(
|
|
leave_wish_service,
|
|
leave_wish_repo,
|
|
seeded_ids,
|
|
child_id=seeded_ids.child_id,
|
|
first_year=2026,
|
|
last_year=None,
|
|
)
|
|
assert [r.first_year for r in rows] == [2026, 2027, 2028]
|
|
|
|
async def test_editing_one_year_leaves_its_siblings_alone(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""The reason this shape exists: 2028's Easter is not 2027's, so one
|
|
year must be able to move without dragging the others with it.
|
|
"""
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
before = {r.first_year: r.window() for r in rows}
|
|
target = next(r for r in rows if r.first_year == 2028)
|
|
|
|
updated = await leave_wish_service.update(
|
|
leave_wish_id=target.id,
|
|
window_start_month=11,
|
|
window_start_day=6,
|
|
window_end_month=11,
|
|
window_end_day=12,
|
|
scope="occurrence",
|
|
)
|
|
assert updated.window() == (date(2028, 11, 6), date(2028, 11, 12))
|
|
assert updated.series_id == target.series_id # still part of the series
|
|
|
|
after = {
|
|
r.first_year: r.window()
|
|
for r in await leave_wish_repo.get_by_series(target.series_id)
|
|
if r.first_year != 2028
|
|
}
|
|
assert after == {y: w for y, w in before.items() if y != 2028}
|
|
|
|
async def test_editing_the_series_reprojects_every_year_from_the_edited_row(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
""" "Every year" makes the clicked occurrence the new anchor; the other
|
|
years follow it with the same weekday-preserving projection the old
|
|
single-row shape ran at read time.
|
|
"""
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
target = next(r for r in rows if r.first_year == 2028)
|
|
|
|
await leave_wish_service.update(
|
|
leave_wish_id=target.id,
|
|
name="Höstlov",
|
|
window_start_month=11,
|
|
window_start_day=6, # 2028-11-06 is a Monday
|
|
window_end_month=11,
|
|
window_end_day=12,
|
|
scope="series",
|
|
)
|
|
|
|
assert target.series_id is not None
|
|
after = await leave_wish_repo.get_by_series(target.series_id)
|
|
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029]
|
|
assert {r.name for r in after} == {"Höstlov"}
|
|
for row in after:
|
|
start, end = row.window()
|
|
assert start.weekday() == 0, row.first_year
|
|
assert end.weekday() == 6, row.first_year
|
|
assert start.month == 11
|
|
assert (end - start).days == 6
|
|
|
|
async def test_extending_last_year_adds_the_missing_rows(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
assert len(rows) == 4
|
|
|
|
await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2031, scope="series")
|
|
|
|
assert anchor.series_id is not None
|
|
after = await leave_wish_repo.get_by_series(anchor.series_id)
|
|
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029, 2030, 2031]
|
|
assert {r.last_year for r in after} == {2031}
|
|
|
|
async def test_shortening_last_year_deletes_the_rows_past_it(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
dropped = next(r for r in rows if r.first_year == 2029)
|
|
orphan_candidate = await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2029, 10, 9),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
wish_id=dropped.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
await leave_wish_service.update(leave_wish_id=anchor.id, last_year=2027, scope="series")
|
|
|
|
assert anchor.series_id is not None
|
|
after = await leave_wish_repo.get_by_series(anchor.series_id)
|
|
assert [r.first_year for r in after] == [2026, 2027]
|
|
# The dropped years take their planned days with them, exactly like
|
|
# deleting a period does.
|
|
assert await planned_leave_repo.get_by_id(orphan_candidate.id) is None
|
|
|
|
async def test_changing_last_year_on_one_occurrence_is_rejected(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""`last_year` belongs to the series, not to one year of it — silently
|
|
desyncing the mirror would be worse than a 422.
|
|
"""
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
target = next(r for r in rows if r.first_year == 2028)
|
|
|
|
with pytest.raises(ValidationError, match="series"):
|
|
await leave_wish_service.update(
|
|
leave_wish_id=target.id, last_year=2031, scope="occurrence"
|
|
)
|
|
|
|
async def test_making_a_standalone_wish_repeat_materialises_around_it(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
created = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
|
|
)
|
|
assert created.series_id is None
|
|
|
|
updated = await leave_wish_service.update(
|
|
leave_wish_id=created.id, recurrence="yearly", last_year=2028
|
|
)
|
|
assert updated.series_id is not None
|
|
rows = await leave_wish_repo.get_by_series(updated.series_id)
|
|
assert [r.first_year for r in rows] == [2026, 2027, 2028]
|
|
assert rows[0].id == created.id # the original row anchors the series
|
|
|
|
async def test_stopping_a_series_keeps_the_clicked_year_and_drops_the_rest(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
keeper = next(r for r in rows if r.first_year == 2028)
|
|
|
|
updated = await leave_wish_service.update(
|
|
leave_wish_id=keeper.id, recurrence="once", scope="series"
|
|
)
|
|
assert updated.series_id is None
|
|
assert updated.last_year is None
|
|
|
|
remaining = await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id)
|
|
assert [r.id for r in remaining] == [keeper.id]
|
|
|
|
async def test_delete_occurrence_removes_only_that_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""What used to require narrowing the range — or splitting the wish in
|
|
two around a middle year — is now one plain row delete.
|
|
"""
|
|
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
middle = next(r for r in rows if r.first_year == 2028)
|
|
|
|
await leave_wish_service.delete(middle.id, scope="occurrence")
|
|
|
|
assert anchor.series_id is not None
|
|
after = await leave_wish_repo.get_by_series(anchor.series_id)
|
|
assert [r.first_year for r in after] == [2026, 2027, 2029]
|
|
|
|
async def test_delete_series_removes_every_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
middle = next(r for r in rows if r.first_year == 2028)
|
|
|
|
await leave_wish_service.delete(middle.id, scope="series")
|
|
|
|
assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == []
|
|
|
|
async def test_delete_series_clears_every_occurrences_assertion_not_just_the_clicked_year(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
day_assertion_repo: SQLiteDayAssertionRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""`scope='series'` deletes every occurrence's rows (restructure
|
|
|
|
step 3b: and their assertions), not just the clicked year's — the
|
|
cleanup must not accidentally narrow to a single occurrence the way
|
|
the pre-materialisation single-row shape used to force.
|
|
"""
|
|
_, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
middle = next(r for r in rows if r.first_year == 2028)
|
|
|
|
planned_by_year: dict[int, PlannedLeaveDay] = {}
|
|
for occurrence in rows:
|
|
day = date(
|
|
occurrence.first_year, occurrence.window_start_month, occurrence.window_start_day
|
|
)
|
|
planned = await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=day,
|
|
day_type="vacation",
|
|
wish_id=occurrence.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
planned_by_year[occurrence.first_year] = planned
|
|
await day_assertion_repo.create(
|
|
DayAssertion(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=day,
|
|
day_type="vacation",
|
|
wish_id=occurrence.id,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
await leave_wish_service.delete(middle.id, scope="series")
|
|
|
|
for occurrence in rows:
|
|
planned = planned_by_year[occurrence.first_year]
|
|
assert await planned_leave_repo.get_by_id(planned.id) is None
|
|
assert (
|
|
await day_assertion_repo.get_by_natural_key(
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
planned.date,
|
|
)
|
|
is None
|
|
), f"assertion for {occurrence.first_year} survived a series-scoped delete"
|
|
|
|
async def test_delete_series_on_a_standalone_wish_removes_just_it(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
""" "This year" and "every year" coincide on a one-off occurrence."""
|
|
created = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
|
|
)
|
|
await leave_wish_service.delete(created.id, scope="series")
|
|
assert await leave_wish_repo.get_by_workspace(seeded_ids.workspace_id) == []
|
|
|
|
async def test_an_unknown_scope_is_rejected(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
created = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id, first_year=2026, **self._BASE
|
|
)
|
|
with pytest.raises(ValidationError, match="scope"):
|
|
await leave_wish_service.update(leave_wish_id=created.id, scope="everything")
|
|
with pytest.raises(ValidationError, match="scope"):
|
|
await leave_wish_service.delete(created.id, scope="everything")
|
|
|
|
async def test_a_removed_year_can_be_put_back_into_its_series(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""The calendar's undo of "remove this year" must restore the year as
|
|
part of the repeating wish, not as a detached one-off a later "every
|
|
year" edit would miss.
|
|
"""
|
|
anchor, rows = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
assert anchor.series_id is not None
|
|
removed = next(r for r in rows if r.first_year == 2028)
|
|
await leave_wish_service.delete(removed.id, scope="occurrence")
|
|
|
|
restored = await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
first_year=removed.first_year,
|
|
window_start_month=removed.window_start_month,
|
|
window_start_day=removed.window_start_day,
|
|
window_end_month=removed.window_end_month,
|
|
window_end_day=removed.window_end_day,
|
|
target_weeks=removed.target_weeks,
|
|
name=removed.name,
|
|
series_id=anchor.series_id,
|
|
)
|
|
assert restored.series_id == anchor.series_id
|
|
assert restored.last_year == anchor.last_year # the mirror stays consistent
|
|
after = await leave_wish_repo.get_by_series(anchor.series_id)
|
|
assert [r.first_year for r in after] == [2026, 2027, 2028, 2029]
|
|
|
|
async def test_an_unknown_series_id_on_create_is_rejected(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
with pytest.raises(ValidationError, match="series_id"):
|
|
await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
first_year=2026,
|
|
series_id=uuid4(),
|
|
**self._BASE,
|
|
)
|
|
|
|
async def test_series_id_cannot_be_combined_with_recurrence_yearly(
|
|
self,
|
|
leave_wish_service: LeaveWishService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
anchor, _ = await self._series(leave_wish_service, leave_wish_repo, seeded_ids)
|
|
with pytest.raises(ValidationError, match="series_id"):
|
|
await leave_wish_service.create(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
first_year=2035,
|
|
recurrence="yearly",
|
|
last_year=2036,
|
|
series_id=anchor.series_id,
|
|
**self._BASE,
|
|
)
|