""" test_enrollment_and_commissions.py Tests for the enrollment management system: - StudentEnrollment model CRUD - Commission management routes (Bedelia/Admin) - /mis-materias routes per role (Docente, Alumno, Admin, Bedelia) - RBAC enrollment permissions - Bedelia role deduplication (no accented duplicate) """ import unittest from app import create_app, db from app.models.user import User from app.models.role import Role, SYSTEM_MODULES from app.models.subject import Subject, Commission from app.models.enrollment import StudentEnrollment def _login_as(client, user_id): """Helper: inject Flask-Login session for the given user id.""" with client.session_transaction() as sess: sess['_user_id'] = str(user_id) sess['_fresh'] = True class TestStudentEnrollmentModel(unittest.TestCase): """Unit tests for the StudentEnrollment SQLAlchemy model.""" def setUp(self): self.app = create_app() self.app.config['TESTING'] = True self.app.config['WTF_CSRF_ENABLED'] = False self.ctx = self.app.app_context() self.ctx.push() def tearDown(self): self.ctx.pop() def test_table_exists(self): """student_enrollments table must exist in the database.""" result = db.session.execute( db.text("SELECT to_regclass('public.student_enrollments')") ).scalar() self.assertIsNotNone(result, "Tabla student_enrollments no existe en la BD") def test_model_columns(self): """StudentEnrollment must expose all required columns.""" cols = {c.name for c in StudentEnrollment.__table__.columns} for expected in ('id', 'student_id', 'commission_id', 'status', 'enrolled_at', 'notes'): self.assertIn(expected, cols, f"Columna faltante: {expected}") def test_unique_constraint_name(self): """Unique constraint uq_student_commission must exist.""" constraint_names = {c.name for c in StudentEnrollment.__table__.constraints} self.assertIn('uq_student_commission', constraint_names) def test_valid_statuses(self): """to_dict must return a status in the accepted set.""" alumno = User.query.filter( User.role.ilike('%ALUMNO%'), User.is_active == True ).first() comm = Commission.query.filter_by(active=True).first() if not alumno or not comm: self.skipTest("Se requiere al menos un alumno y una comision activa en la BD") # Check if already enrolled to avoid integrity error existing = StudentEnrollment.query.filter_by( student_id=alumno.id, commission_id=comm.id ).first() if not existing: enr = StudentEnrollment( student_id=alumno.id, commission_id=comm.id, status='activo', notes='Test enrollment' ) db.session.add(enr) db.session.commit() enrolled = enr else: enrolled = existing d = enrolled.to_dict() self.assertIn(d['status'], ['activo', 'retirado', 'condicional']) self.assertEqual(d['student_id'], alumno.id) self.assertEqual(d['commission_id'], comm.id) # Cleanup (only if we created it) if not existing: db.session.delete(enrolled) # Restore current_students counter comm.current_students = max(0, (comm.current_students or 1) - 1) db.session.commit() def test_relationships_accessible(self): """student and commission relationships must resolve without error.""" enr = StudentEnrollment.query.first() if enr: _ = enr.student _ = enr.commission class TestRBACEnrollmentModule(unittest.TestCase): """Tests for the enrollment RBAC module and role permissions.""" def setUp(self): self.app = create_app() self.app.config['TESTING'] = True self.ctx = self.app.app_context() self.ctx.push() def tearDown(self): self.ctx.pop() def test_enrollment_module_in_system_modules(self): """SYSTEM_MODULES must contain an 'enrollment' module.""" module_ids = [m['id'] for m in SYSTEM_MODULES] self.assertIn('enrollment', module_ids, "Modulo 'enrollment' no encontrado en SYSTEM_MODULES") def test_bedelia_has_enrollment_read_write(self): """Bedelia role must have read_write on enrollment.""" bedelia = Role.query.filter_by(name='Bedelia').first() self.assertIsNotNone(bedelia, "Rol 'Bedelia' no existe en la BD") self.assertEqual(bedelia.get_permission('enrollment'), 'read_write') self.assertTrue(bedelia.has_permission('enrollment', 'read_write')) def test_admin_has_enrollment_read_write(self): """Admin role must have read_write on enrollment.""" admin = Role.query.filter_by(name='Admin').first() self.assertIsNotNone(admin) self.assertEqual(admin.get_permission('enrollment'), 'read_write') def test_docente_has_enrollment_read(self): """Docente role must have read on enrollment (not write).""" docente = Role.query.filter_by(name='Docente').first() self.assertIsNotNone(docente) self.assertTrue(docente.has_permission('enrollment', 'read')) self.assertFalse(docente.has_permission('enrollment', 'read_write')) def test_alumno_has_enrollment_read(self): """Alumno role must have read on enrollment.""" alumno_role = Role.query.filter_by(name='Alumno').first() self.assertIsNotNone(alumno_role) self.assertTrue(alumno_role.has_permission('enrollment', 'read')) def test_no_duplicate_bedelia_roles(self): """There must be exactly one Bedelia role (no accented duplicate).""" bedelia_variants = Role.query.filter( Role.name.in_(['Bedelia', 'Bedelía', 'BEDELÍA']) ).all() names = [r.name for r in bedelia_variants] self.assertNotIn('Bedelía', names, "Rol duplicado 'Bedelía' (con acento) todavía existe en la BD") self.assertEqual(len(bedelia_variants), 1, f"Se encontraron {len(bedelia_variants)} variantes de Bedelia: {names}") class TestCommissionsManagementRoutes(unittest.TestCase): """Integration tests for /admin/commissions/ routes (Bedelia/Admin).""" def setUp(self): self.app = create_app() self.app.config['TESTING'] = True self.app.config['WTF_CSRF_ENABLED'] = False self.client = self.app.test_client() self.ctx = self.app.app_context() self.ctx.push() # Login as admin (first active user) self.admin = User.query.filter_by(email='admin@edu-space.com').first() self.bedelia = User.query.filter_by(email='bedelia@edu-space.com').first() self.docente = User.query.filter_by(email='docente@edu-space.com').first() self.alumno_user = User.query.filter_by(email='alumno@edu-space.com').first() def tearDown(self): self.ctx.pop() def _login(self, user): _login_as(self.client, user.id) def test_commissions_list_accessible_by_admin(self): """Admin can access /admin/commissions.""" self._login(self.admin) res = self.client.get('/admin/commissions') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn('Gestión de Comisiones', html) def test_commissions_list_accessible_by_bedelia(self): """Bedelia can access /admin/commissions.""" if not self.bedelia: self.skipTest("No bedelia user in DB") self._login(self.bedelia) res = self.client.get('/admin/commissions') self.assertEqual(res.status_code, 200) def test_commissions_list_forbidden_for_docente(self): """Docente without manage permissions should get 403 on /admin/commissions.""" if not self.docente: self.skipTest("No docente user in DB") self._login(self.docente) res = self.client.get('/admin/commissions') # Docente has 'academic: read' → can_manage() is False → 403 self.assertIn(res.status_code, [403, 302]) def test_commission_detail_accessible_by_admin(self): """Commission detail page returns 200 for an existing commission.""" self._login(self.admin) comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") res = self.client.get(f'/admin/commissions/{comm.id}') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn('Alumnos Inscriptos', html) self.assertIn('Docente Asignado', html) def test_commission_detail_accessible_by_bedelia(self): """Bedelia can view commission detail.""" if not self.bedelia: self.skipTest("No bedelia user in DB") self._login(self.bedelia) comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") res = self.client.get(f'/admin/commissions/{comm.id}') self.assertEqual(res.status_code, 200) def test_commission_detail_nonexistent_returns_404(self): """Non-existent commission ID must return 404.""" self._login(self.admin) res = self.client.get('/admin/commissions/999999') self.assertEqual(res.status_code, 404) def test_assign_teacher_post(self): """Bedelia can POST to assign a teacher to a commission.""" if not self.bedelia or not self.docente: self.skipTest("Need bedelia and docente users") self._login(self.bedelia) comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") res = self.client.post( f'/admin/commissions/{comm.id}/assign-teacher', data={'teacher_id': self.docente.id}, follow_redirects=True ) self.assertEqual(res.status_code, 200) # Verify assignment persisted db.session.refresh(comm) self.assertEqual(comm.teacher_id, self.docente.id) def test_enroll_student_and_unenroll(self): """Bedelia can enroll and unenroll an alumno in a commission.""" if not self.bedelia or not self.alumno_user: self.skipTest("Need bedelia and alumno users") self._login(self.bedelia) comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") # Clean up any prior enrollment to ensure idempotent test prior = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).first() if prior: db.session.delete(prior) db.session.commit() prev_count = comm.current_students or 0 # Enroll res = self.client.post( f'/admin/commissions/{comm.id}/enroll-student', data={'student_id': self.alumno_user.id, 'notes': 'Test'}, follow_redirects=True ) self.assertEqual(res.status_code, 200) enr = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).first() self.assertIsNotNone(enr, "Inscripción no encontrada tras POST enroll") self.assertEqual(enr.status, 'activo') db.session.refresh(comm) self.assertEqual(comm.current_students, prev_count + 1) # Unenroll res2 = self.client.post( f'/admin/commissions/{comm.id}/unenroll-student/{self.alumno_user.id}', follow_redirects=True ) self.assertEqual(res2.status_code, 200) enr_after = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).first() self.assertIsNone(enr_after, "Inscripción debería haber sido eliminada") db.session.refresh(comm) self.assertEqual(comm.current_students, prev_count) def test_update_enrollment_status(self): """Bedelia can change enrollment status (activo→condicional→retirado).""" if not self.bedelia or not self.alumno_user: self.skipTest("Need bedelia and alumno users") self._login(self.bedelia) comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") # Ensure enrollment exists enr = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).first() if not enr: enr = StudentEnrollment( student_id=self.alumno_user.id, commission_id=comm.id, status='activo' ) db.session.add(enr) db.session.commit() for new_status in ['condicional', 'retirado', 'activo']: res = self.client.post( f'/admin/commissions/{comm.id}/update-enrollment/{self.alumno_user.id}', data={'status': new_status}, follow_redirects=True ) self.assertEqual(res.status_code, 200) db.session.refresh(enr) self.assertEqual(enr.status, new_status) # Cleanup db.session.delete(enr) db.session.commit() def test_duplicate_enrollment_rejected(self): """Enrolling the same student twice in the same commission is rejected.""" if not self.bedelia or not self.alumno_user: self.skipTest("Need bedelia and alumno users") self._login(self.bedelia) comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") # Ensure a clean start prior = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).first() if not prior: enr = StudentEnrollment( student_id=self.alumno_user.id, commission_id=comm.id, status='activo' ) db.session.add(enr) db.session.commit() # Second enroll attempt res = self.client.post( f'/admin/commissions/{comm.id}/enroll-student', data={'student_id': self.alumno_user.id}, follow_redirects=True ) self.assertEqual(res.status_code, 200) # Should see a warning message, and only one enrollment should exist count = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).count() self.assertEqual(count, 1) # Cleanup StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).delete() db.session.commit() class TestMySubjectsRoutes(unittest.TestCase): """/mis-materias route tests per role.""" def setUp(self): self.app = create_app() self.app.config['TESTING'] = True self.app.config['WTF_CSRF_ENABLED'] = False self.client = self.app.test_client() self.ctx = self.app.app_context() self.ctx.push() self.admin = User.query.filter_by(email='admin@edu-space.com').first() self.bedelia = User.query.filter_by(email='bedelia@edu-space.com').first() self.docente = User.query.filter_by(email='docente@edu-space.com').first() self.alumno_user = User.query.filter_by(email='alumno@edu-space.com').first() def tearDown(self): self.ctx.pop() def _login(self, user): _login_as(self.client, user.id) def test_mis_materias_requires_login(self): """Unauthenticated request to /mis-materias/ must redirect to login.""" res = self.client.get('/mis-materias/') self.assertIn(res.status_code, [302, 401]) def test_mis_materias_admin_ok(self): """Admin sees all commissions at /mis-materias/.""" self._login(self.admin) res = self.client.get('/mis-materias/') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn('Todas las Comisiones', html) def test_mis_materias_bedelia_ok(self): """Bedelia sees all commissions at /mis-materias/.""" if not self.bedelia: self.skipTest("No bedelia user") self._login(self.bedelia) res = self.client.get('/mis-materias/') self.assertEqual(res.status_code, 200) def test_mis_materias_docente_ok(self): """Docente gets 200 at /mis-materias/ (even with no assigned commissions).""" if not self.docente: self.skipTest("No docente user") self._login(self.docente) res = self.client.get('/mis-materias/') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn('Mis Materias', html) def test_mis_materias_alumno_ok(self): """Alumno gets 200 at /mis-materias/ (even with no enrollments).""" if not self.alumno_user: self.skipTest("No alumno user") self._login(self.alumno_user) res = self.client.get('/mis-materias/') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn('Mis Materias', html) def test_mis_materias_detail_enrolled_alumno(self): """Alumno enrolled in a commission can see its detail page.""" if not self.alumno_user: self.skipTest("No alumno user") comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") # Create temporary enrollment enr = StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).first() cleanup_needed = False if not enr: enr = StudentEnrollment( student_id=self.alumno_user.id, commission_id=comm.id, status='activo' ) db.session.add(enr) db.session.commit() cleanup_needed = True self._login(self.alumno_user) res = self.client.get(f'/mis-materias/{comm.id}') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn('Tu inscripción', html) if cleanup_needed: db.session.delete(enr) db.session.commit() def test_mis_materias_detail_forbidden_for_unenrolled_alumno(self): """Alumno NOT enrolled in a commission gets 403 on its detail page.""" if not self.alumno_user: self.skipTest("No alumno user") comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") # Ensure alumno is NOT enrolled StudentEnrollment.query.filter_by( student_id=self.alumno_user.id, commission_id=comm.id ).delete() db.session.commit() self._login(self.alumno_user) res = self.client.get(f'/mis-materias/{comm.id}') self.assertEqual(res.status_code, 403) def test_mis_materias_detail_docente_own_commission(self): """Docente can see detail of their own assigned commission.""" if not self.docente: self.skipTest("No docente user") comm = Commission.query.filter_by(active=True).first() if not comm: self.skipTest("No active commission in DB") # Assign commission to docente prev_teacher = comm.teacher_id comm.teacher_id = self.docente.id db.session.commit() self._login(self.docente) res = self.client.get(f'/mis-materias/{comm.id}') self.assertEqual(res.status_code, 200) html = res.get_data(as_text=True) self.assertIn(comm.subject.name, html) # Restore comm.teacher_id = prev_teacher db.session.commit() def test_mis_materias_detail_docente_unauthorized_commission(self): """Docente cannot see a commission they are not assigned to.""" if not self.docente: self.skipTest("No docente user") # Find a commission where docente is NOT the teacher comm = Commission.query.filter( Commission.active == True, Commission.teacher_id != self.docente.id ).first() if not comm: self.skipTest("No commission without this docente in DB") self._login(self.docente) res = self.client.get(f'/mis-materias/{comm.id}') self.assertEqual(res.status_code, 403) if __name__ == '__main__': unittest.main(verbosity=2)