Files
F-r-ldraledigplannerare/backend/tests/unit/test_planning_service.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

490 lines
17 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.personal_data import PersonalData
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
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
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_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_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)