"""Tests for past-slot immutability rules (BE-CAL-008). Tests cover: - Editing a past real slot is forbidden - Cancelling a past real slot is forbidden - Editing a past virtual slot is forbidden - Cancelling a past virtual slot is forbidden - Editing/cancelling today's slots is allowed - Editing/cancelling future slots is allowed - Plan-edit / plan-cancel do not retroactively affect past materialized slots """ import pytest from datetime import date, time from app.models.calendar import ( SchedulePlan, SlotStatus, SlotType, TimeSlot, DayOfWeek, ) from app.services.slot_immutability import ( ImmutableSlotError, guard_edit_real_slot, guard_cancel_real_slot, guard_edit_virtual_slot, guard_cancel_virtual_slot, get_past_materialized_slot_ids, guard_plan_edit_no_past_retroaction, guard_plan_cancel_no_past_retroaction, ) from app.services.plan_slot import make_virtual_slot_id TODAY = date(2026, 3, 31) YESTERDAY = date(2026, 3, 30) LAST_WEEK = date(2026, 3, 24) TOMORROW = date(2026, 4, 1) # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- def _make_slot(db, seed, slot_date, plan_id=None): """Create and return a real TimeSlot.""" slot = TimeSlot( user_id=seed["admin_user"].id, date=slot_date, slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, plan_id=plan_id, ) db.add(slot) db.flush() return slot # --------------------------------------------------------------------------- # Real slot: edit # --------------------------------------------------------------------------- class TestGuardEditRealSlot: def test_past_slot_raises(self, db, seed): slot = _make_slot(db, seed, YESTERDAY) db.commit() with pytest.raises(ImmutableSlotError, match="Cannot edit"): guard_edit_real_slot(db, slot, today=TODAY) def test_today_slot_allowed(self, db, seed): slot = _make_slot(db, seed, TODAY) db.commit() # Should not raise guard_edit_real_slot(db, slot, today=TODAY) def test_future_slot_allowed(self, db, seed): slot = _make_slot(db, seed, TOMORROW) db.commit() guard_edit_real_slot(db, slot, today=TODAY) # --------------------------------------------------------------------------- # Real slot: cancel # --------------------------------------------------------------------------- class TestGuardCancelRealSlot: def test_past_slot_raises(self, db, seed): slot = _make_slot(db, seed, YESTERDAY) db.commit() with pytest.raises(ImmutableSlotError, match="Cannot cancel"): guard_cancel_real_slot(db, slot, today=TODAY) def test_today_slot_allowed(self, db, seed): slot = _make_slot(db, seed, TODAY) db.commit() guard_cancel_real_slot(db, slot, today=TODAY) def test_future_slot_allowed(self, db, seed): slot = _make_slot(db, seed, TOMORROW) db.commit() guard_cancel_real_slot(db, slot, today=TODAY) # --------------------------------------------------------------------------- # Virtual slot: edit # --------------------------------------------------------------------------- class TestGuardEditVirtualSlot: def test_past_virtual_raises(self): vid = make_virtual_slot_id(1, YESTERDAY) with pytest.raises(ImmutableSlotError, match="Cannot edit"): guard_edit_virtual_slot(vid, today=TODAY) def test_today_virtual_allowed(self): vid = make_virtual_slot_id(1, TODAY) guard_edit_virtual_slot(vid, today=TODAY) def test_future_virtual_allowed(self): vid = make_virtual_slot_id(1, TOMORROW) guard_edit_virtual_slot(vid, today=TODAY) def test_invalid_virtual_id_raises_value_error(self): with pytest.raises(ValueError, match="Invalid virtual slot id"): guard_edit_virtual_slot("bad-id", today=TODAY) # --------------------------------------------------------------------------- # Virtual slot: cancel # --------------------------------------------------------------------------- class TestGuardCancelVirtualSlot: def test_past_virtual_raises(self): vid = make_virtual_slot_id(1, YESTERDAY) with pytest.raises(ImmutableSlotError, match="Cannot cancel"): guard_cancel_virtual_slot(vid, today=TODAY) def test_today_virtual_allowed(self): vid = make_virtual_slot_id(1, TODAY) guard_cancel_virtual_slot(vid, today=TODAY) def test_future_virtual_allowed(self): vid = make_virtual_slot_id(1, TOMORROW) guard_cancel_virtual_slot(vid, today=TODAY) # --------------------------------------------------------------------------- # Plan retroaction: past materialized slots are protected # --------------------------------------------------------------------------- class TestPlanNoRetroaction: def _make_plan_with_slots(self, db, seed): """Create a plan with materialized slots in the past, today, and future.""" user_id = seed["admin_user"].id plan = SchedulePlan( user_id=user_id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.flush() past_slot = _make_slot(db, seed, LAST_WEEK, plan_id=plan.id) yesterday_slot = _make_slot(db, seed, YESTERDAY, plan_id=plan.id) today_slot = _make_slot(db, seed, TODAY, plan_id=plan.id) future_slot = _make_slot(db, seed, TOMORROW, plan_id=plan.id) db.commit() return plan, past_slot, yesterday_slot, today_slot, future_slot def test_get_past_materialized_slot_ids(self, db, seed): plan, past_slot, yesterday_slot, today_slot, future_slot = ( self._make_plan_with_slots(db, seed) ) past_ids = get_past_materialized_slot_ids(db, plan.id, today=TODAY) assert set(past_ids) == {past_slot.id, yesterday_slot.id} assert today_slot.id not in past_ids assert future_slot.id not in past_ids def test_guard_plan_edit_returns_protected_ids(self, db, seed): plan, past_slot, yesterday_slot, _, _ = ( self._make_plan_with_slots(db, seed) ) protected = guard_plan_edit_no_past_retroaction(db, plan.id, today=TODAY) assert set(protected) == {past_slot.id, yesterday_slot.id} def test_guard_plan_cancel_returns_protected_ids(self, db, seed): plan, past_slot, yesterday_slot, _, _ = ( self._make_plan_with_slots(db, seed) ) protected = guard_plan_cancel_no_past_retroaction(db, plan.id, today=TODAY) assert set(protected) == {past_slot.id, yesterday_slot.id} def test_no_past_slots_returns_empty(self, db, seed): """If all materialized slots are today or later, no past IDs returned.""" user_id = seed["admin_user"].id plan = SchedulePlan( user_id=user_id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.flush() _make_slot(db, seed, TODAY, plan_id=plan.id) _make_slot(db, seed, TOMORROW, plan_id=plan.id) db.commit() past_ids = get_past_materialized_slot_ids(db, plan.id, today=TODAY) assert past_ids == [] # --------------------------------------------------------------------------- # ImmutableSlotError attributes # --------------------------------------------------------------------------- class TestImmutableSlotError: def test_error_attributes(self): err = ImmutableSlotError(YESTERDAY, "edit", detail="test detail") assert err.slot_date == YESTERDAY assert err.operation == "edit" assert err.detail == "test detail" assert "Cannot edit" in str(err) assert "2026-03-30" in str(err) assert "test detail" in str(err) def test_error_without_detail(self): err = ImmutableSlotError(YESTERDAY, "cancel") assert "Cannot cancel" in str(err) assert "test detail" not in str(err)