import unittest from datetime import datetime, timedelta from app import create_app, db from app.models.subject import Subject, Commission from app.models.classroom import Classroom from app.models.reservation import Reservation, ReservationStatus from app.models.user import User from app.models.role import Role from app.models.audit_log import AuditLog from app.models.genetic_algorithm import ReservationOptimizer from app.services.jwt_service import JWTService class TestPhase4InteractiveImpersonation(unittest.TestCase): """Pruebas unitarias e integrales para la Fase 4 del Roadmap MVP (UniCABA).""" @classmethod def setUpClass(cls): cls.app = create_app() cls.app.config['TESTING'] = True cls.app_context = cls.app.app_context() cls.app_context.push() db.create_all() # Roles cls.role_admin = Role.query.filter_by(name='Admin').first() if not cls.role_admin: cls.role_admin = Role(name='Admin', description='Rol Administrador') db.session.add(cls.role_admin) cls.role_alumno = Role.query.filter_by(name='Alumno').first() if not cls.role_alumno: cls.role_alumno = Role(name='Alumno', description='Rol Alumno') db.session.add(cls.role_alumno) cls.role_docente = Role.query.filter_by(name='Docente').first() if not cls.role_docente: cls.role_docente = Role(name='Docente', description='Rol Docente') db.session.add(cls.role_docente) db.session.commit() ts = int(datetime.utcnow().timestamp()) # Admin cls.admin = User( name=f'Super Administrador {ts}', email=f'admin_p4_{ts}@unicaba.edu.ar', role_id=cls.role_admin.id, role='Admin', is_active=True ) cls.admin.set_password('AdminSecret123!') db.session.add(cls.admin) # Docente cls.docente = User( name=f'Profesor Fase 4 {ts}', email=f'docente_p4_{ts}@unicaba.edu.ar', role_id=cls.role_docente.id, role='Docente', is_active=True ) cls.docente.set_password('DocSecret123!') db.session.add(cls.docente) # Alumno cls.alumno = User( name=f'Estudiante Fase 4 {ts}', email=f'alumno_p4_{ts}@unicaba.edu.ar', role_id=cls.role_alumno.id, role='Alumno', is_active=True ) cls.alumno.set_password('AlumSecret123!') db.session.add(cls.alumno) # Aulas cls.aula1 = Classroom( code=f'AULA-P4-1-{ts}', capacity=40, floor='1', building='Edificio Central', is_active=True ) cls.aula2 = Classroom( code=f'AULA-P4-2-{ts}', capacity=25, floor='2', building='Edificio Central', is_active=True ) db.session.add(cls.aula1) db.session.add(cls.aula2) # Materia y Comisión cls.subject = Subject( name=f'Gestión Interactiva {ts}', code=f'GI4-{ts}', is_active=True ) db.session.add(cls.subject) db.session.flush() cls.commission = Commission( subject_id=cls.subject.id, teacher_id=cls.docente.id, code='C1', shift='Mañana', schedule='Lunes 08:00 a 12:00', year=2026, semester='1C', max_students=30, active=True ) db.session.add(cls.commission) db.session.commit() cls.admin_id = cls.admin.id cls.docente_id = cls.docente.id cls.alumno_id = cls.alumno.id cls.aula1_id = cls.aula1.id cls.aula2_id = cls.aula2.id cls.commission_id = cls.commission.id cls.client = cls.app.test_client() # Tokens cls.admin_token = JWTService.create_access_token(cls.admin) cls.alumno_token = JWTService.create_access_token(cls.alumno) cls.docente_token = JWTService.create_access_token(cls.docente) @classmethod def tearDownClass(cls): db.session.remove() cls.app_context.pop() def tearDown(self): db.session.remove() def test_admin_impersonate_user_and_audit(self): """Superadmin inicia sesión como alumno y se genera bitácora inmutable.""" res = self.client.post( '/api/v1/admin/impersonate', headers={'Authorization': f'Bearer {self.admin_token}'}, json={'target_user_id': self.alumno_id} ) self.assertEqual(res.status_code, 200) data = res.get_json() self.assertEqual(data.get('status'), 'success') alumno = db.session.get(User, self.alumno_id) self.assertEqual(data['target_user']['email'], alumno.email) # Verificar auditoría audit = AuditLog.query.filter_by(action='IMPERSONATE_START', entity_id=self.alumno_id).first() self.assertIsNotNone(audit) self.assertEqual(audit.user_id, self.admin_id) self.assertIn('inició sesión temporal', audit.details) def test_non_admin_cannot_impersonate(self): """Usuarios no administradores (docente/alumno) tienen denegado el modo impersonación.""" # Intento por alumno res_alum = self.client.post( '/api/v1/admin/impersonate', headers={'Authorization': f'Bearer {self.alumno_token}'}, json={'target_user_id': self.docente_id} ) self.assertEqual(res_alum.status_code, 403) # Intento por docente res_doc = self.client.post( '/api/v1/admin/impersonate', headers={'Authorization': f'Bearer {self.docente_token}'}, json={'target_user_id': self.alumno_id} ) self.assertEqual(res_doc.status_code, 403) def test_impersonated_request_context_with_header(self): """La cabecera X-Impersonate-User sustituye la identidad para las consultas pero preserva admin real.""" res = self.client.get( '/api/v1/students/my-grades', headers={ 'Authorization': f'Bearer {self.admin_token}', 'X-Impersonate-User': str(self.alumno_id) } ) self.assertEqual(res.status_code, 200) data = res.get_json() self.assertEqual(data.get('student_id'), self.alumno_id) self.assertTrue(data.get('is_impersonating')) def test_stop_impersonating_and_audit(self): """Finalización formal de la impersonación con registro en bitácora.""" res = self.client.post( '/api/v1/admin/stop-impersonating', headers={ 'Authorization': f'Bearer {self.admin_token}', 'X-Impersonate-User': str(self.alumno_id) } ) self.assertEqual(res.status_code, 200) audit = AuditLog.query.filter_by(action='IMPERSONATE_END', user_id=self.admin_id).first() self.assertIsNotNone(audit) self.assertEqual(audit.user_id, self.admin_id) def test_drag_update_reservation_and_conflict_detection(self): """Actualización interactiva de reserva por arrastre con detección de colisión física.""" now = datetime.utcnow().replace(hour=8, minute=0, second=0, microsecond=0) + timedelta(days=2) end = now + timedelta(hours=4) # 1. Crear reserva existente en aula1 res1 = Reservation( classroom_id=self.aula1_id, commission_id=self.commission_id, user_id=self.docente_id, start_time=now, end_time=end, purpose='Clase regular', expected_attendees=25, status=ReservationStatus.CONFIRMED.value ) db.session.add(res1) db.session.commit() # 2. Arrastre exitoso hacia aula2 (vacía en ese horario) res_drag_ok = self.client.post( '/api/v1/reservations/drag-update', headers={'Authorization': f'Bearer {self.admin_token}'}, json={ 'reservation_id': res1.id, 'classroom_id': self.aula2_id, 'start_time': now.isoformat(), 'end_time': end.isoformat() } ) self.assertEqual(res_drag_ok.status_code, 200) self.assertEqual(res_drag_ok.get_json()['reservation']['classroom_id'], self.aula2_id) # 3. Intentar arrastrar una nueva reserva sobre aula2 en el mismo horario (colisión física) res_drag_conflict = self.client.post( '/api/v1/reservations/drag-update', headers={'Authorization': f'Bearer {self.admin_token}'}, json={ 'commission_id': self.commission_id, 'classroom_id': self.aula2_id, 'start_time': now.isoformat(), 'end_time': end.isoformat() } ) self.assertEqual(res_drag_conflict.status_code, 409) data_err = res_drag_conflict.get_json() self.assertEqual(data_err.get('status'), 'conflict') def test_genetic_algorithm_shift_schedule_and_apply(self): """Optimizador genético calcula horarios según turno real y persiste en BD.""" optimizer = ReservationOptimizer() comm = db.session.get(Commission, self.commission_id) # Verificar que calcula según turno Mañana pref = optimizer._calculate_preferred_time(comm) self.assertEqual(pref['start'].hour, 8) self.assertEqual(pref['end'].hour, 12) # Probar aplicación masiva de resultados válidos res_data = [{ 'commission_id': self.commission_id, 'classroom_id': self.aula1_id, 'user_id': self.admin_id, 'start_time': (datetime.utcnow() + timedelta(days=10)).isoformat(), 'end_time': (datetime.utcnow() + timedelta(days=10, hours=3)).isoformat(), 'purpose': 'Asignación Óptima con IA', 'expected_attendees': 20, 'status': 'CONFIRMED' }] applied = optimizer.apply_optimized_reservations(res_data) self.assertEqual(len(applied), 1) self.assertEqual(applied[0].classroom_id, self.aula1_id) if __name__ == '__main__': unittest.main()