Files
admin-edu-space/backend/tests/test_enrollment_and_commissions.py
T

540 lines
21 KiB
Python

"""
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)