Files
HarborForge.Backend/tests/test_slot_immutability.py
zhi 4f0e933de3 BE-CAL-007: MinimumWorkload warning rules + BE-CAL-008: past-slot immutability
BE-CAL-007: Workload warning computation (already implemented in prior wave,
verified tests pass - 24/24). Computes daily/weekly/monthly/yearly scheduled
minutes and compares against user thresholds. Warnings are advisory only.

BE-CAL-008: New slot_immutability service with guards for:
- Forbid edit/cancel of past real slots (raises ImmutableSlotError)
- Forbid edit/cancel of past virtual slots
- Plan-edit/plan-cancel helper to identify past materialized slot IDs
  that must not be retroactively modified
Tests: 19/19 passing.
2026-03-31 04:16:50 +00:00

235 lines
8.2 KiB
Python

"""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)