782415122d
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
656 lines
23 KiB
Python
656 lines
23 KiB
Python
"""Tests for the PlanningService (planned leave day planning).
|
|
|
|
Demonstrates service-layer testing with external service dependencies.
|
|
Aim for >=90% coverage on new code (ARCHITECTURE.md §10).
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from app.entities.child import Child
|
|
from app.entities.leave_wish import LeaveWish
|
|
from app.entities.personal_data import PersonalData
|
|
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
|
from app.services.planning_service import PlanningService
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
from tests.conftest import SeededIds
|
|
|
|
|
|
def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish:
|
|
base = dict(
|
|
id=uuid4(),
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
name="Test wish",
|
|
window_start_month=6,
|
|
window_start_day=1,
|
|
window_end_month=6,
|
|
window_end_day=30,
|
|
target_weeks=1,
|
|
first_year=2026,
|
|
last_year=None,
|
|
priority=1,
|
|
parent_mode="either",
|
|
parent_personal_data_uuid=None,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
base.update(overrides)
|
|
return LeaveWish(**base)
|
|
|
|
|
|
class TestPlanningService:
|
|
"""Test PlanningService business logic."""
|
|
|
|
async def test_add_planned_day_simple(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Add a simple planned day (non-dubbeldag)."""
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
assert result.date == date(2026, 6, 15)
|
|
assert result.day_type == "vacation"
|
|
assert result.is_dubbeldag is False
|
|
|
|
async def test_add_planned_day_with_wish_id(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Round-trips `wish_id` through the repository (create + re-fetch),
|
|
|
|
not just the in-memory object the create call returns. `is_pinned`
|
|
is no longer a `planned_leave_day` column (restructure step 3b) — a
|
|
freshly-added row is never pinned on its own; `PlanningService.
|
|
pinned_keys`/`is_pinned` compute that live from `DayAssertion`, see
|
|
`test_day_assertion.py` for that half of the guarantee this test
|
|
used to also cover.
|
|
"""
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
|
|
)
|
|
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
wish_id=wish.id,
|
|
)
|
|
assert result.wish_id == wish.id
|
|
assert await planning_service.is_pinned(result) is False
|
|
|
|
fetched = await planning_service.get(result.id)
|
|
assert fetched.wish_id == wish.id
|
|
|
|
async def test_add_planned_day_without_wish_id_defaults_to_none(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
assert result.wish_id is None
|
|
|
|
async def test_add_double_booking_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Adding a second planned day on same date raises ValidationError.
|
|
|
|
Note: This test verifies that the same parent cannot plan the same date twice.
|
|
The error message was updated to be more accurate: "Parent already has a planned
|
|
day" instead of the previous generic "A planned day already exists" (which
|
|
incorrectly rejected different parents planning the same date as a dubbeldag).
|
|
"""
|
|
child_id = seeded_ids.child_id
|
|
|
|
await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Try to add another on same date
|
|
with pytest.raises(ValidationError, match="Parent already has a planned day"):
|
|
await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="lagsta",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
async def test_remove_planned_day(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Remove a planned day."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
|
|
await planning_service.remove_planned_day(created.id)
|
|
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.get(created.id)
|
|
|
|
async def test_remove_missing_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Removing a missing planned day raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.remove_planned_day(uuid4())
|
|
|
|
async def test_get_planned_day(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Get a planned day by ID."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
|
|
fetched = await planning_service.get(created.id)
|
|
assert fetched.id == created.id
|
|
assert fetched.date == date(2026, 6, 15)
|
|
|
|
async def test_get_missing_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Getting a missing planned day raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.get(uuid4())
|
|
|
|
async def test_list_by_workspace(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""List all planned days in a workspace."""
|
|
ws = seeded_ids.workspace_id
|
|
child_id = seeded_ids.child_id
|
|
|
|
d1 = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
d2 = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="vacation",
|
|
)
|
|
|
|
result = await planning_service.list_by_workspace(ws)
|
|
ids = {d.id for d in result}
|
|
assert d1.id in ids
|
|
assert d2.id in ids
|
|
|
|
async def test_list_by_date_range(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""List planned days within a date range."""
|
|
ws = seeded_ids.workspace_id
|
|
child_id = seeded_ids.child_id
|
|
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="vacation",
|
|
)
|
|
|
|
result = await planning_service.list_by_date_range(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
start_date=date(2026, 6, 15),
|
|
end_date=date(2026, 6, 16),
|
|
)
|
|
assert len(result) == 2
|
|
|
|
async def test_list_by_date_range_empty(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Listing with no planned days in range returns empty."""
|
|
result = await planning_service.list_by_date_range(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=uuid4(),
|
|
start_date=date(2026, 6, 15),
|
|
end_date=date(2026, 6, 16),
|
|
)
|
|
assert result == []
|
|
|
|
async def test_add_planned_day_different_day_types(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Add planned days with different day types."""
|
|
ws = seeded_ids.workspace_id
|
|
child_id = seeded_ids.child_id
|
|
parent_uuid = seeded_ids.personal_data_uuid
|
|
|
|
vacation = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
lagsta = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="lagsta",
|
|
)
|
|
sjuk = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2026, 6, 17),
|
|
day_type="sjukpenning",
|
|
)
|
|
|
|
assert vacation.day_type == "vacation"
|
|
assert lagsta.day_type == "lagsta"
|
|
assert sjuk.day_type == "sjukpenning"
|
|
|
|
async def test_list_by_child(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""List all planned days for a child."""
|
|
child_id = seeded_ids.child_id
|
|
|
|
d1 = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
d2 = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 16),
|
|
day_type="lagsta",
|
|
)
|
|
|
|
result = await planning_service.list_by_child(child_id)
|
|
ids = {d.id for d in result}
|
|
assert d1.id in ids
|
|
assert d2.id in ids
|
|
|
|
async def test_list_by_child_empty(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Listing a child with no planned days returns empty."""
|
|
result = await planning_service.list_by_child(uuid4())
|
|
assert result == []
|
|
|
|
async def test_update_planned_day_day_type(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Update the day_type of a planned day."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
day_type="lagsta",
|
|
)
|
|
assert updated.day_type == "lagsta"
|
|
assert updated.date == date(2026, 6, 15)
|
|
|
|
async def test_update_planned_day_is_dubbeldag(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Update the is_dubbeldag flag of a planned day."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
is_dubbeldag=True,
|
|
)
|
|
assert updated.is_dubbeldag is True
|
|
assert updated.day_type == "vacation"
|
|
|
|
async def test_update_planned_day_both_fields(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Update both day_type and is_dubbeldag."""
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=True,
|
|
)
|
|
assert updated.day_type == "sjukpenning"
|
|
assert updated.is_dubbeldag is True
|
|
|
|
async def test_update_planned_day_wish_id(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""`is_pinned` is not an `update()` parameter any more (restructure
|
|
|
|
step 3b) — see `add_planned_day`'s docstring. Pinning this row is
|
|
now `DayAssertionService.assert_day`'s job (`test_day_assertion.py`).
|
|
"""
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
|
|
)
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
assert created.wish_id is None
|
|
|
|
updated = await planning_service.update(
|
|
created.id,
|
|
wish_id=wish.id,
|
|
)
|
|
assert updated.wish_id == wish.id
|
|
|
|
refetched = await planning_service.get(created.id)
|
|
assert refetched.wish_id == wish.id
|
|
|
|
async def test_update_omitted_wish_id_leaves_it_unchanged(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Regression: an OMITTED `wish_id` (the caller doesn't pass it at
|
|
|
|
all) must leave the row's existing wish attachment untouched — a
|
|
call that's only updating `day_type`, say, must not accidentally
|
|
detach the row from its wish.
|
|
"""
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
|
|
)
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
attached = await planning_service.update(created.id, wish_id=wish.id)
|
|
assert attached.wish_id == wish.id
|
|
|
|
updated = await planning_service.update(created.id, day_type="lagsta")
|
|
assert updated.day_type == "lagsta"
|
|
assert updated.wish_id == wish.id # unchanged, not detached
|
|
|
|
async def test_update_explicit_none_detaches_wish_id(
|
|
self,
|
|
planning_service: PlanningService,
|
|
leave_wish_repo: SQLiteLeaveWishRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Regression: an explicit `wish_id=None` (`_clear_wish_id=True`)
|
|
|
|
must actually detach the row from its wish, unlike a merely
|
|
OMITTED `wish_id` (see the sibling test above) — before the fix,
|
|
`if wish_id is not None: entity.wish_id = wish_id` silently ignored
|
|
both cases identically, so a day could never be detached through
|
|
`update()`.
|
|
"""
|
|
wish = await leave_wish_repo.create(
|
|
_make_wish(seeded_ids.workspace_id, seeded_ids.child_id)
|
|
)
|
|
created = await planning_service.add_planned_day(
|
|
workspace_id=seeded_ids.workspace_id,
|
|
child_id=seeded_ids.child_id,
|
|
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
|
|
date=date(2026, 6, 15),
|
|
day_type="vacation",
|
|
)
|
|
attached = await planning_service.update(created.id, wish_id=wish.id)
|
|
assert attached.wish_id == wish.id
|
|
|
|
detached = await planning_service.update(created.id, _clear_wish_id=True)
|
|
assert detached.wish_id is None
|
|
|
|
refetched = await planning_service.get(created.id)
|
|
assert refetched.wish_id is None
|
|
|
|
async def test_update_missing_raises(
|
|
self,
|
|
planning_service: PlanningService,
|
|
) -> None:
|
|
"""Updating a missing planned day raises NotFoundError."""
|
|
with pytest.raises(NotFoundError):
|
|
await planning_service.update(
|
|
uuid4(),
|
|
day_type="lagsta",
|
|
)
|
|
|
|
async def test_add_planned_day_different_parents_same_date_allowed(
|
|
self,
|
|
planning_service: PlanningService,
|
|
personal_data_repo: SQLitePersonalDataRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Two different parents can plan the same date for the same child (dubbeldagar).
|
|
|
|
This is the legitimate dubbeldagar case: parent A has the date planned,
|
|
parent B can also plan the same date (subject to dubbeldagar validation).
|
|
Note: The test uses a non-dubbeldag date for parent A and a dubbeldag date
|
|
for parent B within the 15-month window.
|
|
"""
|
|
child_id = seeded_ids.child_id
|
|
ws = seeded_ids.workspace_id
|
|
parent_a_uuid = seeded_ids.personal_data_uuid
|
|
|
|
# Create a second parent
|
|
parent_b = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws,
|
|
name="Test Parent B",
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
parent_b_uuid = parent_b.uuid
|
|
|
|
# Parent A plans 2024-10-01 (within dubbeldagar window, but not as a dubbeldag)
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_a_uuid,
|
|
date=date(2024, 10, 1),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Parent B can also plan the same date (this should succeed)
|
|
# Parent B marks this as a dubbeldag
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_b_uuid,
|
|
date=date(2024, 10, 1),
|
|
day_type="lagsta",
|
|
is_dubbeldag=True,
|
|
)
|
|
assert result.parent_personal_data_uuid == parent_b_uuid
|
|
assert result.date == date(2024, 10, 1)
|
|
assert result.is_dubbeldag is True
|
|
|
|
async def test_add_planned_day_same_parent_same_date_rejected(
|
|
self,
|
|
planning_service: PlanningService,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Same parent cannot plan the same date twice (genuine double-booking).
|
|
|
|
This verifies that the fix for parent-aware checking still rejects
|
|
the genuine double-booking case where the same parent tries to plan
|
|
the same date twice.
|
|
"""
|
|
child_id = seeded_ids.child_id
|
|
ws = seeded_ids.workspace_id
|
|
parent_uuid = seeded_ids.personal_data_uuid
|
|
|
|
# Parent plans 2024-09-01
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Same parent tries to plan the same date again - should be rejected
|
|
with pytest.raises(ValidationError, match="already has a planned day"):
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="lagsta",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
async def test_add_planned_day_same_date_different_child_allowed(
|
|
self,
|
|
planning_service: PlanningService,
|
|
child_repo: SQLiteChildRepository,
|
|
seeded_ids: SeededIds,
|
|
) -> None:
|
|
"""Same parent can plan the same date for different children.
|
|
|
|
The double-booking check is per (parent, child), so the same parent
|
|
planning the same date for different children is always allowed.
|
|
"""
|
|
ws = seeded_ids.workspace_id
|
|
parent_uuid = seeded_ids.personal_data_uuid
|
|
child_1 = seeded_ids.child_id
|
|
|
|
# Create a second child
|
|
child_2 = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws,
|
|
name="Test Child 2",
|
|
birth_date=date(2024, 6, 1),
|
|
created_at=datetime.now(),
|
|
)
|
|
)
|
|
child_2_id = child_2.id
|
|
|
|
# Parent plans 2024-09-01 for child 1
|
|
await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_1,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="sjukpenning",
|
|
is_dubbeldag=False,
|
|
)
|
|
|
|
# Same parent can plan the same date for child 2
|
|
result = await planning_service.add_planned_day(
|
|
workspace_id=ws,
|
|
child_id=child_2_id,
|
|
parent_personal_data_uuid=parent_uuid,
|
|
date=date(2024, 9, 1),
|
|
day_type="lagsta",
|
|
is_dubbeldag=False,
|
|
)
|
|
assert result.child_id == child_2_id
|
|
assert result.parent_personal_data_uuid == parent_uuid
|
|
assert result.date == date(2024, 9, 1)
|