Track preschool planning days, partner invites and real vacation forfeiture

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>
This commit is contained in:
2026-09-20 11:22:07 +02:00
parent 6904961bb4
commit 0e9b20ec37
88 changed files with 5301 additions and 348 deletions
+20 -1
View File
@@ -31,6 +31,9 @@ from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
SQLitePlannedDayExclusionRepository,
)
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.preschool_planning_days_repository import (
SQLitePreschoolPlanningDaysRepository,
)
from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork
from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository
@@ -49,6 +52,7 @@ from app.services.day_override_service import DayOverrideService
from app.services.leave_wish_service import LeaveWishService
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
from app.services.planning_service import PlanningService
from app.services.preschool_planning_days_service import PreschoolPlanningDaysService
from app.services.tips_service import TipsService
from app.services.vacation_days_service import VacationDaysService
from app.services.workspace_service import WorkspaceService
@@ -114,8 +118,9 @@ async def auth_service(
user_repo: SQLiteUserRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
invitation_repo: SQLiteWorkspaceInvitationRepository,
) -> AuthService:
return AuthService(user_repo, personal_data_repo, workspace_repo)
return AuthService(user_repo, personal_data_repo, workspace_repo, inv_repo=invitation_repo)
# ---------------------------------------------------------------------------
@@ -448,6 +453,20 @@ async def tip_dismissal_repo(
return SQLiteTipDismissalRepository(connection)
@pytest_asyncio.fixture
async def preschool_planning_days_repo(
connection: aiosqlite.Connection,
) -> SQLitePreschoolPlanningDaysRepository:
return SQLitePreschoolPlanningDaysRepository(connection)
@pytest_asyncio.fixture
async def preschool_planning_days_service(
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
) -> PreschoolPlanningDaysService:
return PreschoolPlanningDaysService(preschool_planning_days_repo)
@pytest_asyncio.fixture
async def tips_service(
parental_leave_repo: SQLiteParentalLeaveRepository,
@@ -74,6 +74,8 @@ class TestGetOverviewYearBalances:
"left",
"carried_out",
"expiring",
"expiring_age",
"expiring_over_cap",
}
assert vacation["earned"] == 25 # statutory default — no vacation_days row entered
assert vacation["is_assumed"] is True
+41
View File
@@ -641,3 +641,44 @@ class TestForeignWorkspaceIdIs404:
)
resp = await client.delete(f"/api/invitations/{inv.id}")
assert resp.status_code == 404
async def test_list_invitations_foreign_workspace(
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
) -> None:
"""`GET /{workspace_id}/invitations` is the only way to discover an
id for `DELETE /api/invitations/{id}` -- it must not leak workspace
B's invitations (or their existence) to a caller in workspace A."""
resp = await client.get(f"/api/workspace/{two_workspaces.ws_b.id}/invitations")
assert resp.status_code == 404
async def test_list_invitations_never_returns_token_hash(
self, client: AsyncClient, connection: aiosqlite.Connection, two_workspaces: _TwoWorkspaces
) -> None:
user_repo = SQLiteUserRepository(connection)
inviter = await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=_CALLER_PD_UUID,
email="inviter-a@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
inv_repo = SQLiteWorkspaceInvitationRepository(connection)
await inv_repo.create(
WorkspaceInvitation(
id=uuid4(),
workspace_id=two_workspaces.ws_a.id,
token_hash="super-secret-hash",
invited_by_user_id=inviter.id,
status="pending",
expires_at=datetime.now() + timedelta(hours=1),
created_at=datetime.now(),
)
)
resp = await client.get(f"/api/workspace/{two_workspaces.ws_a.id}/invitations")
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert "token_hash" not in body[0]
assert "super-secret-hash" not in resp.text
@@ -192,10 +192,27 @@ class TestSyncChangeKinds:
) -> None:
ws, child = seeded_ids.workspace_id, seeded_ids.child_id
# An hourly-salaried parent prefers FP (sjukpenning first) over
# vacation for tier-3 interior days, so this is deterministic
# regardless of any assumed vacation default.
# vacation for tier-3 interior days -- UNLESS §Task 2's
# vacation-forfeiture bias overrides that for a year where vacation
# would otherwise be lost. With no `vacation_days` row entered, the
# loader defaults this parent's 2027 entitlement to the statutory
# 25 days and, since nothing has been used yet, `AnalyticsService.
# _vacation_year_series` forecasts most of it as forfeited (the
# `min_vacation_days_per_year` floor caps how much can be banked)
# -- which would make this test's own assertions about sjukpenning
# flakily depend on the forfeiture bias instead of the FP-pool
# fallback it's meant to isolate. `min_vacation_days_per_year=0`
# means the ENTIRE entitlement is bankable, so the simulation
# forecasts zero forfeiture and this parent is deterministically
# FP-first, exactly like before §Task 2.
parent = await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws, name="Hourly Parent", salary_type="hourly")
PersonalData(
uuid=uuid4(),
workspace_id=ws,
name="Hourly Parent",
salary_type="hourly",
min_vacation_days_per_year=0,
)
)
# `parent_mode="specific"` -- with a second (seeded) parent now also
# in the workspace, an "either" wish would default to whichever
@@ -0,0 +1,239 @@
"""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]
@@ -0,0 +1,88 @@
"""API-level tests for `/api/preschool-planning-days`.
Uses the shared `authed_client`/`seeded_ids` fixtures (see `tests/conftest.py`)
since these routes are scoped per child within the caller's own workspace.
"""
from uuid import uuid4
from httpx import AsyncClient
from tests.conftest import SeededIds
class TestPreschoolPlanningDaysRouter:
async def test_get_year_with_no_row_returns_child_default(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
resp = await authed_client.get(f"/api/preschool-planning-days/{seeded_ids.child_id}/2026")
assert resp.status_code == 200
body = resp.json()
assert body["is_default"] is True
assert body["id"] is None
assert body["planned_days"] == 6 # Child entity default
assert body["taken_days"] is None
async def test_upsert_planned_then_record_taken(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
put_planned = await authed_client.put(
f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/planned",
json={"planned_days": 8},
)
assert put_planned.status_code == 200
body = put_planned.json()
assert body["is_default"] is False
assert body["planned_days"] == 8
assert body["taken_days"] is None
put_taken = await authed_client.put(
f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/taken",
json={"taken_days": 5},
)
assert put_taken.status_code == 200
taken_body = put_taken.json()
assert taken_body["id"] == body["id"]
assert taken_body["planned_days"] == 8 # preserved
assert taken_body["taken_days"] == 5
async def test_record_taken_zero_distinguishable_from_default_none(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
resp = await authed_client.put(
f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/taken",
json={"taken_days": 0},
)
assert resp.status_code == 200
assert resp.json()["taken_days"] == 0
assert resp.json()["is_default"] is False
async def test_list_years_for_child(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
await authed_client.put(
f"/api/preschool-planning-days/{seeded_ids.child_id}/2025/planned",
json={"planned_days": 6},
)
await authed_client.put(
f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/planned",
json={"planned_days": 7},
)
resp = await authed_client.get(f"/api/preschool-planning-days/{seeded_ids.child_id}")
assert resp.status_code == 200
years = [row["year"] for row in resp.json()]
assert years == [2026, 2025]
async def test_unknown_child_returns_404(self, authed_client: AsyncClient) -> None:
resp = await authed_client.get(f"/api/preschool-planning-days/{uuid4()}/2026")
assert resp.status_code == 404
async def test_rejects_negative_planned_days(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
resp = await authed_client.put(
f"/api/preschool-planning-days/{seeded_ids.child_id}/2026/planned",
json={"planned_days": -1},
)
assert resp.status_code == 422
@@ -19,6 +19,7 @@ from app.entities.leave_wish import LeaveWish
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.preschool_planning_days import PreschoolPlanningDays
from app.entities.vacation_days import VacationDays
from app.entities.workspace import Workspace
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
@@ -27,6 +28,9 @@ from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRe
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.preschool_planning_days_repository import (
SQLitePreschoolPlanningDaysRepository,
)
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.allocation_service import AllocationInputLoader, Allocator, PlanningEngineService
@@ -41,6 +45,7 @@ def _make_loader(
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository | None = None,
) -> AllocationInputLoader:
return AllocationInputLoader(
workspace_repo=workspace_repo,
@@ -50,6 +55,7 @@ def _make_loader(
vacation_repo=vacation_repo,
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
preschool_planning_days_repo=preschool_planning_days_repo,
)
@@ -626,3 +632,118 @@ class TestAllocationInputLoader:
with pytest.raises(ValidationError):
await loader.load(ws.id, today=date(2027, 1, 1))
class TestPreschoolPlanningDaysReserve:
"""§Task 1 — `AllocationInputLoader` sources the per-year preschool
reserve from `PreschoolPlanningDaysRepository` rows instead of the flat
`preschool_planning_days_per_year * years` lump sum, using recorded
`taken_days` in preference to `planned_days`, and leaving a (child,
year) pair with no row out of the dict entirely — never defaulting it
to 0 — so `_preschool_reserve_days` falls back to `ChildFacts.
preschool_planning_days_per_year` for it (see that field's docstring).
"""
async def test_recorded_taken_days_wins_over_planned_days(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
) -> None:
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date(2025, 1, 1),
preschool_start_date=date(2027, 1, 1),
preschool_planning_days_per_year=6,
created_at=now,
)
)
# Recorded: planned 6, but only 3 actually taken -> 3 must win.
await preschool_planning_days_repo.create(
PreschoolPlanningDays(
id=uuid4(), child_id=child.id, year=2027, planned_days=6, taken_days=3
)
)
loader = _make_loader(
workspace_repo,
child_repo,
personal_data_repo,
parental_leave_repo,
vacation_repo,
planned_leave_repo,
leave_wish_repo,
preschool_planning_days_repo=preschool_planning_days_repo,
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
assert alloc_input.preschool_reserve_by_year[(child.id, 2027)] == 3
async def test_missing_row_is_absent_not_zero(
self,
workspace_repo: SQLiteWorkspaceRepository,
child_repo: SQLiteChildRepository,
personal_data_repo: SQLitePersonalDataRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository,
) -> None:
now = datetime.now()
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await personal_data_repo.create(
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date(2025, 1, 1),
preschool_start_date=date(2027, 1, 1),
preschool_planning_days_per_year=6,
created_at=now,
)
)
# No `preschool_planning_days` row created for (child, 2027) at all.
loader = _make_loader(
workspace_repo,
child_repo,
personal_data_repo,
parental_leave_repo,
vacation_repo,
planned_leave_repo,
leave_wish_repo,
preschool_planning_days_repo=preschool_planning_days_repo,
)
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
# Absent, not zero -- `_preschool_reserve_days` is what supplies the
# child-default fallback (6/year), never this dict directly.
assert (child.id, 2027) not in alloc_input.preschool_reserve_by_year
from app.services.allocation_service import _preschool_reserve_days
reserve = _preschool_reserve_days(
next(c for c in alloc_input.children if c.id == child.id),
alloc_input.today,
date(2027, 12, 31),
alloc_input.preschool_reserve_by_year,
)
assert reserve == 6
@@ -182,6 +182,8 @@ def _build_input(
age4_savable_cap: int | None = None,
take_pl_on_red_days: bool = False,
excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None,
preschool_reserve_by_year: dict[tuple[UUID, int], int] | None = None,
vacation_forfeiting: dict[tuple[UUID, int], int] | None = None,
) -> AllocationInput:
years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years}
if age4_savable_cap is None:
@@ -206,6 +208,8 @@ def _build_input(
take_pl_on_red_days=take_pl_on_red_days,
all_existing_planned=all_existing_planned,
excluded_days=excluded_days or frozenset(),
preschool_reserve_by_year=preschool_reserve_by_year or {},
vacation_forfeiting=vacation_forfeiting or {},
)
@@ -322,6 +322,12 @@ class TestWarnings:
assert sgi_warnings[0].data["days_planned"] == 3
def test_vacation_expiring_warning(self) -> None:
# §Task 2 — `_vacation_expiry_warnings` now surfaces the real,
# loader-computed `vacation_forfeiting` figure (same FIFO
# carry-forward simulation as the Analytics tab) instead of the old
# `has_vacation_near_expiry` heuristic, so a hand-built pure
# `AllocationInput` supplies that figure directly rather than a
# `VacationBudget` shape the heuristic used to infer it from.
parent = _parent()
alloc_input = _build_input(
today=date(2027, 1, 1),
@@ -329,15 +335,87 @@ class TestWarnings:
children=[],
parents=[parent],
pools={},
vacation={(parent.uuid, 2026): VacationBudget(25, 8, 0)},
vacation={(parent.uuid, 2027): VacationBudget(25, 8, 0)},
wishes=[],
vacation_forfeiting={(parent.uuid, 2027): 5},
)
proposal = Allocator().propose(alloc_input)
expiry_warnings = [w for w in proposal.warnings if w.kind == "vacation_expiring"]
assert len(expiry_warnings) == 1
assert expiry_warnings[0].data["saved_days_in"] == 8
assert expiry_warnings[0].data["expiring_days"] == 5
assert expiry_warnings[0].data["year"] == 2027
class TestVacationForfeitureBias:
"""§Task 2 — `_assign_tier3` spends vacation before FP in a year where
`AllocationInput.vacation_forfeiting` says the vacation would otherwise
be lost anyway, even for an hourly-salaried parent (whose default
tier-3 order is FP-first, since a vacation day normally costs them
pay). February 2027 is used as the funded window: a clean month (no
real red days near its edges, per this file's module docstring), so
every non-free day goes through tier-3 with nothing else interfering.
"""
def test_forfeiting_year_spends_vacation_before_fp(self) -> None:
child = _child(birth_date=date(2025, 1, 1))
parent = _parent(salary_type="hourly")
wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
vacation_forfeiting={(parent.uuid, 2027): 5},
)
proposal = Allocator().propose(alloc_input)
interior_days = [
d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta")
]
assert interior_days, "expected some funded interior (non-free) days"
assert all(d.day_type == "vacation" for d in interior_days)
# The FP pool must be untouched: every interior day was paid from
# vacation instead, exactly the point of the bias (bank the FP days
# for a later year rather than let this year's vacation go to waste).
balances_after = proposal.balances_after.pools_after[(parent.uuid, child.id)]
assert balances_after.sjukpenning_left == 90
assert balances_after.lagsta_left == 90
def test_non_forfeiting_year_is_unchanged_fp_first_for_hourly_parent(self) -> None:
"""Regression guard: absent a forecast forfeiture, an hourly parent's
tier-3 order must stay exactly what it was before §Task 2 — FP
first, vacation only backstopping what FP can't cover.
"""
child = _child(birth_date=date(2025, 1, 1))
parent = _parent(salary_type="hourly")
wish = _wish(child_id=child.id, start=FEB_2027[0], end=FEB_2027[1], weeks=4)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(90, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
)
proposal = Allocator().propose(alloc_input)
interior_days = [
d for d in proposal.days if d.day_type in ("vacation", "sjukpenning", "lagsta")
]
assert interior_days, "expected some funded interior (non-free) days"
assert all(d.day_type == "sjukpenning" for d in interior_days)
balances_after = proposal.balances_after.vacation_after[(parent.uuid, 2027)]
assert balances_after.already_used == 0
class TestRemedies:
@@ -732,7 +732,14 @@ class TestVacationYearBalanceSeries:
assert first.carried_in == 0
assert first.left == 25
assert first.carried_out == 5 # max(25 - floor(20), 0)
assert first.expiring == 0
# `expiring` now reports EVERY lost day, not just age-based losses
# (the bug this predicate/service pair fixes): of the 25 unused
# days, only 5 (earned - floor) can be banked — the other 20 (the
# unused floor) are forfeited outright, with no age-expiry involved
# yet in year one.
assert first.expiring == 20
assert first.expiring_age == 0
assert first.expiring_over_cap == 20
def test_carry_chain_accumulates_across_years(self) -> None:
years = [2025, 2026, 2027]
@@ -756,10 +763,14 @@ class TestVacationYearBalanceSeries:
# 2020's 5 saved days must not still be sitting in `carried_in`
# `carry_years` years later, and must have been reported as
# `expiring` exactly once, in the year they lapsed.
expiring_years = [y for y, r in by_year.items() if r.expiring > 0]
assert expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring == 5
# `expiring_age` exactly once, in the year they lapsed. (`expiring`
# itself is nonzero every year here too, since the unused floor is
# an over-cap loss every year — isolate the age-based portion.)
age_expiring_years = [y for y, r in by_year.items() if r.expiring_age > 0]
assert age_expiring_years == [2020 + carry_years]
assert by_year[2020 + carry_years].expiring_age == 5
assert by_year[2020 + carry_years].expiring_over_cap == 20
assert by_year[2020 + carry_years].expiring == 25
def test_continuous_saving_expires_the_oldest_batch_every_year_thereafter(self) -> None:
"""Once the bank has been running >= carry_years, an unbroken
@@ -775,7 +786,9 @@ class TestVacationYearBalanceSeries:
by_year = {r.year: r for r in result}
for year in years[carry_years:]:
assert by_year[year].expiring == 5
assert by_year[year].expiring_age == 5
assert by_year[year].expiring_over_cap == 20 # unused floor, every year
assert by_year[year].expiring == 25
# Carried-in plateaus at exactly carry_years batches of 5.
assert by_year[years[-1]].carried_in == 5 * carry_years
@@ -825,6 +838,53 @@ class TestVacationYearBalanceSeries:
# 2026's row must not wipe that computed carry-in out.
assert by_year[2026].carried_in == 5
def test_expiring_breakdown_matches_dashboard_screenshot_case(self) -> None:
"""Real dashboard bug report: earned 30, floor 20, planned_use 15 →
left 15, carried_out 10 — 5 days genuinely lost, but the OLD
age-only `expiring` showed 0. `expiring` must report every lost day,
with `expiring_over_cap` isolating the over-cap portion."""
from app.entities.vacation_days import VacationDays
years = [2025]
rows = {
2025: VacationDays(
id=uuid4(),
personal_data_uuid=uuid4(),
workspace_id=uuid4(),
year=2025,
total_days=30,
saved_days_in=0,
),
}
use_by_year = {2025: 15}
result = AnalyticsService._vacation_year_series(years, rows, use_by_year, floor=20)
balance = result[0]
assert balance.left == 15
assert balance.carried_out == 10
assert balance.expiring == 5
assert balance.expiring_age == 0
assert balance.expiring_over_cap == 5
def test_age_expiry_and_over_cap_loss_coincide_without_double_counting(self) -> None:
"""A bank entry aging out at the 5-year cap and a fresh over-cap
loss can both happen in the same year (earned 25, floor 20, never
used: every year loses the unused floor AND, from year
`carry_years` on, also ages out that year's oldest 5-day batch).
`expiring` must equal their sum exactly once, never double-counted.
"""
from app.config import settings
carry_years = settings.rules.vacation_carry_forward_years
years = list(range(2020, 2020 + carry_years + 1))
result = AnalyticsService._vacation_year_series(years, {}, {}, floor=20)
last = result[-1]
assert last.expiring_age == 5
assert last.expiring_over_cap == 20
assert last.expiring == 25
assert last.expiring == last.expiring_age + last.expiring_over_cap
class TestParentalLeaveYearBalanceSeries:
"""Pure unit tests against `AnalyticsService._pl_year_series`."""
@@ -1,10 +1,17 @@
"""Tests for AuthService: register, login, token round-trip."""
from datetime import datetime
from uuid import uuid4
import pytest
from app.entities.user import User
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.auth_service import AuthService
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
from app.services.workspace_service import WorkspaceService
from app.utils.errors import AuthError, ConflictError, InvitationError, UnauthorizedError
class TestAuthServiceRegisterLogin:
@@ -104,3 +111,126 @@ class TestAuthServiceTokens:
with pytest.raises(UnauthorizedError):
await auth_service.get_user_from_token(token)
class TestAuthServiceRegisterWithInviteToken:
"""`register(..., invite_token=...)` lets a partner join straight from an
invite link instead of first creating a throwaway solo workspace. Mirrors
`WorkspaceService.redeem_invitation`'s claim logic -- see
`test_workspace_service.py::TestInvitationScoping` for the service-level
coverage of that logic; these tests cover the registration entry point.
"""
async def test_claims_existing_placeholder(
self,
auth_service: AuthService,
workspace_service: WorkspaceService,
user_repo: SQLiteUserRepository,
) -> None:
ws = await workspace_service.create_workspace("Shared plan")
owner_pd = await workspace_service.add_placeholder_parent(ws.id, "Owner")
owner_user = await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=owner_pd.uuid,
email="owner@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
placeholder = await workspace_service.add_placeholder_parent(ws.id, "Partner")
token = await workspace_service.invite_partner(ws.id, owner_user.id)
user, pd = await auth_service.register(
"partner@example.com", "password123", "Partner Real Name", invite_token=token
)
assert pd.uuid == placeholder.uuid
assert pd.name == "Partner Real Name"
assert user.personal_data_uuid == placeholder.uuid
members = await workspace_service.list_members(ws.id)
assert {m.name for m in members} == {"Owner", "Partner Real Name"}
async def test_joins_free_slot_when_no_placeholder_exists(
self,
auth_service: AuthService,
workspace_service: WorkspaceService,
user_repo: SQLiteUserRepository,
) -> None:
ws = await workspace_service.create_workspace("Shared plan")
owner_pd = await workspace_service.add_placeholder_parent(ws.id, "Owner")
owner_user = await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=owner_pd.uuid,
email="owner2@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
token = await workspace_service.invite_partner(ws.id, owner_user.id)
user, pd = await auth_service.register(
"joiner@example.com", "password123", "Joiner", invite_token=token
)
assert pd.workspace_id == ws.id
assert user.personal_data_uuid == pd.uuid
members = await workspace_service.list_members(ws.id)
assert {m.name for m in members} == {"Owner", "Joiner"}
async def test_invalid_token_raises_invitation_error(self, auth_service: AuthService) -> None:
with pytest.raises(InvitationError, match="not found"):
await auth_service.register(
"nope@example.com", "password123", "Nope", invite_token="bogus-token"
)
async def test_refuses_when_both_slots_have_real_logins(
self,
auth_service: AuthService,
workspace_service: WorkspaceService,
user_repo: SQLiteUserRepository,
) -> None:
ws = await workspace_service.create_workspace("Shared plan")
owner_pd = await workspace_service.add_placeholder_parent(ws.id, "Owner")
owner_user = await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=owner_pd.uuid,
email="owner3@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
second_pd = await workspace_service.add_placeholder_parent(ws.id, "Partner")
await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=second_pd.uuid,
email="partner3@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
token = await workspace_service.invite_partner(ws.id, owner_user.id)
with pytest.raises(InvitationError, match="maximum number of members"):
await auth_service.register(
"late@example.com", "password123", "Late", invite_token=token
)
async def test_raises_when_invitations_not_configured(
self,
user_repo: SQLiteUserRepository,
personal_data_repo: SQLitePersonalDataRepository,
workspace_repo: SQLiteWorkspaceRepository,
) -> None:
"""A direct-construction call site (e.g. a script) that never wires
`inv_repo` gets a clear error rather than a crash if it's ever
handed an invite token."""
bare_auth_service = AuthService(user_repo, personal_data_repo, workspace_repo)
with pytest.raises(InvitationError, match="not available"):
await bare_auth_service.register(
"x@example.com", "password123", "X", invite_token="whatever"
)
@@ -15,6 +15,7 @@ from app.entities.leave_wish import LeaveWish
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.services.analytics_service import VacationYearBalance
from app.services.tips_service import (
TipsContext,
_rule_age4_pooling_loss,
@@ -25,6 +26,7 @@ from app.services.tips_service import (
_rule_reserve_for_preschool_planning_days,
_rule_stretch_time_at_home,
_rule_use_dubbeldagar_strategically,
_rule_use_vacation_before_expiry,
_rule_wrap_red_days_with_vacation,
_rule_wrap_weekend_with_vacation,
)
@@ -159,6 +161,7 @@ def _make_ctx(
vacation_by_parent: dict | None = None,
leave_wishes: list | None = None,
today: date | None = None,
vacation_year_balance_by_parent: dict | None = None,
) -> TipsContext:
ws = workspace_id or uuid4()
return TipsContext(
@@ -170,6 +173,31 @@ def _make_ctx(
vacation_by_parent=vacation_by_parent or {},
leave_wishes=leave_wishes or [],
today=today or date.today(),
vacation_year_balance_by_parent=vacation_year_balance_by_parent or {},
)
def _make_vacation_balance(
year: int = 2026,
earned: int = 25,
carried_in: int = 0,
planned_use: int = 0,
left: int = 25,
carried_out: int = 5,
expiring_age: int = 0,
expiring_over_cap: int = 0,
) -> VacationYearBalance:
return VacationYearBalance(
year=year,
earned=earned,
is_assumed=False,
carried_in=carried_in,
planned_use=planned_use,
left=left,
carried_out=carried_out,
expiring=expiring_age + expiring_over_cap,
expiring_age=expiring_age,
expiring_over_cap=expiring_over_cap,
)
@@ -181,6 +209,79 @@ def _next_weekday(target_weekday: int, offset_days: int = 1) -> date:
return d
# ---------------------------------------------------------------------------
# _rule_use_vacation_before_expiry
# ---------------------------------------------------------------------------
class TestUseVacationBeforeExpiry:
async def test_fires_on_genuine_forfeiture(self):
ws = uuid4()
parent = _make_parent(workspace_id=ws, name="Alice")
balance = _make_vacation_balance(
year=2026, left=15, carried_out=10, expiring_age=0, expiring_over_cap=5
)
ctx = _make_ctx(
workspace_id=ws,
parents=[parent],
vacation_year_balance_by_parent={parent.uuid: balance},
)
tip = await _rule_use_vacation_before_expiry(ctx)
assert tip is not None
assert tip.id == "use_vacation_before_expiry"
assert tip.data["parent_name"] == "Alice"
assert tip.data["lost_days"] == 5
assert tip.data["year"] == 2026
assert tip.anchor_date == date(2026, 12, 31)
async def test_fires_when_only_age_expiry_is_lost(self):
ws = uuid4()
parent = _make_parent(workspace_id=ws)
balance = _make_vacation_balance(expiring_age=5, expiring_over_cap=0)
ctx = _make_ctx(
workspace_id=ws,
parents=[parent],
vacation_year_balance_by_parent={parent.uuid: balance},
)
tip = await _rule_use_vacation_before_expiry(ctx)
assert tip is not None
assert tip.data["lost_days"] == 5
async def test_no_fire_when_nothing_is_lost(self):
ws = uuid4()
parent = _make_parent(workspace_id=ws)
balance = _make_vacation_balance(expiring_age=0, expiring_over_cap=0)
ctx = _make_ctx(
workspace_id=ws,
parents=[parent],
vacation_year_balance_by_parent={parent.uuid: balance},
)
tip = await _rule_use_vacation_before_expiry(ctx)
assert tip is None
async def test_no_fire_when_parent_has_no_balance(self):
ws = uuid4()
parent = _make_parent(workspace_id=ws)
ctx = _make_ctx(workspace_id=ws, parents=[parent], vacation_year_balance_by_parent={})
tip = await _rule_use_vacation_before_expiry(ctx)
assert tip is None
async def test_no_fire_for_a_past_year_even_with_forfeiture(self):
"""Days already lost in a past year are gone — warning about them
is noise the user shouldn't see, regardless of how much was lost."""
ws = uuid4()
parent = _make_parent(workspace_id=ws)
balance = _make_vacation_balance(year=2024, expiring_age=0, expiring_over_cap=20)
ctx = _make_ctx(
workspace_id=ws,
parents=[parent],
vacation_year_balance_by_parent={parent.uuid: balance},
today=date(2026, 1, 15),
)
tip = await _rule_use_vacation_before_expiry(ctx)
assert tip is None
# ---------------------------------------------------------------------------
# _rule_balance_between_parents
# ---------------------------------------------------------------------------
+108 -3
View File
@@ -410,14 +410,77 @@ class TestInvitationScoping:
with pytest.raises(InvitationError, match="already belong"):
await workspace_service.redeem_invitation(token, seeded_ids.personal_data_uuid)
async def test_redeem_refuses_when_workspace_already_has_two_members(
async def test_redeem_claims_placeholder_instead_of_refusing_when_full_of_a_placeholder(
self,
workspace_service: WorkspaceService,
seeded_ids: SeededIds,
user_repo: SQLiteUserRepository,
) -> None:
# Fill the target workspace to its 2-member cap first.
await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Partner")
"""Regression for "the second parent can't log in": the target
workspace's second slot is commonly a placeholder auto-created by the
onboarding wizard (`add_placeholder_parent`), not a genuinely full
workspace. Redemption must CLAIM that placeholder -- re-pointing the
redeemer's login onto it and preserving whatever plan data the
placeholder already holds -- rather than refusing with "maximum
number of members", which used to make the entire invite happy path
impossible (see `WorkspaceService.redeem_invitation` docstring)."""
placeholder = await workspace_service.add_placeholder_parent(
seeded_ids.workspace_id, "Partner"
)
other_ws = await workspace_service.create_workspace("Other WS")
redeemer_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer")
redeemer_user = await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=redeemer_pd.uuid,
email="redeemer@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
token = await workspace_service.invite_partner(
seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid)
)
inv = await workspace_service.redeem_invitation(token, redeemer_pd.uuid)
assert inv.status == "accepted"
# Login re-pointed onto the placeholder, not left on the (now
# discarded) auto-created profile.
refreshed_user = await user_repo.get_by_id(redeemer_user.id)
assert refreshed_user is not None
assert refreshed_user.personal_data_uuid == placeholder.uuid
# The placeholder took over the redeemer's already-chosen name.
members = await workspace_service.list_members(seeded_ids.workspace_id)
assert {m.name for m in members} == {"Test Parent", "Redeemer"}
# The redeemer's own auto-created profile is gone -- not left behind
# as a second, orphaned membership of the target workspace.
assert await workspace_service._pd.get_by_id(redeemer_pd.uuid) is None
async def test_redeem_refuses_when_both_slots_have_real_logins(
self,
workspace_service: WorkspaceService,
seeded_ids: SeededIds,
user_repo: SQLiteUserRepository,
) -> None:
"""Unlike the placeholder case above, a workspace whose second slot
is a real, logged-in member has genuinely no room -- redemption must
still be refused."""
second_member = await workspace_service.add_placeholder_parent(
seeded_ids.workspace_id, "Partner"
)
await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=second_member.uuid,
email="partner@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
other_ws = await workspace_service.create_workspace("Other WS")
other_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer")
@@ -427,3 +490,45 @@ class TestInvitationScoping:
)
with pytest.raises(InvitationError, match="maximum number of members"):
await workspace_service.redeem_invitation(token, other_pd.uuid)
async def test_redeem_refuses_claim_when_redeemer_has_dependent_plan_data(
self,
workspace_service: WorkspaceService,
seeded_ids: SeededIds,
user_repo: SQLiteUserRepository,
vacation_repo: SQLiteVacationDaysRepository,
) -> None:
"""A redeemer who already entered plan data under their own profile
cannot be safely claimed onto the placeholder: `vacation_days` (and
siblings) denormalize `workspace_id` alongside `personal_data_uuid`,
and no repository method exists to repoint that column -- silently
discarding or merging it would lose or corrupt data, so this is
refused instead."""
await workspace_service.add_placeholder_parent(seeded_ids.workspace_id, "Partner")
other_ws = await workspace_service.create_workspace("Other WS")
redeemer_pd = await workspace_service.add_placeholder_parent(other_ws.id, "Redeemer")
await user_repo.create(
User(
id=uuid4(),
personal_data_uuid=redeemer_pd.uuid,
email="redeemer2@example.com",
hashed_password="not-a-real-hash",
created_at=datetime.now(),
)
)
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=redeemer_pd.uuid,
workspace_id=other_ws.id,
year=2026,
total_days=25,
)
)
token = await workspace_service.invite_partner(
seeded_ids.workspace_id, await _make_inviter(user_repo, seeded_ids.personal_data_uuid)
)
with pytest.raises(InvitationError, match="vacation_days"):
await workspace_service.redeem_invitation(token, redeemer_pd.uuid)