"""Tests for BE-CAL-001: Calendar model definitions. Covers: - TimeSlot model creation and fields - SchedulePlan model creation and fields - Enum validations - Model relationships - DB constraints (check constraints, foreign keys) """ import pytest from datetime import date, time, datetime from sqlalchemy.exc import IntegrityError from app.models.calendar import ( TimeSlot, SchedulePlan, SlotType, SlotStatus, EventType, DayOfWeek, MonthOfYear, ) # --------------------------------------------------------------------------- # TimeSlot Model Tests # --------------------------------------------------------------------------- class TestTimeSlotModel: """Tests for TimeSlot ORM model.""" def test_create_timeslot_basic(self, db, seed): """Test creating a basic TimeSlot with required fields.""" slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot) db.commit() db.refresh(slot) assert slot.id is not None assert slot.user_id == seed["admin_user"].id assert slot.date == date(2026, 4, 1) assert slot.slot_type == SlotType.WORK assert slot.estimated_duration == 30 assert slot.scheduled_at == time(9, 0) assert slot.status == SlotStatus.NOT_STARTED assert slot.priority == 0 assert slot.attended is False assert slot.plan_id is None def test_create_timeslot_all_fields(self, db, seed): """Test creating a TimeSlot with all optional fields.""" slot = TimeSlot( user_id=seed["dev_user"].id, date=date(2026, 4, 1), slot_type=SlotType.ON_CALL, estimated_duration=45, scheduled_at=time(14, 30), started_at=time(14, 35), attended=True, actual_duration=40, event_type=EventType.JOB, event_data={"type": "Task", "code": "TASK-42"}, priority=5, status=SlotStatus.FINISHED, ) db.add(slot) db.commit() db.refresh(slot) assert slot.started_at == time(14, 35) assert slot.attended is True assert slot.actual_duration == 40 assert slot.event_type == EventType.JOB assert slot.event_data == {"type": "Task", "code": "TASK-42"} assert slot.priority == 5 assert slot.status == SlotStatus.FINISHED def test_timeslot_slot_type_variants(self, db, seed): """Test all SlotType enum variants.""" for idx, slot_type in enumerate(SlotType): slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=slot_type, estimated_duration=10, scheduled_at=time(idx, 0), status=SlotStatus.NOT_STARTED, priority=idx, ) db.add(slot) db.commit() slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all() assert len(slots) == 4 assert {s.slot_type for s in slots} == set(SlotType) def test_timeslot_status_transitions(self, db, seed): """Test all SlotStatus enum variants.""" for idx, status in enumerate(SlotStatus): slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=10, scheduled_at=time(idx, 0), status=status, priority=0, ) db.add(slot) db.commit() slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all() assert len(slots) == 7 assert {s.status for s in slots} == set(SlotStatus) def test_timeslot_event_type_variants(self, db, seed): """Test all EventType enum variants.""" for idx, event_type in enumerate(EventType): slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=10, scheduled_at=time(idx, 0), status=SlotStatus.NOT_STARTED, event_type=event_type, priority=0, ) db.add(slot) db.commit() slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all() assert len(slots) == 3 assert {s.event_type for s in slots} == set(EventType) def test_timeslot_nullable_event_type(self, db, seed): """Test that event_type can be NULL.""" slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, event_type=None, priority=0, ) db.add(slot) db.commit() db.refresh(slot) assert slot.event_type is None assert slot.event_data is None def test_timeslot_duration_bounds(self, db, seed): """Test duration at boundary values (1-50).""" # Min duration slot_min = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=1, scheduled_at=time(8, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot_min) # Max duration slot_max = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=50, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot_max) db.commit() assert slot_min.estimated_duration == 1 assert slot_max.estimated_duration == 50 def test_timeslot_priority_bounds(self, db, seed): """Test priority at boundary values (0-99).""" slot_low = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=10, scheduled_at=time(8, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot_low) slot_high = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=10, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=99, ) db.add(slot_high) db.commit() assert slot_low.priority == 0 assert slot_high.priority == 99 def test_timeslot_timestamps_auto_set(self, db, seed): """Test that created_at and updated_at are set automatically.""" slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot) db.commit() db.refresh(slot) assert slot.created_at is not None assert isinstance(slot.created_at, datetime) def test_timeslot_user_foreign_key(self, db): """Test that invalid user_id raises IntegrityError.""" slot = TimeSlot( user_id=99999, # Non-existent user date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot) with pytest.raises(IntegrityError): db.commit() def test_timeslot_plan_relationship(self, db, seed): """Test relationship between TimeSlot and SchedulePlan.""" # Create a plan first plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) # Create a slot linked to the plan slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, plan_id=plan.id, ) db.add(slot) db.commit() db.refresh(slot) assert slot.plan_id == plan.id assert slot.plan.id == plan.id assert slot.plan.user_id == seed["admin_user"].id def test_timeslot_query_by_date(self, db, seed): """Test querying slots by date.""" dates = [date(2026, 4, 1), date(2026, 4, 2), date(2026, 4, 1)] for idx, d in enumerate(dates): slot = TimeSlot( user_id=seed["admin_user"].id, date=d, slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9 + idx, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(slot) db.commit() slots_april_1 = db.query(TimeSlot).filter_by( user_id=seed["admin_user"].id, date=date(2026, 4, 1) ).all() assert len(slots_april_1) == 2 def test_timeslot_query_by_status(self, db, seed): """Test querying slots by status.""" for idx, status in enumerate([SlotStatus.NOT_STARTED, SlotStatus.ONGOING, SlotStatus.NOT_STARTED]): slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9 + idx, 0), status=status, priority=0, ) db.add(slot) db.commit() not_started = db.query(TimeSlot).filter_by( user_id=seed["admin_user"].id, status=SlotStatus.NOT_STARTED ).all() assert len(not_started) == 2 # --------------------------------------------------------------------------- # SchedulePlan Model Tests # --------------------------------------------------------------------------- class TestSchedulePlanModel: """Tests for SchedulePlan ORM model.""" def test_create_plan_basic(self, db, seed): """Test creating a basic SchedulePlan with required fields.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.id is not None assert plan.user_id == seed["admin_user"].id assert plan.slot_type == SlotType.WORK assert plan.estimated_duration == 30 assert plan.at_time == time(9, 0) assert plan.is_active is True assert plan.on_day is None assert plan.on_week is None assert plan.on_month is None assert plan.event_type is None assert plan.event_data is None def test_create_plan_daily(self, db, seed): """Test creating a daily plan (--at only).""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=25, at_time=time(10, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.at_time == time(10, 0) assert plan.on_day is None assert plan.on_week is None assert plan.on_month is None def test_create_plan_weekly(self, db, seed): """Test creating a weekly plan (--at + --on-day).""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.ON_CALL, estimated_duration=45, at_time=time(14, 0), on_day=DayOfWeek.MON, is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.on_day == DayOfWeek.MON assert plan.on_week is None assert plan.on_month is None def test_create_plan_monthly(self, db, seed): """Test creating a monthly plan (--at + --on-day + --on-week).""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.ENTERTAINMENT, estimated_duration=45, at_time=time(19, 0), on_day=DayOfWeek.FRI, on_week=2, is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.on_day == DayOfWeek.FRI assert plan.on_week == 2 assert plan.on_month is None def test_create_plan_yearly(self, db, seed): """Test creating a yearly plan (all period params).""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=50, at_time=time(9, 0), on_day=DayOfWeek.SUN, on_week=1, on_month=MonthOfYear.JAN, is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.on_day == DayOfWeek.SUN assert plan.on_week == 1 assert plan.on_month == MonthOfYear.JAN def test_create_plan_with_event(self, db, seed): """Test creating a plan with event_type and event_data.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), event_type=EventType.JOB, event_data={"type": "Meeting", "participants": ["user1", "user2"]}, is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.event_type == EventType.JOB assert plan.event_data == {"type": "Meeting", "participants": ["user1", "user2"]} def test_plan_slot_type_variants(self, db, seed): """Test all SlotType enum variants for SchedulePlan.""" for idx, slot_type in enumerate(SlotType): plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=slot_type, estimated_duration=10, at_time=time(idx, 0), is_active=True, ) db.add(plan) db.commit() plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all() assert len(plans) == 4 assert {p.slot_type for p in plans} == set(SlotType) def test_plan_on_week_validation(self, db, seed): """Test on_week validation (must be 1-4).""" # Valid values for week in [1, 2, 3, 4]: plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), on_day=DayOfWeek.MON, on_week=week, is_active=True, ) db.add(plan) db.commit() plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all() assert len(plans) == 4 assert {p.on_week for p in plans} == {1, 2, 3, 4} def test_plan_on_week_validation_invalid(self, db, seed): """Test that invalid on_week values raise ValueError.""" for week in [0, 5, 10, -1]: with pytest.raises(ValueError): plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), on_day=DayOfWeek.MON, on_week=week, # Invalid is_active=True, ) db.add(plan) db.commit() db.rollback() def test_plan_duration_validation(self, db, seed): """Test estimated_duration validation (must be 1-50).""" # Valid bounds plan_min = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=1, at_time=time(8, 0), is_active=True, ) db.add(plan_min) plan_max = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=50, at_time=time(9, 0), is_active=True, ) db.add(plan_max) db.commit() assert plan_min.estimated_duration == 1 assert plan_max.estimated_duration == 50 def test_plan_duration_validation_invalid(self, db, seed): """Test that invalid estimated_duration raises ValueError.""" for duration in [0, 51, 100, -10]: with pytest.raises(ValueError): plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=duration, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.rollback() def test_plan_hierarchy_constraint_month_requires_week(self, db, seed): """Test validation: on_month requires on_week.""" with pytest.raises(ValueError, match="on_month requires on_week"): SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), on_month=MonthOfYear.JAN, # Without on_week is_active=True, ) def test_plan_hierarchy_constraint_week_requires_day(self, db, seed): """Test DB constraint: on_week requires on_day.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), on_week=1, # Without on_day is_active=True, ) db.add(plan) with pytest.raises(IntegrityError): db.commit() def test_plan_day_of_week_enum(self, db, seed): """Test all DayOfWeek enum values.""" for day in DayOfWeek: plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=10, at_time=time(9, 0), on_day=day, is_active=True, ) db.add(plan) db.commit() plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all() assert len(plans) == 7 assert {p.on_day for p in plans} == set(DayOfWeek) def test_plan_month_of_year_enum(self, db, seed): """Test all MonthOfYear enum values.""" for month in MonthOfYear: plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=10, at_time=time(9, 0), on_day=DayOfWeek.MON, on_week=1, on_month=month, is_active=True, ) db.add(plan) db.commit() plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all() assert len(plans) == 12 assert {p.on_month for p in plans} == set(MonthOfYear) def test_plan_materialized_slots_relationship(self, db, seed): """Test relationship between SchedulePlan and TimeSlot.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) # Create slots linked to the plan for i in range(3): slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1 + i), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, plan_id=plan.id, ) db.add(slot) db.commit() # Refresh to get relationship db.refresh(plan) materialized = plan.materialized_slots.all() assert len(materialized) == 3 assert all(s.plan_id == plan.id for s in materialized) def test_plan_is_active_default_true(self, db, seed): """Test that is_active defaults to True.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), ) db.add(plan) db.commit() db.refresh(plan) assert plan.is_active is True def test_plan_soft_delete(self, db, seed): """Test soft delete by setting is_active=False.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) # Soft delete plan.is_active = False db.commit() db.refresh(plan) assert plan.is_active is False def test_plan_timestamps(self, db, seed): """Test that created_at is set automatically.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) assert plan.created_at is not None assert isinstance(plan.created_at, datetime) # --------------------------------------------------------------------------- # Combined Model Tests # --------------------------------------------------------------------------- class TestCalendarModelsCombined: """Tests for interactions between TimeSlot and SchedulePlan.""" def test_plan_to_slots_cascade_behavior(self, db, seed): """Test that deleting a plan doesn't delete materialized slots.""" plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(plan) db.commit() db.refresh(plan) # Create slots linked to the plan for i in range(3): slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1 + i), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, plan_id=plan.id, ) db.add(slot) db.commit() # Delete the plan (soft delete) plan.is_active = False db.commit() # Slots should still exist slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all() assert len(slots) == 3 # plan_id should remain (not cascade deleted) assert all(s.plan_id == plan.id for s in slots) def test_multiple_plans_per_user(self, db, seed): """Test that a user can have multiple plans.""" for i, slot_type in enumerate([SlotType.WORK, SlotType.ON_CALL, SlotType.ENTERTAINMENT]): plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=slot_type, estimated_duration=30, at_time=time(9 + i, 0), is_active=True, ) db.add(plan) db.commit() plans = db.query(SchedulePlan).filter_by( user_id=seed["admin_user"].id, is_active=True ).all() assert len(plans) == 3 def test_multiple_slots_per_user(self, db, seed): """Test that a user can have multiple slots on same day.""" target_date = date(2026, 4, 1) for i in range(5): slot = TimeSlot( user_id=seed["admin_user"].id, date=target_date, slot_type=SlotType.WORK, estimated_duration=10, scheduled_at=time(9 + i, 0), status=SlotStatus.NOT_STARTED, priority=i, ) db.add(slot) db.commit() slots = db.query(TimeSlot).filter_by( user_id=seed["admin_user"].id, date=target_date ).all() assert len(slots) == 5 # Check ordering by scheduled_at times = [s.scheduled_at for s in sorted(slots, key=lambda x: x.scheduled_at)] assert times == [time(9, 0), time(10, 0), time(11, 0), time(12, 0), time(13, 0)] def test_different_users_isolated(self, db, seed): """Test that users cannot see each other's slots/plans.""" # Create plan and slot for admin admin_plan = SchedulePlan( user_id=seed["admin_user"].id, slot_type=SlotType.WORK, estimated_duration=30, at_time=time(9, 0), is_active=True, ) db.add(admin_plan) admin_slot = TimeSlot( user_id=seed["admin_user"].id, date=date(2026, 4, 1), slot_type=SlotType.WORK, estimated_duration=30, scheduled_at=time(9, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(admin_slot) # Create plan and slot for dev user dev_plan = SchedulePlan( user_id=seed["dev_user"].id, slot_type=SlotType.ON_CALL, estimated_duration=45, at_time=time(14, 0), is_active=True, ) db.add(dev_plan) dev_slot = TimeSlot( user_id=seed["dev_user"].id, date=date(2026, 4, 1), slot_type=SlotType.ON_CALL, estimated_duration=45, scheduled_at=time(14, 0), status=SlotStatus.NOT_STARTED, priority=0, ) db.add(dev_slot) db.commit() # Verify isolation admin_slots = db.query(TimeSlot).filter_by(user_id=seed["admin_user"].id).all() dev_slots = db.query(TimeSlot).filter_by(user_id=seed["dev_user"].id).all() assert len(admin_slots) == 1 assert len(dev_slots) == 1 assert admin_slots[0].slot_type == SlotType.WORK assert dev_slots[0].slot_type == SlotType.ON_CALL admin_plans = db.query(SchedulePlan).filter_by(user_id=seed["admin_user"].id).all() dev_plans = db.query(SchedulePlan).filter_by(user_id=seed["dev_user"].id).all() assert len(admin_plans) == 1 assert len(dev_plans) == 1