d06c8c8449
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.
335 lines
13 KiB
Python
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
|