0e9b20ec37
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
9.3 KiB
Python
240 lines
9.3 KiB
Python
"""Tests for the PreschoolPlanningDays repository + service.
|
|
|
|
Follows the pattern in `test_vacation_days_repository.py`: arrange an
|
|
entity, exercise the repo/service, assert on the result.
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.entities.child import Child
|
|
from app.entities.preschool_planning_days import PreschoolPlanningDays
|
|
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
|
from app.repositories_impl.sqlite.preschool_planning_days_repository import (
|
|
SQLitePreschoolPlanningDaysRepository,
|
|
)
|
|
from app.services.preschool_planning_days_service import PreschoolPlanningDaysService
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
def _make(child_id=None, **overrides) -> PreschoolPlanningDays:
|
|
base = dict(
|
|
id=uuid4(),
|
|
child_id=child_id or uuid4(),
|
|
year=2026,
|
|
planned_days=6,
|
|
taken_days=None,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
base.update(overrides)
|
|
return PreschoolPlanningDays(**base)
|
|
|
|
|
|
class TestSQLitePreschoolPlanningDaysRepository:
|
|
async def test_create_and_get_by_id(
|
|
self,
|
|
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(child_id=seeded_ids.child_id)
|
|
await preschool_planning_days_repo.create(entity)
|
|
|
|
fetched = await preschool_planning_days_repo.get_by_id(entity.id)
|
|
assert fetched is not None
|
|
assert fetched.id == entity.id
|
|
assert fetched.planned_days == 6
|
|
assert fetched.taken_days is None
|
|
|
|
async def test_get_by_child_year(
|
|
self,
|
|
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(child_id=seeded_ids.child_id)
|
|
await preschool_planning_days_repo.create(entity)
|
|
|
|
found = await preschool_planning_days_repo.get_by_child_year(entity.child_id, 2026)
|
|
assert found is not None
|
|
assert found.id == entity.id
|
|
|
|
missing = await preschool_planning_days_repo.get_by_child_year(uuid4(), 2026)
|
|
assert missing is None
|
|
|
|
async def test_update_preserves_null_taken_days_distinction(
|
|
self,
|
|
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Not-recorded (None) must round-trip distinctly from recorded-as-zero."""
|
|
entity = _make(child_id=seeded_ids.child_id, taken_days=None)
|
|
await preschool_planning_days_repo.create(entity)
|
|
|
|
fetched = await preschool_planning_days_repo.get_by_id(entity.id)
|
|
assert fetched is not None
|
|
assert fetched.taken_days is None
|
|
|
|
fetched.taken_days = 0
|
|
updated = await preschool_planning_days_repo.update(fetched.id, fetched)
|
|
assert updated.taken_days == 0
|
|
|
|
async def test_update_missing_raises(
|
|
self, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await preschool_planning_days_repo.update(uuid4(), _make())
|
|
|
|
async def test_delete(
|
|
self,
|
|
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(child_id=seeded_ids.child_id)
|
|
await preschool_planning_days_repo.create(entity)
|
|
await preschool_planning_days_repo.delete(entity.id)
|
|
assert await preschool_planning_days_repo.get_by_id(entity.id) is None
|
|
|
|
async def test_delete_missing_raises(
|
|
self, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await preschool_planning_days_repo.delete(uuid4())
|
|
|
|
async def test_list_filters_by_child(
|
|
self,
|
|
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
|
|
seeded_ids: SeededIds,
|
|
child_repo: SQLiteChildRepository,
|
|
) -> None:
|
|
other_child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
name="Other Child",
|
|
birth_date=date(2022, 1, 1),
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
await preschool_planning_days_repo.create(_make(child_id=seeded_ids.child_id, year=2025))
|
|
await preschool_planning_days_repo.create(_make(child_id=seeded_ids.child_id, year=2026))
|
|
await preschool_planning_days_repo.create(_make(child_id=other_child.id))
|
|
|
|
rows = await preschool_planning_days_repo.list(child_id=seeded_ids.child_id)
|
|
assert len(rows) == 2
|
|
assert [r.year for r in rows] == [2026, 2025] # ordered year DESC
|
|
|
|
async def test_list_rejects_unsupported_filter(
|
|
self, preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository
|
|
) -> None:
|
|
with pytest.raises(ValueError, match="invalid_filter"):
|
|
await preschool_planning_days_repo.list(invalid_filter="value")
|
|
|
|
|
|
class TestPreschoolPlanningDaysService:
|
|
async def test_upsert_planned_creates_then_updates(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
seeded_ids: SeededIds,
|
|
child_repo: SQLiteChildRepository,
|
|
) -> None:
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
|
|
created = await preschool_planning_days_service.upsert_planned(child, 2026, 8)
|
|
assert created.planned_days == 8
|
|
assert created.taken_days is None
|
|
|
|
updated = await preschool_planning_days_service.upsert_planned(child, 2026, 10)
|
|
assert updated.id == created.id
|
|
assert updated.planned_days == 10
|
|
|
|
async def test_upsert_planned_rejects_negative(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
with pytest.raises(ValidationError):
|
|
await preschool_planning_days_service.upsert_planned(child, 2026, -1)
|
|
|
|
async def test_record_taken_seeds_default_planned_days(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""With no existing row, `planned_days` is seeded from the child's default."""
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
assert child.preschool_planning_days_per_year == 6 # entity default
|
|
|
|
recorded = await preschool_planning_days_service.record_taken(child, 2026, 4)
|
|
assert recorded.planned_days == 6
|
|
assert recorded.taken_days == 4
|
|
|
|
async def test_record_taken_preserves_planned_days_on_existing_row(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
await preschool_planning_days_service.upsert_planned(child, 2026, 12)
|
|
|
|
recorded = await preschool_planning_days_service.record_taken(child, 2026, 3)
|
|
assert recorded.planned_days == 12
|
|
assert recorded.taken_days == 3
|
|
|
|
async def test_record_taken_rejects_negative(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
with pytest.raises(ValidationError):
|
|
await preschool_planning_days_service.record_taken(child, 2026, -1)
|
|
|
|
async def test_record_taken_zero_distinguishable_from_unrecorded(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
|
|
unrecorded = await preschool_planning_days_service.get_by_child_year(child.id, 2026)
|
|
assert unrecorded is None
|
|
|
|
recorded_zero = await preschool_planning_days_service.record_taken(child, 2026, 0)
|
|
assert recorded_zero.taken_days == 0
|
|
|
|
async def test_get_missing_raises(
|
|
self, preschool_planning_days_service: PreschoolPlanningDaysService
|
|
) -> None:
|
|
with pytest.raises(NotFoundError):
|
|
await preschool_planning_days_service.get(uuid4())
|
|
|
|
async def test_list_for_child(
|
|
self,
|
|
preschool_planning_days_service: PreschoolPlanningDaysService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
child = await child_repo.get_by_id(seeded_ids.child_id)
|
|
assert child is not None
|
|
await preschool_planning_days_service.upsert_planned(child, 2025, 6)
|
|
await preschool_planning_days_service.upsert_planned(child, 2026, 6)
|
|
|
|
rows = await preschool_planning_days_service.list_for_child(child.id)
|
|
assert [r.year for r in rows] == [2026, 2025]
|