import unittest from datetime import datetime, timedelta from app import create_app, db from app.models.classroom import Classroom from app.models.subject import Subject, Commission, CommissionTeacher from app.models.user import User from app.models.role import Role from app.models.reservation import Reservation from app.models.enrollment import StudentEnrollment from app.services.reservation_service import ReservationService from app.services.enrollment_service import EnrollmentService from app.schemas.reservation_dto import ReservationCreateDTO class TestPhase2Rules(unittest.TestCase): """Pruebas unitarias e integrales para la Fase 2 del Roadmap MVP (UniCABA).""" def setUp(self): self.app = create_app() self.app_context = self.app.app_context() self.app_context.push() self.res_service = ReservationService() # Obtener o crear rol alumno y docente self.role_alumno = Role.query.filter_by(name='Alumno').first() if not self.role_alumno: self.role_alumno = Role(name='Alumno', description='Rol Alumno') db.session.add(self.role_alumno) self.role_docente = Role.query.filter_by(name='Docente').first() if not self.role_docente: self.role_docente = Role(name='Docente', description='Rol Docente') db.session.add(self.role_docente) db.session.commit() # Crear usuarios para tests ts = int(datetime.utcnow().timestamp()) self.docente1 = User( name=f'Profesor Principal {ts}', email=f'docente1_{ts}@unicaba.edu.ar', role_id=self.role_docente.id, role='Docente', is_active=True ) self.docente1.set_password('Secret123!') self.docente2 = User( name=f'Profesor Adjunto {ts}', email=f'docente2_{ts}@unicaba.edu.ar', role_id=self.role_docente.id, role='Docente', is_active=True ) self.docente2.set_password('Secret123!') self.alumno1 = User( name=f'Estudiante Test {ts}', email=f'alumno_{ts}@unicaba.edu.ar', role_id=self.role_alumno.id, role='Alumno', is_active=True ) self.alumno1.set_password('Secret123!') # Aulas física y virtual self.aula_fisica = Classroom( code=f'A-501-{ts}', capacity=30, building='Sede Central', floor='Piso 5', is_active=True ) self.aula_virtual = Classroom( code=f'VIRTUAL-MEET-{ts}', capacity=100, building='Campus Virtual', floor='Plataforma Digital', is_active=True ) # Asignaturas regulares y curso corto self.materia_regular_a = Subject( code=f'REG-A-{ts}', name=f'Algoritmos y Estructuras {ts}', is_short_course=False, is_active=True ) self.materia_regular_b = Subject( code=f'REG-B-{ts}', name=f'Sistemas Operativos {ts}', is_short_course=False, is_active=True ) self.curso_corto = Subject( code=f'TALLER-{ts}', name=f'Taller de Herramientas Git {ts}', is_short_course=True, is_active=True ) db.session.add_all([ self.docente1, self.docente2, self.alumno1, self.aula_fisica, self.aula_virtual, self.materia_regular_a, self.materia_regular_b, self.curso_corto ]) db.session.commit() # Comisiones self.comision_a = Commission( subject_id=self.materia_regular_a.id, code=f'COM-A-{ts}', semester='1C', year=2026, teacher_id=self.docente1.id, max_students=40, current_students=0, schedule='Lunes 08:00 - 12:00', active=True ) self.comision_b = Commission( subject_id=self.materia_regular_b.id, code=f'COM-B-{ts}', semester='1C', year=2026, teacher_id=self.docente1.id, max_students=40, current_students=0, schedule='Lunes 14:00 - 18:00', active=True ) self.comision_c_corto = Commission( subject_id=self.curso_corto.id, code=f'COM-CORTO-{ts}', semester='1C', year=2026, teacher_id=self.docente2.id, max_students=20, current_students=0, schedule='Lunes 18:00 - 20:00', active=True ) db.session.add_all([self.comision_a, self.comision_b, self.comision_c_corto]) db.session.commit() def tearDown(self): db.session.rollback() self.app_context.pop() def test_01_physical_conflict_blocks_double_booking(self): """Estadio 2.2: Debe bloquear doble asignación en misma aula física y horario.""" start = datetime(2026, 10, 5, 8, 0) end = datetime(2026, 10, 5, 12, 0) # Primera reserva exitosa dto1 = ReservationCreateDTO( classroom_id=self.aula_fisica.id, commission_id=self.comision_a.id, user_id=self.docente1.id, start_time=start, end_time=end, expected_attendees=25 ) res1 = self.res_service.create_reservation(dto1) self.assertIsNotNone(res1.id) # Segunda reserva en la misma aula solapándose (09:00 a 11:00) dto2 = ReservationCreateDTO( classroom_id=self.aula_fisica.id, commission_id=self.comision_b.id, user_id=self.docente2.id, start_time=start + timedelta(hours=1), end_time=end - timedelta(hours=1), expected_attendees=20 ) with self.assertRaises(ValueError) as ctx: self.res_service.create_reservation(dto2) self.assertIn("Conflicto de horario", str(ctx.exception)) def test_02_capacity_aforo_validation(self): """Estadio 2.2: Debe bloquear reserva si asistentes esperados superan capacidad física.""" start = datetime(2026, 10, 6, 8, 0) end = datetime(2026, 10, 6, 12, 0) dto = ReservationCreateDTO( classroom_id=self.aula_fisica.id, commission_id=self.comision_a.id, user_id=self.docente1.id, start_time=start, end_time=end, expected_attendees=self.aula_fisica.capacity + 15 # supera capacidad (30) ) with self.assertRaises(ValueError) as ctx: self.res_service.create_reservation(dto) self.assertIn("Conflicto de aforo", str(ctx.exception)) def test_03_teacher_conflict_and_co_teaching_exception(self): """Estadio 2.2: Detección de conflicto docente y excepción justificada por co-docencia.""" start = datetime(2026, 10, 7, 14, 0) end = datetime(2026, 10, 7, 18, 0) # Docente 1 dicta clase en aula física dto1 = ReservationCreateDTO( classroom_id=self.aula_fisica.id, commission_id=self.comision_a.id, user_id=self.docente1.id, start_time=start, end_time=end, expected_attendees=20 ) self.res_service.create_reservation(dto1) # Mismo docente 1 intenta otra clase en sala virtual al mismo tiempo dto_conflict = ReservationCreateDTO( classroom_id=self.aula_virtual.id, commission_id=self.comision_b.id, user_id=self.docente1.id, start_time=start, end_time=end, expected_attendees=20 ) # Sin co-docencia debe bloquear with self.assertRaises(ValueError) as ctx: self.res_service.create_reservation(dto_conflict) self.assertIn("Conflicto de horario docente", str(ctx.exception)) # Ahora incorporamos a Docente 2 como co-docente en comision_b ct = CommissionTeacher( commission_id=self.comision_b.id, user_id=self.docente2.id, role='Adjunto' ) db.session.add(ct) db.session.commit() # Con co-docencia registrada, la matriz detecta cobertura docente y permite la reserva res_co = self.res_service.create_reservation(dto_conflict) self.assertIsNotNone(res_co.id) def test_04_student_same_day_restriction_rule(self): """Estadio 2.3: Un alumno no puede cursar dos asignaturas regulares el mismo día sin excepción.""" # Inscribir al alumno en comision_a (Lunes regular) enr1 = EnrollmentService.enroll_student( commission_id=self.comision_a.id, student_id=self.alumno1.id ) self.assertEqual(enr1.status, 'activo') # Intentar inscribir en comision_b (también Lunes regular): debe BLOQUEAR with self.assertRaises(ValueError) as ctx: EnrollmentService.enroll_student( commission_id=self.comision_b.id, student_id=self.alumno1.id ) self.assertIn("Restricción de cursada diaria", str(ctx.exception)) self.assertIn("Lunes", str(ctx.exception)) def test_05_student_short_course_bypass(self): """Estadio 2.3: Si una de las materias es curso corto, se autoriza automáticamente.""" # El alumno cursa comision_a los Lunes EnrollmentService.enroll_student( commission_id=self.comision_a.id, student_id=self.alumno1.id ) # Intentar inscribir en curso corto (Lunes taller): debe PERMITIR enr_short = EnrollmentService.enroll_student( commission_id=self.comision_c_corto.id, student_id=self.alumno1.id ) self.assertIsNotNone(enr_short.id) self.assertEqual(enr_short.status, 'activo') def test_06_student_bedelia_manual_exception_bypass(self): """Estadio 2.3: Con autorización expresa de Bedelía (allow_same_day_exception), se permite.""" # Alumno cursa comision_a los Lunes EnrollmentService.enroll_student( commission_id=self.comision_a.id, student_id=self.alumno1.id ) # Inscribir en comision_b con allow_same_day_exception=True enr2 = EnrollmentService.enroll_student( commission_id=self.comision_b.id, student_id=self.alumno1.id, allow_same_day_exception=True, exception_reason="Autorización especial de Bedelía por cambio de plan de estudios" ) self.assertIsNotNone(enr2.id) self.assertTrue(enr2.allow_same_day_exception) self.assertIn("cambio de plan", enr2.exception_reason) if __name__ == '__main__': unittest.main()