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.
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user