feat: implementacion de dashboards por rol, soporte de clases virtuales concurrentes en importador de sheets y cartelera del dia
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
from app import create_app, db
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
app = create_app()
|
||||
# Configure testing DB
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['TESTING'] = True
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
@pytest.fixture
|
||||
def app_context(app):
|
||||
with app.app_context():
|
||||
yield
|
||||
|
||||
@pytest.fixture
|
||||
def init_database(app, app_context):
|
||||
db.create_all()
|
||||
yield db
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
@@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from app.services.jwt_service import JWTService
|
||||
from app.models.user import User
|
||||
|
||||
def test_get_dashboard_stats(client, app_context, init_database):
|
||||
"""Prueba que el endpoint del dashboard devuelva las métricas correctas en formato JSON"""
|
||||
# Create a test admin user if not exists
|
||||
admin = User.query.filter_by(email="admin@edu-space.com").first()
|
||||
if not admin:
|
||||
admin = User(email="admin@edu-space.com", name="Admin Test", role_id=1, role="admin", is_active=True)
|
||||
admin.set_password("admin123")
|
||||
from app import db
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
||||
access_token = JWTService.create_access_token(admin)
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}'
|
||||
}
|
||||
|
||||
response = client.get('/api/v1/dashboard/stats', headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['status'] == 'success'
|
||||
assert 'stats' in data
|
||||
assert 'total_classrooms' in data['stats']
|
||||
assert 'monthly_data' in data
|
||||
assert 'audit_logs' in data
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,219 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app.models.genetic_algorithm import GeneticAlgorithm, ReservationOptimizer, ReservationRequest, Gene, Individual
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.subject import Commission
|
||||
|
||||
|
||||
class TestGeneticAlgorithm(unittest.TestCase):
|
||||
"""Test cases for the Genetic Algorithm implementation"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.ga = GeneticAlgorithm(population_size=10, generations=5)
|
||||
|
||||
# Mock classrooms
|
||||
self.classrooms = [
|
||||
Classroom(id=1, code="A101", building="A", capacity=30, is_active=True),
|
||||
Classroom(id=2, code="A102", building="A", capacity=50, is_active=True),
|
||||
Classroom(id=3, code="B101", building="B", capacity=25, is_active=True),
|
||||
Classroom(id=4, code="B102", building="B", capacity=40, is_active=True),
|
||||
]
|
||||
|
||||
# Mock reservation requests
|
||||
start_time = datetime(2024, 1, 15, 9, 0, 0)
|
||||
end_time = datetime(2024, 1, 15, 11, 0, 0)
|
||||
|
||||
self.requests = [
|
||||
ReservationRequest(
|
||||
commission_id=1,
|
||||
expected_attendees=25,
|
||||
purpose="Math Class",
|
||||
preferred_start_time=start_time,
|
||||
preferred_end_time=end_time,
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
),
|
||||
ReservationRequest(
|
||||
commission_id=2,
|
||||
expected_attendees=35,
|
||||
purpose="Physics Lab",
|
||||
preferred_start_time=start_time,
|
||||
preferred_end_time=end_time,
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
),
|
||||
ReservationRequest(
|
||||
commission_id=3,
|
||||
expected_attendees=20,
|
||||
purpose="Chemistry Class",
|
||||
preferred_start_time=start_time,
|
||||
preferred_end_time=end_time,
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
),
|
||||
]
|
||||
|
||||
def test_individual_fitness_calculation(self):
|
||||
"""Test fitness calculation for an individual"""
|
||||
# Create an individual with some genes
|
||||
genes = [
|
||||
Gene(
|
||||
commission_id=1,
|
||||
classroom_id=1, # Capacity 30 for 25 students
|
||||
start_time=datetime(2024, 1, 15, 9, 0, 0),
|
||||
end_time=datetime(2024, 1, 15, 11, 0, 0),
|
||||
expected_attendees=25,
|
||||
purpose="Math Class"
|
||||
),
|
||||
Gene(
|
||||
commission_id=2,
|
||||
classroom_id=2, # Capacity 50 for 35 students
|
||||
start_time=datetime(2024, 1, 15, 9, 0, 0),
|
||||
end_time=datetime(2024, 1, 15, 11, 0, 0),
|
||||
expected_attendees=35,
|
||||
purpose="Physics Lab"
|
||||
)
|
||||
]
|
||||
|
||||
individual = Individual(genes)
|
||||
classrooms_dict = {c.id: c for c in self.classrooms}
|
||||
requests_dict = {r.commission_id: r for r in self.requests}
|
||||
|
||||
# Calculate fitness
|
||||
fitness = individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
|
||||
# Fitness should be positive
|
||||
self.assertGreater(fitness, 0)
|
||||
self.assertEqual(individual.fitness, fitness)
|
||||
|
||||
def test_conflict_detection(self):
|
||||
"""Test time conflict detection"""
|
||||
genes = [
|
||||
Gene(
|
||||
commission_id=1,
|
||||
classroom_id=1,
|
||||
start_time=datetime(2024, 1, 15, 9, 0, 0),
|
||||
end_time=datetime(2024, 1, 15, 11, 0, 0),
|
||||
expected_attendees=25,
|
||||
purpose="Math Class"
|
||||
),
|
||||
Gene(
|
||||
commission_id=2,
|
||||
classroom_id=1, # Same classroom
|
||||
start_time=datetime(2024, 1, 15, 10, 0, 0), # Overlapping time
|
||||
end_time=datetime(2024, 1, 15, 12, 0, 0),
|
||||
expected_attendees=35,
|
||||
purpose="Physics Lab"
|
||||
)
|
||||
]
|
||||
|
||||
individual = Individual(genes)
|
||||
classrooms_dict = {c.id: c for c in self.classrooms}
|
||||
requests_dict = {r.commission_id: r for r in self.requests[:2]}
|
||||
|
||||
# Calculate fitness - should detect conflicts
|
||||
fitness = individual.calculate_fitness(classrooms_dict, requests_dict)
|
||||
|
||||
# Should have conflicts
|
||||
self.assertGreater(len(individual.conflicts), 0)
|
||||
self.assertLess(fitness, 2.0) # Lower fitness due to conflicts
|
||||
|
||||
def test_crossover_operation(self):
|
||||
"""Test crossover operation"""
|
||||
parent1_genes = [
|
||||
Gene(1, 1, datetime.now(), datetime.now() + timedelta(hours=2), 25, "Class 1"),
|
||||
Gene(2, 2, datetime.now(), datetime.now() + timedelta(hours=2), 30, "Class 2")
|
||||
]
|
||||
parent2_genes = [
|
||||
Gene(3, 3, datetime.now(), datetime.now() + timedelta(hours=2), 20, "Class 3"),
|
||||
Gene(4, 4, datetime.now(), datetime.now() + timedelta(hours=2), 35, "Class 4")
|
||||
]
|
||||
|
||||
parent1 = Individual(parent1_genes)
|
||||
parent2 = Individual(parent2_genes)
|
||||
|
||||
child1, child2 = self.ga._crossover(parent1, parent2)
|
||||
|
||||
# Children should have genes from both parents
|
||||
self.assertGreater(len(child1.genes), 0)
|
||||
self.assertGreater(len(child2.genes), 0)
|
||||
|
||||
# Total genes should equal sum of parents
|
||||
total_genes = len(child1.genes) + len(child2.genes)
|
||||
self.assertEqual(total_genes, len(parent1_genes) + len(parent2_genes))
|
||||
|
||||
def test_tournament_selection(self):
|
||||
"""Test tournament selection"""
|
||||
population = []
|
||||
for i in range(5):
|
||||
genes = [
|
||||
Gene(i, 1, datetime.now(), datetime.now() + timedelta(hours=2), 25, f"Class {i}")
|
||||
]
|
||||
individual = Individual(genes)
|
||||
individual.fitness = i * 0.1 # Different fitness values
|
||||
population.append(individual)
|
||||
|
||||
selected = self.ga._tournament_selection(population)
|
||||
|
||||
# Should return an individual from the population
|
||||
self.assertIn(selected, population)
|
||||
|
||||
def test_capacity_score_calculation(self):
|
||||
"""Test capacity score calculation"""
|
||||
# Perfect match
|
||||
individual = Individual([])
|
||||
score = individual._calculate_capacity_score(self.classrooms[0], 30) #capacity 30, students 30
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
# Underfilled but efficient (27/30 = 11.1% waste, goes to next category)
|
||||
score = individual._calculate_capacity_score(self.classrooms[0], 27) # capacity 30, students 27
|
||||
self.assertEqual(score, 0.8)
|
||||
|
||||
# Overfilled
|
||||
score = individual._calculate_capacity_score(self.classrooms[0], 35) # capacity 30, students 35
|
||||
self.assertEqual(score, 0.0)
|
||||
|
||||
def test_optimization_process(self):
|
||||
"""Test the complete optimization process"""
|
||||
result = self.ga.optimize_reservations(
|
||||
self.requests,
|
||||
self.classrooms,
|
||||
datetime(2024, 1, 15),
|
||||
datetime(2024, 1, 20)
|
||||
)
|
||||
|
||||
# Should return best individual
|
||||
self.assertIsInstance(result, Individual)
|
||||
self.assertGreaterEqual(result.fitness, 0)
|
||||
self.assertGreater(len(result.genes), 0)
|
||||
|
||||
def test_reservation_optimizer_interface(self):
|
||||
"""Test the high-level optimizer interface"""
|
||||
optimizer = ReservationOptimizer()
|
||||
|
||||
# Test the interface methods exist
|
||||
self.assertTrue(hasattr(optimizer, 'optimize_schedule'))
|
||||
self.assertTrue(hasattr(optimizer, 'apply_optimized_reservations'))
|
||||
self.assertTrue(hasattr(optimizer, '_calculate_preferred_time'))
|
||||
|
||||
def test_reservation_request_validation(self):
|
||||
"""Test reservation request validation"""
|
||||
request = ReservationRequest(
|
||||
commission_id=1,
|
||||
expected_attendees=25,
|
||||
purpose="Test Class",
|
||||
preferred_start_time=datetime.now(),
|
||||
preferred_end_time=datetime.now() + timedelta(hours=2)
|
||||
)
|
||||
|
||||
# Should have required attributes
|
||||
self.assertEqual(request.commission_id, 1)
|
||||
self.assertEqual(request.expected_attendees, 25)
|
||||
self.assertEqual(request.purpose, "Test Class")
|
||||
self.assertEqual(request.priority, 1) # Default value
|
||||
self.assertEqual(request.flexibility_hours, 2) # Default value
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,85 @@
|
||||
import unittest
|
||||
import json
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
class TestJWTAndAPI(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
db.create_all()
|
||||
|
||||
# Buscar usuario activo o crear de prueba
|
||||
self.test_user = User.query.filter_by(is_active=True).first()
|
||||
if not self.test_user:
|
||||
self.test_user = User(
|
||||
email="test_api_admin@unicaba.edu.ar",
|
||||
name="Test API Admin",
|
||||
role="ADMIN",
|
||||
is_active=True
|
||||
)
|
||||
self.test_user.set_password("Test1234!")
|
||||
db.session.add(self.test_user)
|
||||
db.session.commit()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_jwt_generation_and_decoding(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
self.assertIn('access_token', tokens)
|
||||
self.assertIn('refresh_token', tokens)
|
||||
|
||||
payload = JWTService.decode_token(tokens['access_token'], expected_type='access')
|
||||
self.assertEqual(payload['sub'], str(self.test_user.id))
|
||||
self.assertEqual(payload['email'], self.test_user.email)
|
||||
|
||||
def test_jwt_revocation(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
token = tokens['access_token']
|
||||
# Antes de revocar: válido
|
||||
payload = JWTService.decode_token(token, expected_type='access')
|
||||
self.assertIsNotNone(payload)
|
||||
|
||||
# Revocar
|
||||
revoked = JWTService.revoke_token(token)
|
||||
self.assertTrue(revoked)
|
||||
|
||||
# Después de revocar: debe lanzar InvalidTokenError
|
||||
with self.assertRaises(Exception):
|
||||
JWTService.decode_token(token, expected_type='access')
|
||||
|
||||
def test_protected_endpoints_require_token(self):
|
||||
# Sin token debe dar 401
|
||||
res = self.client.get('/api/v1/auth/me')
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
res_classrooms = self.client.get('/api/v1/classrooms')
|
||||
self.assertEqual(res_classrooms.status_code, 401)
|
||||
|
||||
def test_authorized_endpoint_access(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
headers = {'Authorization': f"Bearer {tokens['access_token']}"}
|
||||
|
||||
res = self.client.get('/api/v1/auth/me', headers=headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.get_json()
|
||||
self.assertEqual(data['email'], self.test_user.email)
|
||||
|
||||
res_classrooms = self.client.get('/api/v1/classrooms', headers=headers)
|
||||
self.assertEqual(res_classrooms.status_code, 200)
|
||||
|
||||
def test_token_refresh_flow(self):
|
||||
tokens = JWTService.generate_tokens(self.test_user)
|
||||
res = self.client.post('/api/v1/auth/refresh', json={'refresh_token': tokens['refresh_token']})
|
||||
self.assertEqual(res.status_code, 200)
|
||||
data = res.get_json()
|
||||
self.assertIn('access_token', data)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
import unittest
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
|
||||
class TestRoleDashboardAndMenu(unittest.TestCase):
|
||||
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.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
# Usar usuario administrador para autenticación en sesión de pruebas
|
||||
self.user = User.query.filter_by(is_active=True).first()
|
||||
if self.user:
|
||||
with self.client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(self.user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
|
||||
def test_user_role_methods(self):
|
||||
"""Verifica la normalización y detección de roles en el modelo User"""
|
||||
u = User(name="Test Teacher", email="prof@test.edu", role="Docente")
|
||||
self.assertEqual(u.get_institutional_role(), "docente")
|
||||
self.assertTrue(u.is_docente())
|
||||
self.assertFalse(u.is_admin())
|
||||
self.assertFalse(u.is_alumno())
|
||||
self.assertFalse(u.is_bedelia())
|
||||
|
||||
u_bedelia = User(name="Test Bedelia", email="bedelia@test.edu", role="Bedelia")
|
||||
self.assertEqual(u_bedelia.get_institutional_role(), "bedelia")
|
||||
self.assertTrue(u_bedelia.is_bedelia())
|
||||
|
||||
u_student = User(name="Test Student", email="student@test.edu", role="Alumno")
|
||||
self.assertEqual(u_student.get_institutional_role(), "alumno")
|
||||
self.assertTrue(u_student.is_alumno())
|
||||
|
||||
u_admin = User(name="Test Admin", email="admin@test.edu", role="ADMIN")
|
||||
self.assertEqual(u_admin.get_institutional_role(), "admin")
|
||||
self.assertTrue(u_admin.is_admin())
|
||||
|
||||
badge = u_admin.role_badge_display
|
||||
self.assertIn("badge-admin", badge["class"])
|
||||
self.assertIn("Administrador", badge["label"])
|
||||
|
||||
def test_admin_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard administrativo renderiza KPIs y accesos de administración"""
|
||||
res = self.client.get('/?view_as=admin')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Aulas Totales', html)
|
||||
self.assertIn('Oferta de Materias', html)
|
||||
self.assertIn('Usuarios Activos', html)
|
||||
self.assertIn('Módulos de Gestión & Visualización de Menús', html)
|
||||
self.assertIn('Espacios & Sedes', html)
|
||||
self.assertIn('Auditoría & RBAC', html)
|
||||
|
||||
def test_bedelia_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard de bedelía renderiza métricas operativas de campus"""
|
||||
res = self.client.get('/?view_as=bedelia')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Aulas Presenciales', html)
|
||||
self.assertIn('Salas Virtuales', html)
|
||||
self.assertIn('Acciones Rápidas & Menús Operativos de Bedelía', html)
|
||||
self.assertIn('Cartelera del Día', html)
|
||||
self.assertIn('Sincronizar Sheets', html)
|
||||
|
||||
def test_docente_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard del docente renderiza sus clases y accesos directos"""
|
||||
res = self.client.get('/?view_as=docente')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Mis Clases Hoy', html)
|
||||
self.assertIn('Mis Reservas Activas', html)
|
||||
self.assertIn('Reservar Aula', html)
|
||||
self.assertIn('Accesos Directos de Docencia & Visualización de Menús', html)
|
||||
self.assertIn('Mis Clases Programadas para Hoy', html)
|
||||
|
||||
def test_alumno_dashboard_rendered(self):
|
||||
"""Verifica que el dashboard del alumno muestra la brújula diaria y herramientas de cursada"""
|
||||
res = self.client.get('/?view_as=alumno')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('¿Dónde curso hoy? - Cartelera del Alumno', html)
|
||||
self.assertIn('Menú del Alumno & Herramientas de Cursada', html)
|
||||
self.assertIn('Cartelera de Hoy', html)
|
||||
self.assertIn('Cronograma Semanal', html)
|
||||
self.assertIn('Aulas Virtuales', html)
|
||||
|
||||
def test_audit_logs_view(self):
|
||||
"""Verifica que la vista de auditoría institucional responde correctamente"""
|
||||
res = self.client.get('/admin/audit-logs')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
self.assertIn('Registro de Auditoría Institucional', html)
|
||||
self.assertIn('Trazabilidad completa de operaciones', html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,42 @@
|
||||
import unittest
|
||||
from app import create_app, db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.services.audit_service import AuditService
|
||||
|
||||
class TestSecurityChainAndAudit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_security_headers_injected(self):
|
||||
res = self.client.get('/login')
|
||||
self.assertIn('X-Content-Type-Options', res.headers)
|
||||
self.assertEqual(res.headers['X-Content-Type-Options'], 'nosniff')
|
||||
self.assertIn('X-Frame-Options', res.headers)
|
||||
self.assertEqual(res.headers['X-Frame-Options'], 'SAMEORIGIN')
|
||||
self.assertIn('X-XSS-Protection', res.headers)
|
||||
self.assertEqual(res.headers['X-XSS-Protection'], '1; mode=block')
|
||||
|
||||
def test_audit_log_creation(self):
|
||||
entry = AuditService.log(
|
||||
action='TEST_ACTION',
|
||||
module='classrooms',
|
||||
entity_id=999,
|
||||
details={'test': True, 'desc': 'Prueba unitaria de auditoría'}
|
||||
)
|
||||
self.assertIsNotNone(entry.id)
|
||||
self.assertEqual(entry.action, 'TEST_ACTION')
|
||||
self.assertEqual(entry.module, 'classrooms')
|
||||
|
||||
recent = AuditService.get_recent_logs(limit=5, module='classrooms')
|
||||
self.assertTrue(any(e.id == entry.id for e in recent))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.building import Building
|
||||
from app.models.subject import Commission
|
||||
from app.schemas.classroom_dto import ClassroomCreateDTO
|
||||
from app.schemas.reservation_dto import ReservationCreateDTO
|
||||
from app.services.classroom_service import ClassroomService
|
||||
from app.services.reservation_service import ReservationService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
class TestServicesAndDTO(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
self.classroom_service = ClassroomService()
|
||||
self.reservation_service = ReservationService()
|
||||
self.user_service = UserService()
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_dto_validation(self):
|
||||
# Valid ClassroomCreateDTO
|
||||
dto = ClassroomCreateDTO(code="Aula Test 101", floor="1", capacity=40, building_name="Edificio Central")
|
||||
self.assertEqual(dto.code, "Aula Test 101")
|
||||
self.assertEqual(dto.capacity, 40)
|
||||
|
||||
# Invalid Reservation range (end before start)
|
||||
with self.assertRaises(Exception):
|
||||
ReservationCreateDTO(
|
||||
classroom_id=1,
|
||||
commission_id=1,
|
||||
user_id=1,
|
||||
start_time=datetime(2026, 9, 10, 10, 0),
|
||||
end_time=datetime(2026, 9, 10, 9, 0),
|
||||
purpose="Clase Test"
|
||||
)
|
||||
|
||||
def test_classroom_service_listing(self):
|
||||
classrooms = self.classroom_service.list_classrooms()
|
||||
self.assertIsInstance(classrooms, list)
|
||||
|
||||
def test_user_service_profile(self):
|
||||
user = User.query.first()
|
||||
if user:
|
||||
profile = self.user_service.get_profile_data(user)
|
||||
self.assertIn('email', profile)
|
||||
self.assertIn('role', profile)
|
||||
self.assertIn('permissions', profile)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,275 @@
|
||||
import unittest
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
from app.models.role import Role
|
||||
from app.models.classroom import Classroom
|
||||
from app.models.genetic_algorithm import (
|
||||
GeneticAlgorithm,
|
||||
ReservationRequest,
|
||||
Individual,
|
||||
Gene
|
||||
)
|
||||
from app.services.optimizer_service import OptimizerService, OptimizerJobStatus
|
||||
from app.services.jwt_service import JWTService
|
||||
|
||||
|
||||
class Sprint4OptimizerAndConcurrencyTestCase(unittest.TestCase):
|
||||
"""Pruebas exhaustivas para el Sprint 4: Optimizador Heurístico, Concurrencia y Spring Boot."""
|
||||
|
||||
def setUp(self):
|
||||
self.app = create_app()
|
||||
self.app.config['TESTING'] = True
|
||||
self.app.config['WTF_CSRF_ENABLED'] = False
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
self.client = self.app.test_client()
|
||||
|
||||
# Obtener usuario existente o crear
|
||||
self.user = User.query.filter_by(is_active=True).first()
|
||||
if not self.user:
|
||||
admin_role = Role.query.filter_by(name='Admin').first()
|
||||
if not admin_role:
|
||||
admin_role = Role(name='Admin', description='Administrador')
|
||||
db.session.add(admin_role)
|
||||
db.session.commit()
|
||||
self.user = User(
|
||||
name='Sprint 4 Admin',
|
||||
email='admin_sprint4@edu-space.com',
|
||||
role_id=admin_role.id,
|
||||
is_active=True
|
||||
)
|
||||
self.user.set_password('admin123')
|
||||
db.session.add(self.user)
|
||||
db.session.commit()
|
||||
|
||||
# Obtener aulas activas de la base de datos
|
||||
self.classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
if not self.classrooms:
|
||||
for i in range(1, 11):
|
||||
room = Classroom(
|
||||
room_number=f"AULA-{100 + i}",
|
||||
capacity=30 + (i * 5),
|
||||
is_virtual=(i == 10),
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(room)
|
||||
db.session.commit()
|
||||
self.classrooms = Classroom.query.filter_by(is_active=True).all()
|
||||
|
||||
# Token JWT para el usuario de prueba
|
||||
token_pair = JWTService.generate_tokens(self.user)
|
||||
self.access_token = token_pair['access_token']
|
||||
self.auth_headers = {
|
||||
'Authorization': f'Bearer {self.access_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
db.session.rollback()
|
||||
self.app_context.pop()
|
||||
|
||||
def test_01_fast_fitness_and_conflict_detection(self):
|
||||
"""Verifica la detección de solapamiento O(K log K) y cálculo de fitness."""
|
||||
now = datetime(2026, 3, 10, 18, 0, 0)
|
||||
c1 = self.classrooms[0]
|
||||
|
||||
# Gene 1: 18:00 a 20:00 en AULA-101
|
||||
g1 = Gene(
|
||||
commission_id=1,
|
||||
classroom_id=c1.id,
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=2),
|
||||
expected_attendees=30,
|
||||
purpose="Clase Algoritmos"
|
||||
)
|
||||
# Gene 2: 19:00 a 21:00 en la MISMA aula (conflicto directo de 19:00 a 20:00)
|
||||
g2 = Gene(
|
||||
commission_id=2,
|
||||
classroom_id=c1.id,
|
||||
start_time=now + timedelta(hours=1),
|
||||
end_time=now + timedelta(hours=3),
|
||||
expected_attendees=30,
|
||||
purpose="Clase Base de Datos"
|
||||
)
|
||||
|
||||
ind = Individual([g1, g2])
|
||||
classrooms_dict = {c.id: c for c in self.classrooms}
|
||||
req1 = ReservationRequest(1, 30, "Clase Algoritmos", now, now + timedelta(hours=2))
|
||||
req2 = ReservationRequest(2, 30, "Clase Base de Datos", now + timedelta(hours=1), now + timedelta(hours=3))
|
||||
requests_dict = {1: req1, 2: req2}
|
||||
|
||||
fit = ind.calculate_fitness(classrooms_dict, requests_dict)
|
||||
self.assertGreater(len(ind.conflicts), 0, "Debe detectar conflicto de solapamiento de horario")
|
||||
|
||||
def test_02_benchmark_200_commissions_under_5_seconds(self):
|
||||
"""Hito de Evaluación Sprint 4: Optimización de 200+ comisiones en menos de 5 segundos."""
|
||||
base_time = datetime(2026, 3, 16, 8, 0, 0)
|
||||
requests = []
|
||||
|
||||
# Crear 200 peticiones de comisión distribuidas en turnos
|
||||
for i in range(1, 201):
|
||||
day_offset = (i % 5) # Lun a Vie
|
||||
slot_offset = (i % 4) * 3 # 08:00, 11:00, 14:00, 18:00
|
||||
start = base_time + timedelta(days=day_offset, hours=slot_offset)
|
||||
req = ReservationRequest(
|
||||
commission_id=i,
|
||||
expected_attendees=25 + (i % 25),
|
||||
purpose=f"Comisión {i}",
|
||||
preferred_start_time=start,
|
||||
preferred_end_time=start + timedelta(hours=2),
|
||||
priority=1,
|
||||
flexibility_hours=2
|
||||
)
|
||||
requests.append(req)
|
||||
|
||||
# Configurar optimizador con paralelización (4 workers)
|
||||
ga = GeneticAlgorithm(
|
||||
population_size=30,
|
||||
generations=25,
|
||||
workers=4
|
||||
)
|
||||
|
||||
t_start = time.perf_counter()
|
||||
best = ga.optimize_reservations(
|
||||
requests=requests,
|
||||
available_classrooms=self.classrooms,
|
||||
start_date=base_time,
|
||||
end_date=base_time + timedelta(days=7)
|
||||
)
|
||||
t_elapsed = time.perf_counter() - t_start
|
||||
|
||||
self.assertIsNotNone(best)
|
||||
self.assertEqual(len(best.genes), 200, "Debe haber asignado las 200 comisiones")
|
||||
self.assertLess(t_elapsed, 5.0, f"El algoritmo debe resolver 200 comisiones en < 5s (demoró {t_elapsed:.2f}s)")
|
||||
|
||||
def test_03_optimizer_service_async_queue(self):
|
||||
"""Verifica la cola de trabajos asíncrona de OptimizerService."""
|
||||
# Enviar un job en segundo plano con app_context
|
||||
job = OptimizerService.submit_job(
|
||||
commission_ids=[1, 2],
|
||||
admin_user_id=self.user.id,
|
||||
generations=10,
|
||||
population_size=10,
|
||||
workers=2,
|
||||
app=self.app
|
||||
)
|
||||
|
||||
self.assertIsNotNone(job.job_id)
|
||||
self.assertIn(job.status, [OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING, OptimizerJobStatus.COMPLETED])
|
||||
|
||||
# Esperar a que el worker complete la ejecución (máximo 4 segundos)
|
||||
waited = 0.0
|
||||
while job.status in (OptimizerJobStatus.PENDING, OptimizerJobStatus.RUNNING) and waited < 4.0:
|
||||
time.sleep(0.1)
|
||||
waited += 0.1
|
||||
|
||||
self.assertEqual(job.status, OptimizerJobStatus.COMPLETED, f"El trabajo debió completar con éxito, error: {job.error}")
|
||||
self.assertEqual(job.progress, 100)
|
||||
self.assertIsNotNone(job.completed_at)
|
||||
self.assertIsNotNone(job.execution_time_ms)
|
||||
|
||||
# Consultar via list_jobs y get_job
|
||||
found = OptimizerService.get_job(job.job_id)
|
||||
self.assertIsNotNone(found)
|
||||
self.assertEqual(found.job_id, job.job_id)
|
||||
|
||||
all_jobs = OptimizerService.list_jobs()
|
||||
self.assertTrue(any(j['job_id'] == job.job_id for j in all_jobs))
|
||||
|
||||
def test_04_spring_boot_sync_payload_endpoint(self):
|
||||
"""Verifica el endpoint de interoperabilidad REST/JWT con Spring Boot."""
|
||||
now = datetime(2026, 4, 1, 18, 30, 0)
|
||||
payload = {
|
||||
"requestId": "REQ-SPRING-BOOT-2026-001",
|
||||
"campusCode": "SEDE-CENTRAL",
|
||||
"academicTerm": "2026-1C",
|
||||
"commissions": [
|
||||
{
|
||||
"commissionId": 101,
|
||||
"subjectCode": "INFO-101",
|
||||
"subjectName": "Programación Orientada a Objetos",
|
||||
"expectedAttendees": 35,
|
||||
"preferredShift": "NOCHE",
|
||||
"preferredStart": now.isoformat(),
|
||||
"preferredEnd": (now + timedelta(hours=3)).isoformat()
|
||||
},
|
||||
{
|
||||
"commissionId": 102,
|
||||
"subjectCode": "MATH-201",
|
||||
"subjectName": "Análisis Matemático II",
|
||||
"expectedAttendees": 40,
|
||||
"preferredShift": "NOCHE",
|
||||
"preferredStart": now.isoformat(),
|
||||
"preferredEnd": (now + timedelta(hours=3)).isoformat()
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"generations": 15,
|
||||
"populationSize": 15,
|
||||
"workers": 2
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.post(
|
||||
'/api/v1/optimizer/spring-boot/sync',
|
||||
json=payload,
|
||||
headers=self.auth_headers
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.get_json()
|
||||
self.assertEqual(data.get('requestId'), "REQ-SPRING-BOOT-2026-001")
|
||||
self.assertEqual(data.get('status'), "SUCCESS")
|
||||
self.assertEqual(data.get('totalCommissions'), 2)
|
||||
self.assertIn('scheduledAssignments', data)
|
||||
self.assertIn('executionTimeMs', data)
|
||||
self.assertEqual(len(data['scheduledAssignments']), 2)
|
||||
|
||||
def test_05_api_async_job_submission_and_polling(self):
|
||||
"""Verifica la API REST de despacho asíncrono y sondeo (polling)."""
|
||||
payload = {
|
||||
"commission_ids": [1, 2],
|
||||
"generations": 10,
|
||||
"population_size=10": None, # será ignorado o testeado con defaults
|
||||
"generations": 10,
|
||||
"population_size": 10,
|
||||
"workers": 2
|
||||
}
|
||||
|
||||
# 1. Enviar job (debe retornar 202 Accepted)
|
||||
post_res = self.client.post(
|
||||
'/api/v1/optimizer/jobs',
|
||||
json=payload,
|
||||
headers=self.auth_headers
|
||||
)
|
||||
self.assertEqual(post_res.status_code, 202)
|
||||
post_data = post_res.get_json()
|
||||
self.assertIn('job_id', post_data)
|
||||
job_id = post_data['job_id']
|
||||
self.assertEqual(post_data['poll_url'], f'/api/v1/optimizer/jobs/{job_id}')
|
||||
|
||||
# 2. Consultar estado del job
|
||||
get_res = self.client.get(
|
||||
f'/api/v1/optimizer/jobs/{job_id}',
|
||||
headers=self.auth_headers
|
||||
)
|
||||
self.assertEqual(get_res.status_code, 200)
|
||||
get_data = get_res.get_json()
|
||||
self.assertEqual(get_data['job_id'], job_id)
|
||||
self.assertIn(get_data['status'], ['PENDING', 'RUNNING', 'COMPLETED'])
|
||||
|
||||
# 3. Listar trabajos
|
||||
list_res = self.client.get(
|
||||
'/api/v1/optimizer/jobs',
|
||||
headers=self.auth_headers
|
||||
)
|
||||
self.assertEqual(list_res.status_code, 200)
|
||||
list_data = list_res.get_json()
|
||||
self.assertGreaterEqual(list_data['total'], 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from app import create_app, db
|
||||
from app.models.user import User
|
||||
|
||||
class TestViewSwitcher(unittest.TestCase):
|
||||
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.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
# Login con usuario admin para acceder a las vistas protegidas
|
||||
self.user = User.query.filter_by(is_active=True).first()
|
||||
if self.user:
|
||||
with self.client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(self.user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
|
||||
def test_classrooms_view_switcher_rendered(self):
|
||||
res = self.client.get('/classrooms/?view_mode=physical')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
# Verificar que el selector de vista está presente
|
||||
self.assertIn('view-switcher-group', html)
|
||||
self.assertIn('data-context="classrooms"', html)
|
||||
self.assertIn('data-view-container="cards"', html)
|
||||
self.assertIn('data-view-container="table"', html)
|
||||
|
||||
def test_reservations_view_switcher_rendered(self):
|
||||
res = self.client.get('/schedule/list')
|
||||
self.assertEqual(res.status_code, 200)
|
||||
html = res.get_data(as_text=True)
|
||||
# Verificar que el selector de vista está presente
|
||||
self.assertIn('view-switcher-group', html)
|
||||
self.assertIn('data-context="reservations"', html)
|
||||
self.assertIn('data-view-container="table"', html)
|
||||
self.assertIn('data-view-container="cards"', html)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user