Files
F-r-ldraledigplannerare/backend/tests/unit/test_day_assertion.py
T
jacobh2 782415122d Separate user intent from planner output via day assertions
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
2026-09-21 07:20:14 +02:00

512 lines
19 KiB
Python

"""Tests for the DayAssertion repository and service.
Restructure step 3 (intent vs. derived output — see
`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`).
`TestDayAssertionServiceMirroring`/`TestDayAssertionServiceGuards` below
cover what step 3b's `DayAssertionService` newly took on from the deleted
`PlannedDayExclusionService`/`PlanningService.add_planned_day`: mirroring a
write into `planned_leave_day` and enforcing the free-day guard.
"""
from datetime import date, datetime
from uuid import uuid4
import pytest
from app.entities.day_assertion import DAY_TYPE_NONE, DayAssertion
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
from app.services.day_assertion_service import DayAssertionService
from app.utils.errors import NotFoundError, ValidationError
from tests.conftest import SeededIds
_DAY = date(2027, 2, 3)
def _make(seeded: SeededIds, **overrides) -> DayAssertion:
base = dict(
id=uuid4(),
workspace_id=seeded.workspace_id,
child_id=seeded.child_id,
parent_personal_data_uuid=seeded.personal_data_uuid,
date=_DAY,
day_type="sjukpenning",
is_dubbeldag=False,
wish_id=None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
base.update(overrides)
return DayAssertion(**base)
class TestSQLiteDayAssertionRepository:
async def test_create_and_get_by_id(
self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds
) -> None:
entity = _make(seeded_ids)
await day_assertion_repo.create(entity)
fetched = await day_assertion_repo.get_by_id(entity.id)
assert fetched is not None
assert fetched.day_type == "sjukpenning"
assert fetched.wish_id is None
async def test_get_by_natural_key(
self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds
) -> None:
entity = _make(seeded_ids)
await day_assertion_repo.create(entity)
found = await day_assertion_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
assert (
await day_assertion_repo.get_by_natural_key(
seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY
)
is None
)
async def test_update_and_delete(
self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds
) -> None:
entity = _make(seeded_ids)
await day_assertion_repo.create(entity)
entity.day_type = "vacation"
entity.is_dubbeldag = True
updated = await day_assertion_repo.update(entity.id, entity)
assert updated.day_type == "vacation"
assert updated.is_dubbeldag is True
await day_assertion_repo.delete(entity.id)
assert await day_assertion_repo.get_by_id(entity.id) is None
with pytest.raises(NotFoundError):
await day_assertion_repo.delete(entity.id)
with pytest.raises(NotFoundError):
await day_assertion_repo.update(entity.id, entity)
async def test_delete_by_natural_key_is_idempotent(
self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds
) -> None:
entity = _make(seeded_ids)
await day_assertion_repo.create(entity)
await day_assertion_repo.delete_by_natural_key(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
assert await day_assertion_repo.get_by_id(entity.id) is None
# A no-op the second time — never raises.
await day_assertion_repo.delete_by_natural_key(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
async def test_upsert_creates_then_overwrites(
self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds
) -> None:
first = await day_assertion_repo.upsert(_make(seeded_ids, day_type="sjukpenning"))
second = await day_assertion_repo.upsert(
_make(seeded_ids, id=uuid4(), day_type="vacation", is_dubbeldag=True)
)
# Same natural key -> same underlying row, not a duplicate.
assert second.id == first.id
rows = await day_assertion_repo.list_by_workspace(seeded_ids.workspace_id)
assert len(rows) == 1
assert rows[0].day_type == "vacation"
assert rows[0].is_dubbeldag is True
async def test_list_by_workspace_is_date_ordered(
self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds
) -> None:
await day_assertion_repo.create(_make(seeded_ids, date=date(2027, 2, 5)))
await day_assertion_repo.create(_make(seeded_ids, date=date(2027, 2, 1)))
rows = await day_assertion_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 day_assertion_repo.list_by_workspace(uuid4()) == []
async def test_unsupported_filter_is_rejected(
self, day_assertion_repo: SQLiteDayAssertionRepository
) -> None:
with pytest.raises(ValueError):
await day_assertion_repo.list(nonsense=1)
class TestDayAssertionService:
async def test_assert_day_is_idempotent_by_natural_key(
self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds
) -> None:
first = await day_assertion_service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="lagsta",
)
second = await day_assertion_service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
assert second.id == first.id
rows = await day_assertion_service.list_for_workspace(seeded_ids.workspace_id)
assert len(rows) == 1
assert rows[0].day_type == "vacation"
async def test_assert_none_is_the_exclusion_shape(
self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds
) -> None:
created = await day_assertion_service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type=DAY_TYPE_NONE,
)
assert created.day_type == "none"
async def test_invalid_day_type_is_rejected(
self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds
) -> None:
with pytest.raises(ValidationError):
await day_assertion_service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="not-a-real-type",
)
async def test_clear_is_idempotent(
self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds
) -> None:
await day_assertion_service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="lagsta",
)
await day_assertion_service.clear(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
assert await day_assertion_service.list_for_workspace(seeded_ids.workspace_id) == []
# A no-op the second time.
await day_assertion_service.clear(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
async def test_get_is_workspace_scoped(
self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds
) -> None:
created = await day_assertion_service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="lagsta",
)
with pytest.raises(NotFoundError):
await day_assertion_service.get(created.id, workspace_id=uuid4())
assert (
await day_assertion_service.get(created.id, workspace_id=seeded_ids.workspace_id)
).id == created.id
class TestDayAssertionServiceMirroring:
"""Restructure step 3b: `assert_day`/`clear` mirror into
`planned_leave_day` immediately — the same "true the moment the call
returns" guarantee `PlannedDayExclusionService.exclude()` used to
provide, now generalised to every day_type including a concrete pin.
"""
async def test_assert_day_mirrors_a_concrete_pin_into_planned_leave_day(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo)
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
assert len(rows) == 1
assert rows[0].date == _DAY
assert rows[0].day_type == "vacation"
async def test_reasserting_a_different_day_type_replaces_the_mirrored_row(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo)
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="lagsta",
)
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
assert len(rows) == 1
assert rows[0].day_type == "vacation"
async def test_asserting_none_deletes_the_mirrored_row(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo)
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type=DAY_TYPE_NONE,
)
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
assert rows == []
async def test_clear_deletes_the_mirrored_row_too(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo)
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
await service.clear(
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 rows == []
# Idempotent — a second clear with nothing left is a no-op.
await service.clear(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
class TestDayAssertionServiceAtomicity:
"""Adversarial-review fix #1: `assert_day`/`clear` each do an assertion
write followed by a `planned_leave_day` mirror write — two statements
that must succeed or fail together. Before the fix, `self._repo.upsert`
(or `delete_by_natural_key`) committed immediately and the mirror step
ran as its own separate commit(s), so a crash mid-mirror left the
assertion table and `planned_leave_day` permanently diverged. These
tests inject a failure INTO the mirror step (not just the happy path)
and prove the assertion write itself was rolled back with it.
"""
async def test_failure_during_mirror_leaves_no_assertion_committed(
self,
day_assertion_service_with_uow: DayAssertionService,
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def boom(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("simulated failure mid-mirror")
monkeypatch.setattr(planned_leave_repo, "create", boom)
with pytest.raises(RuntimeError):
await day_assertion_service_with_uow.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
# Neither half of the write landed — the assertion table and
# `planned_leave_day` never diverged.
assert (
await day_assertion_repo.get_by_natural_key(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
)
is None
)
assert (
await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
== []
)
async def test_failure_during_clear_mirror_leaves_the_original_assertion_intact(
self,
day_assertion_service_with_uow: DayAssertionService,
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
monkeypatch: pytest.MonkeyPatch,
) -> None:
created = await day_assertion_service_with_uow.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
_DAY,
day_type="vacation",
)
async def boom(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("simulated failure mid-mirror")
monkeypatch.setattr(planned_leave_repo, "delete_many", boom)
with pytest.raises(RuntimeError):
await day_assertion_service_with_uow.clear(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
# The assertion delete was rolled back along with the failed mirror
# delete — the original assertion (and its mirrored row) survive
# untouched, rather than the assertion vanishing while the row it
# was meant to keep in step with stays behind.
after = await day_assertion_repo.get_by_natural_key(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
assert after is not None
assert after.id == created.id
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
assert len(rows) == 1
class TestDayAssertionServiceGuards:
"""The free-day guard (`app.services.day_guards.reject_illegal_free_day`)
is enforced here too now — the same rule `PlanningService.
add_planned_day`/`update` used to be the only enforcer of.
"""
_SATURDAY = date(2027, 2, 6)
async def test_vacation_on_a_free_day_is_rejected(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
workspace_repo: SQLiteWorkspaceRepository,
day_override_repo: SQLiteDayOverrideRepository,
seeded_ids: SeededIds,
) -> None:
service = DayAssertionService(
day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo
)
with pytest.raises(ValidationError):
await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
self._SATURDAY,
day_type="vacation",
)
async def test_sjukpenning_on_a_free_day_is_allowed_when_workspace_opts_in(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
workspace_repo: SQLiteWorkspaceRepository,
day_override_repo: SQLiteDayOverrideRepository,
seeded_ids: SeededIds,
) -> None:
workspace = await workspace_repo.get_by_id(seeded_ids.workspace_id)
assert workspace is not None
workspace.take_pl_on_red_days = True
await workspace_repo.update(workspace.id, workspace)
service = DayAssertionService(
day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo
)
created = await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
self._SATURDAY,
day_type="sjukpenning",
)
assert created.day_type == "sjukpenning"
async def test_exclusion_is_always_allowed_on_a_free_day(
self,
day_assertion_repo: SQLiteDayAssertionRepository,
workspace_repo: SQLiteWorkspaceRepository,
day_override_repo: SQLiteDayOverrideRepository,
seeded_ids: SeededIds,
) -> None:
"""`day_type='none'` is never a "plan leave here" instruction, so the
free-day guard (which only ever governs sjukpenning/lagsta/vacation)
must not block it.
"""
service = DayAssertionService(
day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo
)
created = await service.assert_day(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
self._SATURDAY,
day_type=DAY_TYPE_NONE,
)
assert created.day_type == DAY_TYPE_NONE