8cfb9c82c6
Taking one day out of a period the planner placed did nothing that lasted: the period still asked for a full week, so the next sync put the day straight back. The option was disabled rather than lying about it. A removed day is now recorded as its own thing — no leave for this parent, this child, this date — and the planner honours it on every later sync. It is kept apart from the working-day overrides, which answer a different question about the calendar rather than about the plan. The day belongs to the parent whose band was clicked, so removing one parent's day leaves their partner's alone; where both parents were to be off on one funding arrangement, the partner is simply funded on their own instead. A removed day shortens what the period is asking for rather than counting as a day the plan failed to find, so the advice about closing a shortfall does not start arguing for a day nobody wants. Nothing is removed without a way back: the toast undoes it, and the day itself is marked in the calendar and offers to restore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
368 lines
15 KiB
Python
368 lines
15 KiB
Python
"""Tests for the PlannedDayExclusion repository, service and router.
|
|
|
|
The core promise, in the user's words: "removing a single day should stay
|
|
permanent. Even if it's an auto added period day." `TestExclusionSurvivesSync`
|
|
is that promise — a day removed from a planner-placed period must still be
|
|
gone after the next `POST /api/plan/sync`, which is precisely what re-derives
|
|
it today.
|
|
|
|
Follows the DayOverride reference pattern (arrange an entity, exercise the
|
|
repo/service, assert on the result).
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.planned_day_exclusion import PlannedDayExclusion
|
|
from app.entities.planned_leave import PlannedLeaveDay
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
|
|
SQLitePlannedDayExclusionRepository,
|
|
)
|
|
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
|
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
|
|
from app.services.planning_service import PlanningService
|
|
from app.utils.errors import NotFoundError
|
|
from tests.conftest import SeededIds
|
|
|
|
_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below
|
|
|
|
|
|
def _make(seeded: SeededIds, **overrides) -> PlannedDayExclusion:
|
|
base = dict(
|
|
id=uuid4(),
|
|
workspace_id=seeded.workspace_id,
|
|
child_id=seeded.child_id,
|
|
parent_personal_data_uuid=seeded.personal_data_uuid,
|
|
date=_DAY,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
base.update(overrides)
|
|
return PlannedDayExclusion(**base)
|
|
|
|
|
|
def _make_wish(seeded: SeededIds) -> LeaveWish:
|
|
"""A fixed, one-week February 2027 window, funded by the seeded parent."""
|
|
return LeaveWish(
|
|
id=uuid4(),
|
|
workspace_id=seeded.workspace_id,
|
|
child_id=seeded.child_id,
|
|
name="Sportlov",
|
|
window_start_month=2,
|
|
window_start_day=1,
|
|
window_end_month=2,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
first_year=2027,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
window_mode="fixed",
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
|
|
|
|
class TestSQLitePlannedDayExclusionRepository:
|
|
async def test_create_and_get_by_id(
|
|
self,
|
|
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(seeded_ids)
|
|
await planned_day_exclusion_repo.create(entity)
|
|
|
|
fetched = await planned_day_exclusion_repo.get_by_id(entity.id)
|
|
assert fetched is not None
|
|
assert fetched.id == entity.id
|
|
assert fetched.date == _DAY
|
|
assert fetched.child_id == seeded_ids.child_id
|
|
assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid
|
|
|
|
async def test_get_by_natural_key(
|
|
self,
|
|
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(seeded_ids)
|
|
await planned_day_exclusion_repo.create(entity)
|
|
|
|
found = await planned_day_exclusion_repo.get_by_natural_key(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
|
)
|
|
assert found is not None
|
|
assert found.id == entity.id
|
|
|
|
# The parent IS part of the key — a different parent is a different row.
|
|
other_parent = await planned_day_exclusion_repo.get_by_natural_key(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY
|
|
)
|
|
assert other_parent is None
|
|
|
|
other_day = await planned_day_exclusion_repo.get_by_natural_key(
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
date(2027, 2, 4),
|
|
)
|
|
assert other_day is None
|
|
|
|
async def test_update_and_delete(
|
|
self,
|
|
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
entity = _make(seeded_ids)
|
|
await planned_day_exclusion_repo.create(entity)
|
|
|
|
entity.date = date(2027, 2, 5)
|
|
updated = await planned_day_exclusion_repo.update(entity.id, entity)
|
|
assert updated.date == date(2027, 2, 5)
|
|
|
|
await planned_day_exclusion_repo.delete(entity.id)
|
|
assert await planned_day_exclusion_repo.get_by_id(entity.id) is None
|
|
|
|
with pytest.raises(NotFoundError):
|
|
await planned_day_exclusion_repo.delete(entity.id)
|
|
with pytest.raises(NotFoundError):
|
|
await planned_day_exclusion_repo.update(entity.id, entity)
|
|
|
|
async def test_list_by_workspace_is_date_ordered(
|
|
self,
|
|
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 5)))
|
|
await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 1)))
|
|
|
|
rows = await planned_day_exclusion_repo.list_by_workspace(seeded_ids.workspace_id)
|
|
assert [r.date for r in rows] == [date(2027, 2, 1), date(2027, 2, 5)]
|
|
|
|
assert await planned_day_exclusion_repo.list_by_workspace(uuid4()) == []
|
|
|
|
async def test_unsupported_filter_is_rejected(
|
|
self,
|
|
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
|
|
) -> None:
|
|
with pytest.raises(ValueError):
|
|
await planned_day_exclusion_repo.list(nonsense=1)
|
|
|
|
|
|
class TestPlannedDayExclusionService:
|
|
async def test_exclude_is_idempotent(
|
|
self,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
first = await planned_day_exclusion_service.exclude(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
|
)
|
|
second = await planned_day_exclusion_service.exclude(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
|
)
|
|
assert second.id == first.id
|
|
rows = await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id)
|
|
assert len(rows) == 1
|
|
|
|
async def test_exclude_removes_the_planned_day_immediately(
|
|
self,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
await planned_leave_repo.create(
|
|
PlannedLeaveDay(
|
|
id=uuid4(),
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=_DAY,
|
|
day_type="sjukpenning",
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
)
|
|
|
|
await planned_day_exclusion_service.exclude(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
|
)
|
|
|
|
rows = await planned_leave_repo.list_by_workspace_child(
|
|
seeded_ids.workspace_id, seeded_ids.child_id
|
|
)
|
|
assert [r for r in rows if r.date == _DAY] == []
|
|
|
|
async def test_get_and_delete_are_workspace_scoped(
|
|
self,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
created = await planned_day_exclusion_service.exclude(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
|
)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
await planned_day_exclusion_service.get(created.id, workspace_id=uuid4())
|
|
with pytest.raises(NotFoundError):
|
|
await planned_day_exclusion_service.delete(created.id, workspace_id=uuid4())
|
|
|
|
await planned_day_exclusion_service.delete(created.id, workspace_id=seeded_ids.workspace_id)
|
|
assert await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) == []
|
|
|
|
async def test_excluded_keys_shape_matches_the_allocator_input(
|
|
self,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
await planned_day_exclusion_service.exclude(
|
|
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
|
|
)
|
|
keys = await planned_day_exclusion_service.get_excluded_keys(seeded_ids.workspace_id)
|
|
assert keys == frozenset({(seeded_ids.personal_data_uuid, seeded_ids.child_id, _DAY)})
|
|
|
|
|
|
class TestExclusionSurvivesSync:
|
|
"""The core promise: a removed day stays removed across re-syncs."""
|
|
|
|
async def test_removed_day_is_not_re_derived_by_the_next_sync(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await leave_wish_repo.create(_make_wish(seeded_ids))
|
|
|
|
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert first.apply_result.accepted is True
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert _DAY in {r.date for r in rows}, "the planner should place the day to begin with"
|
|
|
|
await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
|
|
|
|
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert second.apply_result.accepted is True, second.apply_result.rejections
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert _DAY not in {r.date for r in rows}
|
|
# The rest of the week is still planned — only the one day went.
|
|
assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= {
|
|
r.date for r in rows
|
|
}
|
|
|
|
# ...and a third sync must not quietly bring it back either.
|
|
third = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
assert third.apply_result.accepted is True
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert _DAY not in {r.date for r in rows}
|
|
|
|
async def test_removal_does_not_report_a_shortfall(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""A day the user removed is not a day the plan failed to fund.
|
|
|
|
If it were counted as a gap, `sync_plan`'s eviction preview could
|
|
refuse the very sync that carries out the removal.
|
|
"""
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await leave_wish_repo.create(_make_wish(seeded_ids))
|
|
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
|
|
|
|
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
assert result.apply_result.accepted is True
|
|
assert result.eviction_preview is not None
|
|
assert result.eviction_preview.would_evict is False
|
|
assert [o.shortfall_days for o in result.proposal.outcomes] == [0]
|
|
|
|
async def test_un_excluding_puts_the_day_back(
|
|
self,
|
|
planning_service: PlanningService,
|
|
planned_day_exclusion_service: PlannedDayExclusionService,
|
|
planned_leave_repo: SQLitePlannedLeaveRepository,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""There must be a way back — this is what the undo toast calls."""
|
|
ws, child, parent = (
|
|
seeded_ids.workspace_id,
|
|
seeded_ids.child_id,
|
|
seeded_ids.personal_data_uuid,
|
|
)
|
|
await leave_wish_repo.create(_make_wish(seeded_ids))
|
|
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
created = await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
|
|
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
await planned_day_exclusion_service.delete(created.id, workspace_id=ws)
|
|
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
|
|
|
|
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
|
|
assert _DAY in {r.date for r in rows}
|
|
|
|
|
|
class TestPlannedDayExclusionRouter:
|
|
async def test_create_list_delete_round_trip(
|
|
self, authed_client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
payload = {
|
|
"workspace_id": str(seeded_ids.workspace_id),
|
|
"child_id": str(seeded_ids.child_id),
|
|
"parent_personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
|
"date": _DAY.isoformat(),
|
|
}
|
|
created = await authed_client.post("/api/planned-day-exclusions", json=payload)
|
|
assert created.status_code == 201, created.text
|
|
body = created.json()
|
|
assert body["date"] == _DAY.isoformat()
|
|
|
|
listed = await authed_client.get(
|
|
f"/api/planned-day-exclusions/workspace/{seeded_ids.workspace_id}"
|
|
)
|
|
assert listed.status_code == 200
|
|
assert [r["id"] for r in listed.json()] == [body["id"]]
|
|
|
|
deleted = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}")
|
|
assert deleted.status_code == 204
|
|
|
|
missing = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}")
|
|
assert missing.status_code == 404
|
|
|
|
async def test_a_foreign_workspace_is_rejected(
|
|
self, authed_client: AsyncClient, seeded_ids: SeededIds
|
|
) -> None:
|
|
response = await authed_client.post(
|
|
"/api/planned-day-exclusions",
|
|
json={
|
|
"workspace_id": str(uuid4()),
|
|
"child_id": str(seeded_ids.child_id),
|
|
"parent_personal_data_uuid": str(seeded_ids.personal_data_uuid),
|
|
"date": _DAY.isoformat(),
|
|
},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
async def test_a_foreign_list_is_rejected(self, authed_client: AsyncClient) -> None:
|
|
response = await authed_client.get(f"/api/planned-day-exclusions/workspace/{uuid4()}")
|
|
assert response.status_code == 404
|