Files
F-r-ldraledigplannerare/backend/tests/unit/test_planning_service_vacation.py
T
jacobh2 d06c8c8449 Check vacation days against the entitlement before persisting them
Applied batches were validated against the remaining föräldrapenning tiers
but never against semester. That held only while the allocator was the sole
writer, since its ledger spends vacation against a per-year budget. The
calendar is about to let a user force any day to semester and pin it, which
writes straight past that ledger, so nothing would have stopped forty pinned
vacation days against a twenty-five day entitlement.

The check mirrors the allocator's own derivation rather than restating it:
available days are total plus days saved in, and a year with no entered row
falls back to the statutory entitlement instead of reading as zero. The
semesterlagen floor stays out of it, being a bound on what may be carried
forward rather than a cap on what may be spent. Vacation is budgeted per
calendar year, so a period crossing New Year is charged to both.
2026-09-17 18:22:55 +02:00

335 lines
13 KiB
Python

"""Tests for `PlanningService`'s vacation-day balance validation.
Closes the gap where `_validate_remaining_balance` checked `sjukpenning`/
`lagsta` but never `vacation`, letting a caller persist unlimited vacation
days. Vacation is budgeted per (parent, calendar year) -- not per (parent,
child) like the FP tiers -- and mirrors `AllocationInputLoader.load`'s
`VacationBudget` derivation in `allocation_service.py`:
available = total_days + saved_days_in - already_used
with a year that has no `VacationDays` row defaulting `total_days` to
`rules.statutory_vacation_days_per_year` rather than zero ("not entered"
must never be read as "no entitlement").
"""
from datetime import date, datetime, timedelta
from uuid import uuid4
from app.config import settings
from app.entities.vacation_days import VacationDays
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.services.planning_service import PlanningService, ProposedDayInput
from tests.conftest import SeededIds
def _make_vacation(
personal_data_uuid,
workspace_id,
year: int,
total_days: int,
saved_days_in: int = 0,
) -> VacationDays:
now = datetime.now()
return VacationDays(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
year=year,
total_days=total_days,
saved_days_in=saved_days_in,
created_at=now,
updated_at=now,
)
class TestVacationBalanceWithEnteredEntitlement:
async def test_vacation_within_entitlement_accepted(
self,
planning_service: PlanningService,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5))
days = [ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 6)]
result = await planning_service.apply_proposed_days(ws, days)
assert result.accepted is True, result.rejections
assert result.created_count == 5
async def test_vacation_beyond_entitlement_rejected_whole(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5))
days = [
ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 7)
] # 6 days, only 5 entitled
result = await planning_service.apply_proposed_days(ws, days)
assert result.accepted is False
assert any(
r.message_key == "planApply.rejection.insufficientVacationBalance"
and r.data["year"] == 2026
and r.data["parent_personal_data_uuid"] == str(parent)
and r.data["needed"] == 6
and r.data["remaining"] == 5
for r in result.rejections
)
rows = await planned_leave_repo.list_by_workspace_child(
workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30)
)
assert rows == [], "a rejected batch must write nothing"
class TestVacationBalanceWithNoEnteredYear:
async def test_missing_year_defaults_to_statutory_entitlement_not_zero(
self,
planning_service: PlanningService,
seeded_ids: SeededIds,
) -> None:
"""No `VacationDays` row for 2026 at all -- must fall back to
`rules.statutory_vacation_days_per_year`, never to zero.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
statutory = settings.rules.statutory_vacation_days_per_year
exactly_statutory = [
ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "vacation")
for i in range(statutory)
]
result = await planning_service.apply_proposed_days(ws, exactly_statutory)
assert result.accepted is True, result.rejections
async def test_missing_year_one_day_over_statutory_default_rejected(
self,
planning_service: PlanningService,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
statutory = settings.rules.statutory_vacation_days_per_year
one_too_many = [
ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "vacation")
for i in range(statutory + 1)
]
result = await planning_service.apply_proposed_days(ws, one_too_many)
assert result.accepted is False
assert any(
r.message_key == "planApply.rejection.insufficientVacationBalance"
and r.data["year"] == 2026
for r in result.rejections
)
class TestVacationBalanceCountsSavedDaysIn:
"""5 this year's entitlement + 3 carried forward from prior years = 8
available. Each test starts from a fresh (unspent) budget -- unlike
`TestVacationBalanceWithEnteredEntitlement` above, these two are kept
independent (not one accept-then-reject sequence in a single test)
specifically so the second assertion's `remaining` reflects the
*original* 8-day budget, not 8 minus whatever a prior call in the same
test already persisted.
"""
async def test_exactly_available_including_saved_days_in_accepted(
self,
planning_service: PlanningService,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5, saved_days_in=3))
exactly_available = [
ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 9)
]
result = await planning_service.apply_proposed_days(ws, exactly_available)
assert result.accepted is True, result.rejections
assert result.created_count == 8
async def test_one_more_than_saved_days_in_allows_rejected(
self,
planning_service: PlanningService,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5, saved_days_in=3))
one_more = [
ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 10)
]
result_over = await planning_service.apply_proposed_days(ws, one_more)
assert result_over.accepted is False
assert any(
r.message_key == "planApply.rejection.insufficientVacationBalance"
and r.data["needed"] == 9
and r.data["remaining"] == 8
for r in result_over.rejections
)
class TestVacationBalanceDeleteKeysCredit:
async def test_delete_and_readd_same_count_same_year_accepted(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
"""A tight 1-day budget already fully spent -- adding one more
vacation day would be rejected outright, but replacing (delete +
re-add, same count, same year) must be accepted because the deletion
is credited back before the new demand is checked.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=1))
old = await planning_service.add_planned_day(
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=date(2026, 6, 1),
day_type="vacation",
)
# Sanity: with the budget already fully spent, a bare addition (no
# delete) of one more vacation day in the same year is rejected.
bare_addition = [ProposedDayInput(date(2026, 6, 2), parent, child, "vacation")]
blocked = await planning_service.apply_proposed_days(ws, bare_addition)
assert blocked.accepted is False
assert any(
r.message_key == "planApply.rejection.insufficientVacationBalance"
for r in blocked.rejections
)
new_days = [ProposedDayInput(date(2026, 6, 2), parent, child, "vacation")]
delete_keys = {(child, parent, date(2026, 6, 1))}
result = await planning_service.apply_proposed_days(ws, new_days, delete_keys=delete_keys)
assert result.accepted is True, result.rejections
assert result.deleted_count == 1
assert result.created_count == 1
rows = await planned_leave_repo.list_by_workspace_child(
workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30)
)
assert {r.date for r in rows} == {date(2026, 6, 2)}
assert old.id not in {r.id for r in rows}
class TestVacationBalanceNewYearWrap:
async def test_wrapping_period_charges_each_year_independently(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
"""A Jullov-shaped period (20 Dec - 16 Jan) spans two vacation-year
budgets. 2026 has ample room (12 days needed, 15 entitled); 2027 is
deliberately too tight (16 days needed, only 10 entitled) -- the
whole batch must be rejected, and the rejection must name 2027, not
2026 or a summed total across both years.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=15))
await vacation_repo.create(_make_vacation(parent, ws, 2027, total_days=10))
dec_days = [
ProposedDayInput(date(2026, 12, d), parent, child, "vacation") for d in range(20, 32)
] # 12 days
jan_days = [
ProposedDayInput(date(2027, 1, d), parent, child, "vacation") for d in range(1, 17)
] # 16 days
batch = dec_days + jan_days
result = await planning_service.apply_proposed_days(ws, batch)
assert result.accepted is False
rejections_by_year = {
r.data["year"]: r
for r in result.rejections
if r.message_key == "planApply.rejection.insufficientVacationBalance"
}
assert 2027 in rejections_by_year
assert rejections_by_year[2027].data["needed"] == 16
assert rejections_by_year[2027].data["remaining"] == 10
assert 2026 not in rejections_by_year, "2026 has room and must not be rejected"
rows = await planned_leave_repo.list_by_workspace_child(
workspace_id=ws, child_id=child, from_date=date(2026, 12, 1), to_date=date(2027, 1, 31)
)
assert rows == [], "a rejected batch must write nothing, including the in-budget year"
async def test_wrapping_period_accepted_when_both_years_have_room(
self,
planning_service: PlanningService,
vacation_repo: SQLiteVacationDaysRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=15))
await vacation_repo.create(_make_vacation(parent, ws, 2027, total_days=20))
dec_days = [
ProposedDayInput(date(2026, 12, d), parent, child, "vacation") for d in range(20, 32)
]
jan_days = [
ProposedDayInput(date(2027, 1, d), parent, child, "vacation") for d in range(1, 17)
]
batch = dec_days + jan_days
result = await planning_service.apply_proposed_days(ws, batch)
assert result.accepted is True, result.rejections
assert result.created_count == 28