Fix flexible-wish double-booking, extend calendar cache to 2055, add journey tests
Second real bug found by driving the running app while the suites were green. Flexible wishes were funded twice across propose -> apply -> re-propose. Because planned_leave_day carries no wish_id, the engine could not tell that applied days were that wish's own prior occurrence; it treated them as occupied space, routed the candidate search around them, and funded the year again on shifted dates while reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18 paid days instead of 9. Fixed wishes were unaffected and stay unaffected. Fix (allocation_service.py), per the approved approach of counting already-applied days inside the bounding range rather than adding a wish_id column: - _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its own freshly-computed windows, so a fixed wish's new placement is never mistaken for a flexible wish's history - _reuse_already_funded_window finds this wish's persisted dates within its bound and reuses the window containing them, via the ordinary fund_window path - full funding therefore yields zero new days and partial funding tops up exactly the remainder, with no duplication and no false shortfall - with no persisted dates, or if the reused window is no longer collision-free, it falls through to the original search, so an unsatisfied year is never frozen Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator switched to strict, so an uncached year raises UncachedYearError -> ValidationError -> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as working days. Also fixed normalize_day dropping the second name when two holidays share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red days, not 18: Pingstdagen coincides with Nationaldagen. New backend/tests/integration/test_user_journey.py drives the real app over HTTP across seven journeys: the double-booking guard, the fixed-wish control, apply idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish text reaches the wire now that everything is message_key + data. New backend/scripts/seed_demo.py seeds a realistic workspace through the real service layer for manual testing: demo@example.com / DemoPass123! Backend 414 -> 435 tests. Frontend unchanged at 705. Still not verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from app.entities.child import Child
|
||||
from app.entities.day_override import DayOverride
|
||||
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
|
||||
@@ -29,7 +30,7 @@ from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedL
|
||||
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
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
|
||||
def _make_loader(
|
||||
@@ -391,3 +392,118 @@ class TestAllocationInputLoader:
|
||||
date(2027, 12, 24): True,
|
||||
date(2027, 2, 2): False,
|
||||
}
|
||||
|
||||
async def test_load_costs_julafton_correctly_for_a_far_future_horizon(
|
||||
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,
|
||||
) -> None:
|
||||
"""Before the cache was extended to ~30 years, a horizon reaching the
|
||||
late 2040s ran past the old 10-year cache (2026-2035) and the loader
|
||||
silently fell back to the live `holidays` library, which reports
|
||||
Julafton/Nyårsafton/Midsommarafton as ordinary working days. A child
|
||||
born in 2044 pushes the horizon (their 4th birthday) to 2048 — assert
|
||||
the resulting `holidays` map still carries all three as real red
|
||||
days that far out, proving the extended cache (not a silent
|
||||
fallback) served it, and that the allocator would cost them
|
||||
correctly rather than charging a needless semesterdag."""
|
||||
now = datetime.now()
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Future", created_at=now))
|
||||
await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
|
||||
)
|
||||
await child_repo.create(
|
||||
Child(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
name="Barn",
|
||||
birth_date=date(2044, 1, 1),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
loader = _make_loader(
|
||||
workspace_repo,
|
||||
child_repo,
|
||||
personal_data_repo,
|
||||
parental_leave_repo,
|
||||
vacation_repo,
|
||||
planned_leave_repo,
|
||||
leave_wish_repo,
|
||||
)
|
||||
alloc_input = await loader.load(ws.id, today=date(2027, 1, 1))
|
||||
|
||||
assert alloc_input.horizon_end.year == 2048
|
||||
|
||||
julafton = alloc_input.holidays.get(date(2048, 12, 24))
|
||||
assert julafton is not None and "julafton" in julafton.lower()
|
||||
|
||||
nyarsafton = alloc_input.holidays.get(date(2048, 12, 31))
|
||||
assert nyarsafton is not None and "nyårsafton" in nyarsafton.lower()
|
||||
|
||||
midsommarafton = [
|
||||
n for d, n in alloc_input.holidays.items() if "midsommarafton" in n.lower()
|
||||
]
|
||||
assert midsommarafton, "Midsommarafton must still be a real free day in 2048"
|
||||
|
||||
async def test_load_raises_validation_error_when_horizon_exceeds_the_cache(
|
||||
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,
|
||||
) -> None:
|
||||
"""A wish with a far-out `last_year` and no children yet (so there's
|
||||
no age-12 hard cap to save it) can still in principle push the
|
||||
horizon past even a ~30-year cache. Rather than silently falling
|
||||
back to the live `holidays` library (the original bug) or silently
|
||||
clamping the horizon (which would contradict the §6 decision to plan
|
||||
past B4 and show the squeeze), the loader must raise a
|
||||
`ValidationError` — the same domain error `CalendarService` uses for
|
||||
an out-of-range `start_year` — so the router can turn it into a 422
|
||||
the UI can explain."""
|
||||
now = datetime.now()
|
||||
ws = await workspace_repo.create(Workspace(id=uuid4(), name="FarFuture", created_at=now))
|
||||
await personal_data_repo.create(
|
||||
PersonalData(uuid=uuid4(), workspace_id=ws.id, name="Parent", created_at=now)
|
||||
)
|
||||
await leave_wish_repo.create(
|
||||
LeaveWish(
|
||||
id=uuid4(),
|
||||
workspace_id=ws.id,
|
||||
child_id=None,
|
||||
name="Sommar",
|
||||
window_start_month=6,
|
||||
window_start_day=1,
|
||||
window_end_month=6,
|
||||
window_end_day=30,
|
||||
target_weeks=4,
|
||||
recurrence="yearly",
|
||||
first_year=2027,
|
||||
last_year=2200,
|
||||
priority=1,
|
||||
parent_mode="either",
|
||||
parent_personal_data_uuid=None,
|
||||
)
|
||||
)
|
||||
|
||||
loader = _make_loader(
|
||||
workspace_repo,
|
||||
child_repo,
|
||||
personal_data_repo,
|
||||
parental_leave_repo,
|
||||
vacation_repo,
|
||||
planned_leave_repo,
|
||||
leave_wish_repo,
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
await loader.load(ws.id, today=date(2027, 1, 1))
|
||||
|
||||
Reference in New Issue
Block a user