294 lines
11 KiB
Python
294 lines
11 KiB
Python
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()
|