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

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

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

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

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)