6d92b87bf3
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
588 lines
20 KiB
Python
588 lines
20 KiB
Python
"""Tests for the PlanningService (planned leave day planning).
|
|
|
|
Demonstrates service-layer testing with external service dependencies.
|
|
Aim for >=90% coverage on new code (ARCHITECTURE.md §10).
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.entities.child import Child
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.personal_data import PersonalData
|
|
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
|
from app.services.planning_service import PlanningService
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish:
|
|
base = dict(
|
|
id=uuid4(),
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
name="Test wish",
|
|
window_start_month=6,
|
|
window_start_day=1,
|
|
window_end_month=6,
|
|
window_end_day=30,
|
|
target_weeks=1,
|
|
first_year=2026,
|
|
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 TestPlanningService:
|
|
"""Test PlanningService business logic."""
|
|
|
|
async def test_add_planned_day_simple(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Add a simple planned day (non-dubbeldag)."""
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
assert result.date == date(2026, 6, 15)
|
|
assert result.day_type == "vacation"
|
|
assert result.is_dubbeldag is False
|
|
|
|
async def test_add_planned_day_with_wish_id_and_is_pinned(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Round-trips `wish_id`/`is_pinned` through the repository (create +
|
|
re-fetch), not just the in-memory object the create call returns."""
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
|
|
)
|
|
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
wish_id=wish.id,
|
|
is_pinned=True,
|
|
)
|
|
assert result.wish_id == wish.id
|
|
assert result.is_pinned is True
|
|
|
|
fetched = await planning_service.get(result.id)
|
|
assert fetched.wish_id == wish.id
|
|
assert fetched.is_pinned is True
|
|
|
|
async def test_add_planned_day_without_wish_id_defaults_to_none(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
assert result.wish_id is None
|
|
assert result.is_pinned is False
|
|
|
|
async def test_add_double_booking_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Adding a second planned day on same date raises ValidationError.
|
|
|
|
Note: This test verifies that the same parent cannot plan the same date twice.
|
|
The error message was updated to be more accurate: "Parent already has a planned
|
|
day" instead of the previous generic "A planned day already exists" (which
|
|
incorrectly rejected different parents planning the same date as a dubbeldag).
|
|
"""
|
|
child_id = seeded_ids.child_id
|
|
|
|
await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Try to add another on same date
|
|
with pytest.raises(ValidationError, match="Parent already has a planned day"):
|
|
await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="lagsta",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
async def test_remove_planned_day(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Remove a planned day."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
|
|
await planning_service.remove_planned_day(created.id)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.get(created.id)
|
|
|
|
async def test_remove_missing_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Removing a missing planned day raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.remove_planned_day(uuid4())
|
|
|
|
async def test_get_planned_day(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Get a planned day by ID."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
|
|
fetched = await planning_service.get(created.id)
|
|
assert fetched.id == created.id
|
|
assert fetched.date == date(2026, 6, 15)
|
|
|
|
async def test_get_missing_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Getting a missing planned day raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.get(uuid4())
|
|
|
|
async def test_list_by_workspace(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""List all planned days in a workspace."""
|
|
ws = seeded_ids.workspace_id
|
|
child_id = seeded_ids.child_id
|
|
|
|
d1 = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
d2 = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="vacation",
|
|
)
|
|
|
|
result = await planning_service.list_by_workspace(ws)
|
|
ids = {d.id for d in result}
|
|
assert d1.id in ids
|
|
assert d2.id in ids
|
|
|
|
async def test_list_by_date_range(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""List planned days within a date range."""
|
|
ws = seeded_ids.workspace_id
|
|
child_id = seeded_ids.child_id
|
|
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="vacation",
|
|
)
|
|
|
|
result = await planning_service.list_by_date_range(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
start_date=date(2026, 6, 15),
|
|
end_date=date(2026, 6, 16),
|
|
)
|
|
assert len(result) == 2
|
|
|
|
async def test_list_by_date_range_empty(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Listing with no planned days in range returns empty."""
|
|
result = await planning_service.list_by_date_range(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=uuid4(),
|
|
start_date=date(2026, 6, 15),
|
|
end_date=date(2026, 6, 16),
|
|
)
|
|
assert result == []
|
|
|
|
async def test_add_planned_day_different_day_types(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Add planned days with different day types."""
|
|
ws = seeded_ids.workspace_id
|
|
child_id = seeded_ids.child_id
|
|
parent_uuid = seeded_ids.personal_data_uuid
|
|
|
|
vacation = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
lagsta = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="lagsta",
|
|
)
|
|
sjuk = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2026, 6, 17),
|
|
day_type="sjukpenning",
|
|
)
|
|
|
|
assert vacation.day_type == "vacation"
|
|
assert lagsta.day_type == "lagsta"
|
|
assert sjuk.day_type == "sjukpenning"
|
|
|
|
async def test_list_by_child(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""List all planned days for a child."""
|
|
child_id = seeded_ids.child_id
|
|
|
|
d1 = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
d2 = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="lagsta",
|
|
)
|
|
|
|
result = await planning_service.list_by_child(child_id)
|
|
ids = {d.id for d in result}
|
|
assert d1.id in ids
|
|
assert d2.id in ids
|
|
|
|
async def test_list_by_child_empty(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Listing a child with no planned days returns empty."""
|
|
result = await planning_service.list_by_child(uuid4())
|
|
assert result == []
|
|
|
|
async def test_update_planned_day_day_type(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Update the day_type of a planned day."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
day_type="lagsta",
|
|
)
|
|
assert updated.day_type == "lagsta"
|
|
assert updated.date == date(2026, 6, 15)
|
|
|
|
async def test_update_planned_day_is_dubbeldag(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Update the is_dubbeldag flag of a planned day."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
is_dubbeldag=True,
|
|
)
|
|
assert updated.is_dubbeldag is True
|
|
assert updated.day_type == "vacation"
|
|
|
|
async def test_update_planned_day_both_fields(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Update both day_type and is_dubbeldag."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=True,
|
|
)
|
|
assert updated.day_type == "sjukpenning"
|
|
assert updated.is_dubbeldag is True
|
|
|
|
async def test_update_planned_day_wish_id_and_is_pinned(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
|
|
)
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
assert created.wish_id is None
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
wish_id=wish.id,
|
|
is_pinned=True,
|
|
)
|
|
assert updated.wish_id == wish.id
|
|
assert updated.is_pinned is True
|
|
|
|
refetched = await planning_service.get(created.id)
|
|
assert refetched.wish_id == wish.id
|
|
assert refetched.is_pinned is True
|
|
|
|
async def test_update_missing_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Updating a missing planned day raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.update(
|
|
uuid4(),
|
|
day_type="lagsta",
|
|
)
|
|
|
|
async def test_add_planned_day_different_parents_same_date_allowed(
|
|
self,
|
|
planning_service: PlanningService,
|
|
personal_data_repo: SQLitePersonalDataRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Two different parents can plan the same date for the same child (dubbeldagar).
|
|
|
|
This is the legitimate dubbeldagar case: parent A has the date planned,
|
|
parent B can also plan the same date (subject to dubbeldagar validation).
|
|
Note: The test uses a non-dubbeldag date for parent A and a dubbeldag date
|
|
for parent B within the 15-month window.
|
|
"""
|
|
child_id = seeded_ids.child_id
|
|
ws = seeded_ids.workspace_id
|
|
parent_a_uuid = seeded_ids.personal_data_uuid
|
|
|
|
# Create a second parent
|
|
parent_b = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws,
|
|
name="Test Parent B",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
parent_b_uuid = parent_b.uuid
|
|
|
|
# Parent A plans 2024-10-01 (within dubbeldagar window, but not as a dubbeldag)
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_a_uuid,
|
|
date=date(2024, 10, 1),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Parent B can also plan the same date (this should succeed)
|
|
# Parent B marks this as a dubbeldag
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_b_uuid,
|
|
date=date(2024, 10, 1),
|
|
day_type="lagsta",
|
|
is_dubbeldag=True,
|
|
)
|
|
assert result.parent_personal_data_uuid == parent_b_uuid
|
|
assert result.date == date(2024, 10, 1)
|
|
assert result.is_dubbeldag is True
|
|
|
|
async def test_add_planned_day_same_parent_same_date_rejected(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Same parent cannot plan the same date twice (genuine double-booking).
|
|
|
|
This verifies that the fix for parent-aware checking still rejects
|
|
the genuine double-booking case where the same parent tries to plan
|
|
the same date twice.
|
|
"""
|
|
child_id = seeded_ids.child_id
|
|
ws = seeded_ids.workspace_id
|
|
parent_uuid = seeded_ids.personal_data_uuid
|
|
|
|
# Parent plans 2024-09-01
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Same parent tries to plan the same date again - should be rejected
|
|
with pytest.raises(ValidationError, match="already has a planned day"):
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="lagsta",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
async def test_add_planned_day_same_date_different_child_allowed(
|
|
self,
|
|
planning_service: PlanningService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Same parent can plan the same date for different children.
|
|
|
|
The double-booking check is per (parent, child), so the same parent
|
|
planning the same date for different children is always allowed.
|
|
"""
|
|
ws = seeded_ids.workspace_id
|
|
parent_uuid = seeded_ids.personal_data_uuid
|
|
child_1 = seeded_ids.child_id
|
|
|
|
# Create a second child
|
|
child_2 = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
name="Test Child 2",
|
|
birth_date=date(2024, 6, 1),
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
child_2_id = child_2.id
|
|
|
|
# Parent plans 2024-09-01 for child 1
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_1,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Same parent can plan the same date for child 2
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_2_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="lagsta",
|
|
is_dubbeldag=False,
|
|
)
|
|
assert result.child_id == child_2_id
|
|
assert result.parent_personal_data_uuid == parent_uuid
|
|
assert result.date == date(2024, 9, 1)
|