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.
235 lines
8.2 KiB
Python
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)
|