Compare commits
No commits in common. "73174acd736aba48c6bac7ad197a1ec40dba1d8d" and "0d9c3d46ca55409b76c77ceee6156762f4136e49" have entirely different histories.
73174acd73
...
0d9c3d46ca
90
CLAUDE.md
|
|
@ -907,96 +907,6 @@ docs(readme): update setup instructions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Actualización de Documentación (OBLIGATORIO)
|
|
||||||
|
|
||||||
> **REGLA:** Después de implementar cambios significativos, SIEMPRE actualizar la documentación correspondiente.
|
|
||||||
|
|
||||||
### Cuándo Actualizar
|
|
||||||
|
|
||||||
| Tipo de Cambio | Documentos a Actualizar |
|
|
||||||
|----------------|------------------------|
|
|
||||||
| Nueva entidad/campo en dominio | `DI-002-modelo-dominio.md`, `02-domain-model.puml` |
|
|
||||||
| Nuevo endpoint GraphQL | `DI-004-esquema-graphql.md` |
|
|
||||||
| Nueva funcionalidad | `AN-001-requisitos-funcionales.md`, `AN-003-historias-usuario.md` |
|
|
||||||
| Cambios en arquitectura | `DI-001-arquitectura-backend.md`, `04-components.puml` |
|
|
||||||
| Nuevo flujo de usuario | `03-sequence-*.puml`, historia de usuario correspondiente |
|
|
||||||
| Cambios en BD | `DI-003-diseno-base-datos.md`, `05-entity-relationship.puml` |
|
|
||||||
| Nuevas rutas/páginas | `DI-005-arquitectura-frontend.md`, `DI-006-componentes-ui.md` |
|
|
||||||
| Cambios en despliegue | `DEPLOYMENT.md`, `07-deployment.puml` |
|
|
||||||
| Cualquier cambio significativo | `ENTREGABLES.md`, `README.md` |
|
|
||||||
|
|
||||||
### Archivos de Documentación
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/
|
|
||||||
├── entregables/
|
|
||||||
│ ├── 01-analisis/ # Requisitos, historias, reglas
|
|
||||||
│ ├── 02-diseno/ # Arquitectura, modelo, esquemas
|
|
||||||
│ └── 03-configuracion/ # Setup, variables, calidad
|
|
||||||
├── architecture/
|
|
||||||
│ ├── decisions/ # ADRs (decisiones arquitectónicas)
|
|
||||||
│ └── diagrams/ # Diagramas PUML + SVG
|
|
||||||
├── qa/ # Reportes de QA
|
|
||||||
├── ENTREGABLES.md # Resumen ejecutivo
|
|
||||||
├── DEPLOYMENT.md # Guía de despliegue
|
|
||||||
└── README.md (raíz) # Documentación principal
|
|
||||||
```
|
|
||||||
|
|
||||||
### Diagramas PlantUML
|
|
||||||
|
|
||||||
**Ubicación:** `docs/architecture/diagrams/`
|
|
||||||
|
|
||||||
| Diagrama | Archivo | Actualizar cuando... |
|
|
||||||
|----------|---------|---------------------|
|
|
||||||
| Casos de Uso | `01-use-cases.puml` | Nuevas funcionalidades de usuario |
|
|
||||||
| Modelo Dominio | `02-domain-model.puml` | Cambios en entidades/relaciones |
|
|
||||||
| Secuencia | `03-sequence-*.puml` | Nuevos flujos o cambios en existentes |
|
|
||||||
| Componentes | `04-components.puml` | Cambios en arquitectura |
|
|
||||||
| E-R | `05-entity-relationship.puml` | Cambios en base de datos |
|
|
||||||
| Estados | `06-state-*.puml` | Nuevos estados o transiciones |
|
|
||||||
| Despliegue | `07-deployment.puml` | Cambios en infraestructura |
|
|
||||||
| C4 | `08-c4-context.puml` | Cambios en contexto del sistema |
|
|
||||||
|
|
||||||
**Validar y regenerar PUML después de modificar:**
|
|
||||||
```bash
|
|
||||||
# Validar que compila (sin errores)
|
|
||||||
cat docs/architecture/diagrams/archivo.puml | plantuml -tpng -pipe > /dev/null && echo "OK" || echo "ERROR"
|
|
||||||
|
|
||||||
# Regenerar PNG y SVG
|
|
||||||
cd docs/architecture/diagrams
|
|
||||||
cat archivo.puml | plantuml -tpng -pipe > archivo.png
|
|
||||||
cat archivo.puml | plantuml -tsvg -pipe > archivo.svg
|
|
||||||
|
|
||||||
# Validar TODOS los diagramas
|
|
||||||
for f in docs/architecture/diagrams/*.puml; do
|
|
||||||
echo -n "Validando $f... "
|
|
||||||
cat "$f" | plantuml -tpng -pipe > /dev/null 2>&1 && echo "OK" || echo "ERROR"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
### Proceso de Actualización
|
|
||||||
|
|
||||||
1. **Identificar** qué documentos afecta el cambio
|
|
||||||
2. **Actualizar** archivos MD con la nueva información
|
|
||||||
3. **Modificar** diagramas PUML si aplica
|
|
||||||
4. **Validar** que los PUML compilan sin errores
|
|
||||||
5. **Regenerar** PNG y SVG de diagramas modificados
|
|
||||||
6. **Verificar** consistencia entre documentos
|
|
||||||
|
|
||||||
> **IMPORTANTE:** SIEMPRE validar que los archivos PUML compilan antes de hacer commit. Un diagrama con errores de sintaxis rompe la documentación.
|
|
||||||
|
|
||||||
### Ejemplo
|
|
||||||
|
|
||||||
Si se agrega un nuevo campo `ActivationCode` a la entidad `Student`:
|
|
||||||
|
|
||||||
1. ✅ Actualizar `DI-002-modelo-dominio.md` (agregar campo)
|
|
||||||
2. ✅ Actualizar `02-domain-model.puml` (diagrama de clases)
|
|
||||||
3. ✅ Actualizar `DI-004-esquema-graphql.md` (si se expone en API)
|
|
||||||
4. ✅ Actualizar `AN-001-requisitos-funcionales.md` (si es nuevo requisito)
|
|
||||||
5. ✅ Actualizar `ENTREGABLES.md` (resumen de funcionalidades)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ciclo OODA
|
## Ciclo OODA
|
||||||
|
|
||||||
1. **OBSERVAR:** ¿Qué se solicita? ¿Qué existe?
|
1. **OBSERVAR:** ¿Qué se solicita? ¿Qué existe?
|
||||||
|
|
|
||||||
45
README.md
|
|
@ -27,8 +27,6 @@ Sistema web para gestionar inscripciones de estudiantes en materias con restricc
|
||||||
- Inscripción/cancelación de materias con validación de reglas
|
- Inscripción/cancelación de materias con validación de reglas
|
||||||
- Visualización de compañeros de clase por materia
|
- Visualización de compañeros de clase por materia
|
||||||
- Interfaz responsive con Angular Material
|
- Interfaz responsive con Angular Material
|
||||||
- **Sistema de autenticación con flujo de activación**
|
|
||||||
- **Control de acceso por roles (Admin/Student)**
|
|
||||||
|
|
||||||
### Calidad y Robustez
|
### Calidad y Robustez
|
||||||
- **Manejo de errores**: Mensajes amigables para usuarios + logging detallado para desarrolladores
|
- **Manejo de errores**: Mensajes amigables para usuarios + logging detallado para desarrolladores
|
||||||
|
|
@ -60,7 +58,7 @@ cd Interrapidisimo
|
||||||
### Paso 2: Iniciar SQL Server con Docker
|
### Paso 2: Iniciar SQL Server con Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=Asde71.4Asde71.4" \
|
docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=Your_password123" \
|
||||||
-p 1433:1433 --name sqlserver -d mcr.microsoft.com/mssql/server:2022-latest
|
-p 1433:1433 --name sqlserver -d mcr.microsoft.com/mssql/server:2022-latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -238,7 +236,7 @@ docker restart sqlserver
|
||||||
|
|
||||||
# Conectar con sqlcmd
|
# Conectar con sqlcmd
|
||||||
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
||||||
-S localhost -U sa -P "Asde71.4Asde71.4" -C
|
-S localhost -U sa -P "Your_password123" -C
|
||||||
```
|
```
|
||||||
|
|
||||||
## API GraphQL
|
## API GraphQL
|
||||||
|
|
@ -329,7 +327,7 @@ docker start sqlserver
|
||||||
|
|
||||||
# Verificar conexión
|
# Verificar conexión
|
||||||
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
docker exec -it sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
||||||
-S localhost -U sa -P "Asde71.4Asde71.4" -C -Q "SELECT 1"
|
-S localhost -U sa -P "Your_password123" -C -Q "SELECT 1"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Puerto 5000 en uso
|
### Puerto 5000 en uso
|
||||||
|
|
@ -369,7 +367,7 @@ Verificar que el password en `appsettings.json` coincida con el del contenedor D
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=localhost;Database=StudentEnrollment;User Id=sa;Password=Asde71.4Asde71.4;TrustServerCertificate=True"
|
"DefaultConnection": "Server=localhost;Database=StudentEnrollment;User Id=sa;Password=Your_password123;TrustServerCertificate=True"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -486,41 +484,8 @@ Ver [DEPLOYMENT.md](docs/DEPLOYMENT.md) para instrucciones detalladas.
|
||||||
- [Despliegue](docs/architecture/diagrams/07-deployment.svg)
|
- [Despliegue](docs/architecture/diagrams/07-deployment.svg)
|
||||||
- [C4 Contexto](docs/architecture/diagrams/08-c4-context.svg)
|
- [C4 Contexto](docs/architecture/diagrams/08-c4-context.svg)
|
||||||
|
|
||||||
## Autenticación y Roles
|
## Seguridad
|
||||||
|
|
||||||
### Flujo de Activación de Estudiantes
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Admin crea estudiante → Sistema genera código de activación (12 chars)
|
|
||||||
2. Admin comparte código/URL con estudiante
|
|
||||||
3. Estudiante accede a /activate?code=XXXX
|
|
||||||
4. Estudiante crea credenciales (usuario + contraseña)
|
|
||||||
5. Sistema genera código de recuperación (mostrado una sola vez)
|
|
||||||
6. Estudiante inicia sesión → Dashboard personal
|
|
||||||
```
|
|
||||||
|
|
||||||
### Roles del Sistema
|
|
||||||
|
|
||||||
| Rol | Permisos |
|
|
||||||
|-----|----------|
|
|
||||||
| **Admin** | CRUD estudiantes, ver todo, generar códigos de activación |
|
|
||||||
| **Student** | Dashboard personal, inscribir materias, ver compañeros |
|
|
||||||
|
|
||||||
### URLs de Autenticación
|
|
||||||
|
|
||||||
| Ruta | Descripción |
|
|
||||||
|------|-------------|
|
|
||||||
| `/login` | Inicio de sesión |
|
|
||||||
| `/activate?code=XXX` | Activación de cuenta |
|
|
||||||
| `/dashboard` | Dashboard de estudiante |
|
|
||||||
| `/admin` | Panel de administración |
|
|
||||||
|
|
||||||
### Seguridad
|
|
||||||
|
|
||||||
- **JWT** con HMAC-SHA256, expiración configurable
|
|
||||||
- **PBKDF2-SHA256** para hashing de contraseñas (100,000 iteraciones)
|
|
||||||
- **Código de activación:** 12 caracteres, expira en 48 horas
|
|
||||||
- **Código de recuperación:** 12 caracteres, se muestra solo una vez
|
|
||||||
- Input validation con FluentValidation
|
- Input validation con FluentValidation
|
||||||
- Sanitización contra XSS
|
- Sanitización contra XSS
|
||||||
- Security headers (CSP, HSTS, X-Frame-Options)
|
- Security headers (CSP, HSTS, X-Frame-Options)
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
# =============================================================================
|
|
||||||
# SQL Server 2017 Express - Low RAM Edition
|
|
||||||
# =============================================================================
|
|
||||||
# Imagen optimizada para ejecutar SQL Server con bajo consumo de memoria
|
|
||||||
#
|
|
||||||
# Autor: andresgarcia0313
|
|
||||||
# Repositorio: https://hub.docker.com/r/andresgarcia0313/mssql-express-lowram
|
|
||||||
# GitHub: https://github.com/andresgarcia0313
|
|
||||||
#
|
|
||||||
# USO RAPIDO:
|
|
||||||
# docker run -d --name mssql --memory=384m \
|
|
||||||
# -e MSSQL_SA_PASSWORD=TuPassword123! \
|
|
||||||
# -p 1433:1433 andresgarcia0313/mssql-express-lowram
|
|
||||||
#
|
|
||||||
# VARIABLES DE ENTORNO:
|
|
||||||
# MSSQL_SA_PASSWORD (REQUERIDO) - Password del usuario SA
|
|
||||||
# MSSQL_PID (opcional) - Edición: Express, Developer, Standard
|
|
||||||
# MSSQL_MEMORY_LIMIT_MB (opcional) - Límite de memoria en MB (default: 340)
|
|
||||||
#
|
|
||||||
# RAM RECOMENDADA: 384MB - 512MB (mínimo viable para desarrollo)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/mssql/server:2017-latest
|
|
||||||
|
|
||||||
LABEL maintainer="andresgarcia0313 <andresgarcia0313@gmail.com>"
|
|
||||||
LABEL org.opencontainers.image.title="SQL Server 2017 Express - Low RAM"
|
|
||||||
LABEL org.opencontainers.image.description="SQL Server 2017 Express optimizado para bajo consumo de RAM (384MB-512MB). Ideal para desarrollo local y CI/CD."
|
|
||||||
LABEL org.opencontainers.image.version="1.0"
|
|
||||||
LABEL org.opencontainers.image.vendor="andresgarcia0313"
|
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
|
||||||
LABEL org.opencontainers.image.source="https://github.com/andresgarcia0313"
|
|
||||||
|
|
||||||
# Variables de entorno por defecto (MSSQL_SA_PASSWORD debe ser proporcionada)
|
|
||||||
ENV ACCEPT_EULA=Y \
|
|
||||||
MSSQL_PID=Express \
|
|
||||||
MSSQL_MEMORY_LIMIT_MB=340
|
|
||||||
|
|
||||||
# Copiar scripts de optimización
|
|
||||||
COPY --chmod=755 entrypoint.sh /opt/mssql/bin/entrypoint.sh
|
|
||||||
COPY --chmod=644 optimize.sql /opt/mssql/bin/optimize.sql
|
|
||||||
|
|
||||||
# Puerto SQL Server
|
|
||||||
EXPOSE 1433
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
||||||
CMD /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -Q "SELECT 1" || exit 1
|
|
||||||
|
|
||||||
# Entrypoint personalizado que aplica optimizaciones al iniciar
|
|
||||||
ENTRYPOINT ["/opt/mssql/bin/entrypoint.sh"]
|
|
||||||
|
|
@ -1,671 +0,0 @@
|
||||||
# SQL Server 2017 Express - Low RAM Edition 🐳
|
|
||||||
|
|
||||||
[](https://hub.docker.com/r/andresgarcia0313/mssql-express-lowram)
|
|
||||||
[](https://hub.docker.com/r/andresgarcia0313/mssql-express-lowram)
|
|
||||||
|
|
||||||
Imagen de Docker optimizada para ejecutar **SQL Server 2017 Express** con **bajo consumo de RAM** (384MB - 512MB).
|
|
||||||
|
|
||||||
## ✨ Características
|
|
||||||
|
|
||||||
- 🎯 **RAM mínima:** 384MB (vs 2GB oficial de Microsoft)
|
|
||||||
- ⚡ **Optimizaciones automáticas** al iniciar el contenedor
|
|
||||||
- 🔧 **Trace flags** preconfigurados para estabilidad
|
|
||||||
- 🏥 **Health check** integrado
|
|
||||||
- 📝 **Logs informativos** del proceso de optimización
|
|
||||||
- 🔐 **Contraseña configurable** por variable de entorno
|
|
||||||
|
|
||||||
## 🚀 Uso Rápido
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mssql \
|
|
||||||
--memory=384m \
|
|
||||||
--memory-swap=2g \
|
|
||||||
-e MSSQL_SA_PASSWORD=MiPassword123! \
|
|
||||||
-p 1433:1433 \
|
|
||||||
andresgarcia0313/mssql-express-lowram
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Variables de Entorno
|
|
||||||
|
|
||||||
### Variables Requeridas
|
|
||||||
|
|
||||||
| Variable | Descripción |
|
|
||||||
|----------|-------------|
|
|
||||||
| `MSSQL_SA_PASSWORD` | **REQUERIDO.** Password del usuario SA (administrador) |
|
|
||||||
|
|
||||||
### Variables Opcionales
|
|
||||||
|
|
||||||
| Variable | Default | Descripción |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `MSSQL_PID` | `Express` | Edición de SQL Server |
|
|
||||||
| `MSSQL_MEMORY_LIMIT_MB` | `340` | Límite de memoria interna de SQL Server (MB) |
|
|
||||||
| `MSSQL_COLLATION` | `SQL_Latin1_General_CP1_CI_AS` | Collation de la instancia |
|
|
||||||
| `MSSQL_LCID` | `1033` | ID de idioma (1033=English, 3082=Spanish) |
|
|
||||||
| `MSSQL_TCP_PORT` | `1433` | Puerto TCP de SQL Server |
|
|
||||||
| `TZ` | `UTC` | Zona horaria del contenedor |
|
|
||||||
| `ACCEPT_EULA` | `Y` | Ya aceptada en la imagen |
|
|
||||||
|
|
||||||
### Valores de MSSQL_PID
|
|
||||||
|
|
||||||
| Valor | Descripción | Límites |
|
|
||||||
|-------|-------------|---------|
|
|
||||||
| `Express` | **Recomendado.** Gratuito para desarrollo y producción | 1GB RAM, 10GB DB |
|
|
||||||
| `Developer` | Todas las características, solo desarrollo | Sin límites |
|
|
||||||
| `Standard` | Requiere licencia | 128GB RAM |
|
|
||||||
| `Enterprise` | Requiere licencia | Sin límites |
|
|
||||||
|
|
||||||
### Requisitos del Password
|
|
||||||
|
|
||||||
El password de SA debe cumplir:
|
|
||||||
- Mínimo **8 caracteres**
|
|
||||||
- Al menos una letra **mayúscula** (A-Z)
|
|
||||||
- Al menos una letra **minúscula** (a-z)
|
|
||||||
- Al menos un **número** (0-9)
|
|
||||||
- Al menos un **caracter especial** (!@#$%^&*)
|
|
||||||
|
|
||||||
**Ejemplos válidos:** `MiPassword123!`, `Secure@Pass99`, `Dev#Server2024`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💾 Configuración de Memoria y Swap
|
|
||||||
|
|
||||||
### Parámetros de Docker para Memoria
|
|
||||||
|
|
||||||
| Parámetro | Descripción |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `--memory` | Límite de RAM física para el contenedor |
|
|
||||||
| `--memory-swap` | Límite total (RAM + Swap). Debe ser mayor que `--memory` |
|
|
||||||
| `--memory-reservation` | Reserva mínima de RAM (soft limit) |
|
|
||||||
| `--memory-swappiness` | Preferencia de uso de swap (0-100) |
|
|
||||||
| `--oom-kill-disable` | Deshabilita OOM killer (usar con cuidado) |
|
|
||||||
|
|
||||||
### Configuración Recomendada por Tipo de Disco
|
|
||||||
|
|
||||||
#### 💿 Para SSD/NVMe (Recomendado)
|
|
||||||
|
|
||||||
El swap en SSD es rápido, permite configuración más agresiva:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mssql \
|
|
||||||
--memory=384m \
|
|
||||||
--memory-swap=4g \
|
|
||||||
--memory-reservation=256m \
|
|
||||||
--memory-swappiness=70 \
|
|
||||||
-e MSSQL_SA_PASSWORD=MiPassword123! \
|
|
||||||
-e MSSQL_PID=Express \
|
|
||||||
-e MSSQL_MEMORY_LIMIT_MB=340 \
|
|
||||||
-p 1433:1433 \
|
|
||||||
-v mssql_data:/var/opt/mssql/data \
|
|
||||||
andresgarcia0313/mssql-express-lowram
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parámetro | Valor | Razón |
|
|
||||||
|-----------|-------|-------|
|
|
||||||
| `--memory` | 384m | RAM mínima viable |
|
|
||||||
| `--memory-swap` | 4g | Swap generoso (SSD es rápido) |
|
|
||||||
| `--memory-swappiness` | 60 | Balance entre RAM y swap |
|
|
||||||
|
|
||||||
#### 🗄️ Para HDD (Disco Mecánico)
|
|
||||||
|
|
||||||
El swap en HDD es lento, minimizar su uso:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mssql \
|
|
||||||
--memory=512m \
|
|
||||||
--memory-swap=1g \
|
|
||||||
--memory-reservation=384m \
|
|
||||||
--memory-swappiness=10 \
|
|
||||||
-e MSSQL_SA_PASSWORD=MiPassword123! \
|
|
||||||
-e MSSQL_PID=Express \
|
|
||||||
-e MSSQL_MEMORY_LIMIT_MB=450 \
|
|
||||||
-p 1433:1433 \
|
|
||||||
-v mssql_data:/var/opt/mssql/data \
|
|
||||||
andresgarcia0313/mssql-express-lowram
|
|
||||||
```
|
|
||||||
|
|
||||||
| Parámetro | Valor | Razón |
|
|
||||||
|-----------|-------|-------|
|
|
||||||
| `--memory` | 512m | Más RAM para evitar swap |
|
|
||||||
| `--memory-swap` | 1g | Swap mínimo (HDD lento) |
|
|
||||||
| `--memory-swappiness` | 10 | Evitar swap excepto emergencia |
|
|
||||||
|
|
||||||
### Tabla de Configuraciones Recomendadas
|
|
||||||
|
|
||||||
| Escenario | RAM | Swap | Swappiness | MSSQL_MEMORY_LIMIT_MB |
|
|
||||||
|-----------|-----|------|------------|----------------------|
|
|
||||||
| **Mínimo SSD** | 384m | 4g | 60 | 340 |
|
|
||||||
| **Mínimo HDD** | 512m | 1g | 10 | 450 |
|
|
||||||
| **Desarrollo** | 512m | 2g | 30 | 450 |
|
|
||||||
| **CI/CD** | 512m | 2g | 30 | 450 |
|
|
||||||
| **Múltiples DBs** | 768m | 2g | 20 | 680 |
|
|
||||||
| **Carga moderada** | 1g | 2g | 10 | 900 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚡ Configuración para Máximo Rendimiento
|
|
||||||
|
|
||||||
### Desarrollo Local - Máximo Rendimiento
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mssql-performance \
|
|
||||||
--memory=1g \
|
|
||||||
--memory-swap=2g \
|
|
||||||
--memory-swappiness=10 \
|
|
||||||
--cpus=2 \
|
|
||||||
-e MSSQL_SA_PASSWORD=MiPassword123! \
|
|
||||||
-e MSSQL_PID=Developer \
|
|
||||||
-e MSSQL_MEMORY_LIMIT_MB=900 \
|
|
||||||
-e TZ=America/Bogota \
|
|
||||||
-p 1433:1433 \
|
|
||||||
-v mssql_data:/var/opt/mssql/data \
|
|
||||||
-v mssql_log:/var/opt/mssql/log \
|
|
||||||
andresgarcia0313/mssql-express-lowram
|
|
||||||
```
|
|
||||||
|
|
||||||
### Máxima Estabilidad (Producción-like)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mssql-stable \
|
|
||||||
--memory=768m \
|
|
||||||
--memory-swap=2g \
|
|
||||||
--memory-reservation=512m \
|
|
||||||
--memory-swappiness=20 \
|
|
||||||
--restart=unless-stopped \
|
|
||||||
--cpus=2 \
|
|
||||||
-e MSSQL_SA_PASSWORD=MiPassword123! \
|
|
||||||
-e MSSQL_PID=Express \
|
|
||||||
-e MSSQL_MEMORY_LIMIT_MB=680 \
|
|
||||||
-p 1433:1433 \
|
|
||||||
-v /path/to/data:/var/opt/mssql/data \
|
|
||||||
-v /path/to/log:/var/opt/mssql/log \
|
|
||||||
-v /path/to/backup:/var/opt/mssql/backup \
|
|
||||||
andresgarcia0313/mssql-express-lowram
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recursos Mínimos Absolutos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mssql-minimal \
|
|
||||||
--memory=384m \
|
|
||||||
--memory-swap=6g \
|
|
||||||
--memory-swappiness=80 \
|
|
||||||
-e MSSQL_SA_PASSWORD=MiPassword123! \
|
|
||||||
-e MSSQL_PID=Express \
|
|
||||||
-e MSSQL_MEMORY_LIMIT_MB=300 \
|
|
||||||
-p 1433:1433 \
|
|
||||||
andresgarcia0313/mssql-express-lowram
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ Con recursos mínimos, el rendimiento dependerá mucho del swap (requiere SSD).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Optimizaciones Incluidas en la Imagen
|
|
||||||
|
|
||||||
### Configuración de SQL Server (Automática)
|
|
||||||
|
|
||||||
| Setting | Valor | Propósito |
|
|
||||||
|---------|-------|-----------|
|
|
||||||
| `max server memory` | 320 MB | Límite del buffer pool |
|
|
||||||
| `min server memory` | 128 MB | Permite liberar RAM bajo presión |
|
|
||||||
| `max degree of parallelism` | 2 | Reduce consumo en queries paralelas |
|
|
||||||
| `cost threshold for parallelism` | 50 | Solo queries costosas usan paralelismo |
|
|
||||||
| `optimize for ad hoc workloads` | ON | Reduce caché de planes únicos |
|
|
||||||
| `recovery interval` | 1 min | Recuperación rápida tras crash |
|
|
||||||
|
|
||||||
### Trace Flags Activos (Automáticos)
|
|
||||||
|
|
||||||
| Flag | Propósito |
|
|
||||||
|------|-----------|
|
|
||||||
| **TF 1117** | Crecimiento uniforme de archivos de datos |
|
|
||||||
| **TF 1118** | Reduce contención en tempdb (extents uniformes) |
|
|
||||||
| **TF 3226** | Suprime logs de backup exitoso (menos I/O) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Docker Compose
|
|
||||||
|
|
||||||
### Configuración Básica
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
sqlserver:
|
|
||||||
image: andresgarcia0313/mssql-express-lowram
|
|
||||||
container_name: mssql
|
|
||||||
mem_limit: 512m
|
|
||||||
memswap_limit: 2g
|
|
||||||
mem_reservation: 384m
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- MSSQL_SA_PASSWORD=MiPassword123!
|
|
||||||
- MSSQL_PID=Express
|
|
||||||
- MSSQL_MEMORY_LIMIT_MB=450
|
|
||||||
- TZ=America/Bogota
|
|
||||||
ports:
|
|
||||||
- "1433:1433"
|
|
||||||
volumes:
|
|
||||||
- mssql_data:/var/opt/mssql/data
|
|
||||||
- mssql_log:/var/opt/mssql/log
|
|
||||||
healthcheck:
|
|
||||||
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1"
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mssql_data:
|
|
||||||
mssql_log:
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuración Avanzada (SSD + Rendimiento)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
sqlserver:
|
|
||||||
image: andresgarcia0313/mssql-express-lowram
|
|
||||||
container_name: mssql
|
|
||||||
mem_limit: 768m
|
|
||||||
memswap_limit: 4g
|
|
||||||
mem_reservation: 512m
|
|
||||||
cpus: 2
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MSSQL_SA_PASSWORD: ${MSSQL_PASSWORD:-MiPassword123!}
|
|
||||||
MSSQL_PID: Express
|
|
||||||
MSSQL_MEMORY_LIMIT_MB: 680
|
|
||||||
MSSQL_COLLATION: Modern_Spanish_CI_AS
|
|
||||||
TZ: America/Bogota
|
|
||||||
ports:
|
|
||||||
- "1433:1433"
|
|
||||||
volumes:
|
|
||||||
- mssql_data:/var/opt/mssql/data
|
|
||||||
- mssql_log:/var/opt/mssql/log
|
|
||||||
- mssql_backup:/var/opt/mssql/backup
|
|
||||||
healthcheck:
|
|
||||||
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1"
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
start_period: 30s
|
|
||||||
retries: 3
|
|
||||||
logging:
|
|
||||||
driver: "json-file"
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mssql_data:
|
|
||||||
driver: local
|
|
||||||
mssql_log:
|
|
||||||
driver: local
|
|
||||||
mssql_backup:
|
|
||||||
driver: local
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔌 Conexión
|
|
||||||
|
|
||||||
### Desde línea de comandos
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Usando sqlcmd dentro del contenedor
|
|
||||||
docker exec -it mssql /opt/mssql-tools/bin/sqlcmd \
|
|
||||||
-S localhost -U sa -P 'MiPassword123!'
|
|
||||||
|
|
||||||
# Usando sqlcmd local
|
|
||||||
sqlcmd -S localhost,1433 -U sa -P 'MiPassword123!'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Connection Strings
|
|
||||||
|
|
||||||
**.NET / C#**
|
|
||||||
```csharp
|
|
||||||
Server=localhost,1433;Database=MiDB;User Id=sa;Password=MiPassword123!;TrustServerCertificate=True;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Node.js (mssql)**
|
|
||||||
```javascript
|
|
||||||
const config = {
|
|
||||||
server: 'localhost',
|
|
||||||
port: 1433,
|
|
||||||
user: 'sa',
|
|
||||||
password: 'MiPassword123!',
|
|
||||||
database: 'MiDB',
|
|
||||||
options: { trustServerCertificate: true }
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Python (pyodbc)**
|
|
||||||
```python
|
|
||||||
conn_str = (
|
|
||||||
"DRIVER={ODBC Driver 17 for SQL Server};"
|
|
||||||
"SERVER=localhost,1433;"
|
|
||||||
"DATABASE=MiDB;"
|
|
||||||
"UID=sa;"
|
|
||||||
"PWD=MiPassword123!;"
|
|
||||||
"TrustServerCertificate=yes;"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Java (JDBC)**
|
|
||||||
```java
|
|
||||||
String url = "jdbc:sqlserver://localhost:1433;database=MiDB;user=sa;password=MiPassword123!;trustServerCertificate=true;";
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Ejemplos de Uso
|
|
||||||
|
|
||||||
### CI/CD (GitHub Actions)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
sqlserver:
|
|
||||||
image: andresgarcia0313/mssql-express-lowram
|
|
||||||
env:
|
|
||||||
MSSQL_SA_PASSWORD: TestPassword123!
|
|
||||||
MSSQL_PID: Express
|
|
||||||
MSSQL_MEMORY_LIMIT_MB: 450
|
|
||||||
ports:
|
|
||||||
- 1433:1433
|
|
||||||
options: >-
|
|
||||||
--memory=512m
|
|
||||||
--memory-swap=2g
|
|
||||||
--health-cmd="/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P TestPassword123! -Q 'SELECT 1'"
|
|
||||||
--health-interval=10s
|
|
||||||
--health-timeout=5s
|
|
||||||
--health-retries=5
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Pod
|
|
||||||
metadata:
|
|
||||||
name: mssql-lowram
|
|
||||||
spec:
|
|
||||||
containers:
|
|
||||||
- name: mssql
|
|
||||||
image: andresgarcia0313/mssql-express-lowram
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: "768Mi"
|
|
||||||
requests:
|
|
||||||
memory: "512Mi"
|
|
||||||
env:
|
|
||||||
- name: MSSQL_SA_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: mssql-secret
|
|
||||||
key: sa-password
|
|
||||||
- name: MSSQL_PID
|
|
||||||
value: "Express"
|
|
||||||
- name: MSSQL_MEMORY_LIMIT_MB
|
|
||||||
value: "680"
|
|
||||||
ports:
|
|
||||||
- containerPort: 1433
|
|
||||||
livenessProbe:
|
|
||||||
exec:
|
|
||||||
command:
|
|
||||||
- /opt/mssql-tools/bin/sqlcmd
|
|
||||||
- -S
|
|
||||||
- localhost
|
|
||||||
- -U
|
|
||||||
- sa
|
|
||||||
- -P
|
|
||||||
- $(MSSQL_SA_PASSWORD)
|
|
||||||
- -Q
|
|
||||||
- SELECT 1
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ Advertencias
|
|
||||||
|
|
||||||
- **No recomendado para producción pesada** - La RAM es insuficiente para cargas grandes
|
|
||||||
- **Swap en HDD** - Experimentará lentitud significativa, usar SSD cuando sea posible
|
|
||||||
- **Realizar backups frecuentes** en entornos con recursos limitados
|
|
||||||
- Los **trace flags** se aplican en cada inicio (no persisten en la imagen)
|
|
||||||
- `MSSQL_MEMORY_LIMIT_MB` debe ser **menor** que `--memory` (dejar ~60MB para el OS)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Troubleshooting
|
|
||||||
|
|
||||||
### Ver logs de optimización
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs mssql 2>&1 | grep LOWRAM
|
|
||||||
```
|
|
||||||
|
|
||||||
### Verificar configuración aplicada
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec mssql /opt/mssql-tools/bin/sqlcmd \
|
|
||||||
-S localhost -U sa -P 'MiPassword123!' \
|
|
||||||
-Q "SELECT name, value_in_use FROM sys.configurations WHERE name LIKE '%memory%' OR name LIKE '%parallel%'"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ver uso de memoria real
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker stats mssql --no-stream
|
|
||||||
```
|
|
||||||
|
|
||||||
### El contenedor no inicia
|
|
||||||
|
|
||||||
1. Verificar que el password cumple los requisitos
|
|
||||||
2. Verificar que hay suficiente RAM: `docker stats`
|
|
||||||
3. Revisar logs: `docker logs mssql`
|
|
||||||
4. Verificar swap disponible: `free -h`
|
|
||||||
|
|
||||||
### Rendimiento lento
|
|
||||||
|
|
||||||
1. Verificar tipo de disco (SSD vs HDD)
|
|
||||||
2. Aumentar `--memory` si es posible
|
|
||||||
3. Reducir `--memory-swappiness` en HDD
|
|
||||||
4. Verificar que no hay otros contenedores compitiendo por recursos
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔬 Proceso de Optimización (Trazabilidad)
|
|
||||||
|
|
||||||
Esta imagen fue creada mediante un proceso de investigación y optimización documentado. A continuación se detalla el proceso completo para reproducibilidad y trazabilidad.
|
|
||||||
|
|
||||||
### Problema Original
|
|
||||||
|
|
||||||
Microsoft SQL Server 2017 tiene un **requisito mínimo oficial de 2GB de RAM**, lo cual es excesivo para:
|
|
||||||
- Desarrollo local en equipos con recursos limitados
|
|
||||||
- Pipelines de CI/CD
|
|
||||||
- Contenedores en entornos de pruebas
|
|
||||||
- Microservicios que solo necesitan una DB pequeña
|
|
||||||
|
|
||||||
### Investigación Realizada
|
|
||||||
|
|
||||||
1. **Documentación oficial consultada:**
|
|
||||||
- [Configure SQL Server Docker Containers](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-docker-container-configure)
|
|
||||||
- [SQL Server Linux Performance Best Practices](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-performance-best-practices)
|
|
||||||
- [KB4347055 - Fix OOM in Docker containers](https://support.microsoft.com/en-us/topic/kb4347055)
|
|
||||||
- [mssql-conf Configuration Tool](https://learn.microsoft.com/en-us/sql/linux/sql-server-linux-configure-mssql-conf)
|
|
||||||
|
|
||||||
2. **Hallazgos clave:**
|
|
||||||
- `MSSQL_MEMORY_LIMIT_MB` existe pero requiere versiones recientes
|
|
||||||
- SQL Server 2017 CU10+ incluye fix para respetar límites de contenedor
|
|
||||||
- El 80% de RAM por defecto es configurable vía `sp_configure`
|
|
||||||
- Trace flags pueden mejorar estabilidad en entornos con poca RAM
|
|
||||||
|
|
||||||
### Optimizaciones Aplicadas
|
|
||||||
|
|
||||||
#### 1. Configuración de SQL Server (`sp_configure`)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Ejecutado automáticamente al iniciar el contenedor
|
|
||||||
|
|
||||||
-- Limitar buffer pool (principal consumidor de RAM)
|
|
||||||
EXEC sp_configure 'max server memory', 320;
|
|
||||||
|
|
||||||
-- Permitir liberar memoria bajo presión del sistema
|
|
||||||
EXEC sp_configure 'min server memory', 128;
|
|
||||||
|
|
||||||
-- Reducir caché de planes para queries ad-hoc (ahorra RAM)
|
|
||||||
EXEC sp_configure 'optimize for ad hoc workloads', 1;
|
|
||||||
|
|
||||||
-- Limitar paralelismo (operaciones paralelas consumen más RAM)
|
|
||||||
EXEC sp_configure 'max degree of parallelism', 2;
|
|
||||||
|
|
||||||
-- Solo queries costosas usan paralelismo
|
|
||||||
EXEC sp_configure 'cost threshold for parallelism', 50;
|
|
||||||
|
|
||||||
-- Checkpoints frecuentes para recuperación rápida
|
|
||||||
EXEC sp_configure 'recovery interval (min)', 1;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Trace Flags para Estabilidad
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Ejecutados automáticamente al iniciar
|
|
||||||
|
|
||||||
-- TF 1117: Crecimiento uniforme de archivos
|
|
||||||
-- Evita que un archivo crezca más que otros, reduciendo fragmentación
|
|
||||||
DBCC TRACEON(1117, -1);
|
|
||||||
|
|
||||||
-- TF 1118: Extents uniformes en tempdb
|
|
||||||
-- Reduce contención de páginas mixtas, mejora rendimiento
|
|
||||||
DBCC TRACEON(1118, -1);
|
|
||||||
|
|
||||||
-- TF 3226: Suprime logs de backup exitoso
|
|
||||||
-- Reduce I/O en el log de errores
|
|
||||||
DBCC TRACEON(3226, -1);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Entrypoint Personalizado
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# El entrypoint realiza:
|
|
||||||
# 1. Valida que MSSQL_SA_PASSWORD esté definido
|
|
||||||
# 2. Inicia SQL Server en foreground
|
|
||||||
# 3. En background, espera que SQL Server esté listo
|
|
||||||
# 4. Aplica todas las optimizaciones automáticamente
|
|
||||||
# 5. Muestra logs informativos del proceso
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Health Check Integrado
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
||||||
CMD /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" -Q "SELECT 1"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pruebas de Validación
|
|
||||||
|
|
||||||
La imagen fue probada durante **6+ minutos** de monitoreo continuo:
|
|
||||||
|
|
||||||
| Minuto | Estado | RAM | Operación |
|
|
||||||
|--------|--------|-----|-----------|
|
|
||||||
| 0 | ✅ OK | 380.9 MB | Query básica |
|
|
||||||
| 2 | ✅ OK | 381.9 MB | Count de databases |
|
|
||||||
| 4 | ✅ OK | 381.8 MB | CREATE TABLE + 1000 INSERTs |
|
|
||||||
| 6 | ✅ OK | 381.6 MB | Listado de databases |
|
|
||||||
|
|
||||||
**Resultado:** Sin errores, sin OOM, sin crashes, memoria estable.
|
|
||||||
|
|
||||||
### Archivos de la Imagen
|
|
||||||
|
|
||||||
```
|
|
||||||
deploy/docker-mssql/
|
|
||||||
├── Dockerfile # Imagen base + labels + health check
|
|
||||||
├── entrypoint.sh # Validación + inicio + optimización automática
|
|
||||||
├── optimize.sql # Queries de sp_configure y trace flags
|
|
||||||
├── build-push.sh # Script para build y push a Docker Hub
|
|
||||||
└── README.md # Esta documentación
|
|
||||||
```
|
|
||||||
|
|
||||||
### Limitaciones Conocidas
|
|
||||||
|
|
||||||
| Limitación | Causa | Mitigación |
|
|
||||||
|------------|-------|------------|
|
|
||||||
| RAM mínima ~380MB | Componentes fuera del buffer pool | Usar swap en SSD |
|
|
||||||
| No persisten trace flags | Se aplican en runtime, no en imagen | Se re-aplican en cada inicio |
|
|
||||||
| Rendimiento reducido | Menos RAM = menos caché | Usar SSD, aumentar swap |
|
|
||||||
|
|
||||||
### Diferencias vs Imagen Oficial
|
|
||||||
|
|
||||||
| Aspecto | Imagen Oficial | Esta Imagen |
|
|
||||||
|---------|----------------|-------------|
|
|
||||||
| RAM mínima | 2 GB | 384 MB |
|
|
||||||
| Optimizaciones | Ninguna | Automáticas |
|
|
||||||
| Trace flags | Ninguno | 3 preconfigurados |
|
|
||||||
| Health check | No | Sí |
|
|
||||||
| Validación password | No | Sí (con mensaje de error claro) |
|
|
||||||
| Documentación | Básica | Completa |
|
|
||||||
|
|
||||||
### Reproducir las Optimizaciones Manualmente
|
|
||||||
|
|
||||||
Si prefieres aplicar las optimizaciones a la imagen oficial:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Iniciar contenedor oficial
|
|
||||||
docker run -d --name mssql --memory=512m \
|
|
||||||
-e ACCEPT_EULA=Y \
|
|
||||||
-e MSSQL_SA_PASSWORD=TuPassword123! \
|
|
||||||
-p 1433:1433 \
|
|
||||||
mcr.microsoft.com/mssql/server:2017-latest
|
|
||||||
|
|
||||||
# 2. Esperar que inicie
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
# 3. Aplicar optimizaciones
|
|
||||||
docker exec mssql /opt/mssql-tools/bin/sqlcmd \
|
|
||||||
-S localhost -U sa -P 'TuPassword123!' -Q "
|
|
||||||
EXEC sp_configure 'show advanced options', 1; RECONFIGURE;
|
|
||||||
EXEC sp_configure 'max server memory', 384; RECONFIGURE;
|
|
||||||
EXEC sp_configure 'min server memory', 128; RECONFIGURE;
|
|
||||||
EXEC sp_configure 'optimize for ad hoc workloads', 1; RECONFIGURE;
|
|
||||||
EXEC sp_configure 'max degree of parallelism', 2; RECONFIGURE;
|
|
||||||
EXEC sp_configure 'cost threshold for parallelism', 50; RECONFIGURE;
|
|
||||||
EXEC sp_configure 'recovery interval (min)', 1; RECONFIGURE;
|
|
||||||
DBCC TRACEON(1117, -1);
|
|
||||||
DBCC TRACEON(1118, -1);
|
|
||||||
DBCC TRACEON(3226, -1);
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Basado en
|
|
||||||
|
|
||||||
- **Microsoft SQL Server 2017 Express** (CU31 - versión 14.0.3515.1)
|
|
||||||
- Imagen base: `mcr.microsoft.com/mssql/server:2017-latest`
|
|
||||||
- Documentación oficial de Microsoft para SQL Server en Linux
|
|
||||||
|
|
||||||
## 📄 Licencia
|
|
||||||
|
|
||||||
- **SQL Server Express**: Gratuito para desarrollo y producción (con límites)
|
|
||||||
- **Scripts de optimización**: MIT License
|
|
||||||
|
|
||||||
## 👤 Autor
|
|
||||||
|
|
||||||
**andresgarcia0313**
|
|
||||||
|
|
||||||
- GitHub: [@andresgarcia0313](https://github.com/andresgarcia0313)
|
|
||||||
- Docker Hub: [andresgarcia0313](https://hub.docker.com/u/andresgarcia0313)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
⭐ Si esta imagen te fue útil, considera dejar una estrella en el repositorio.
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# =============================================================================
|
|
||||||
# Build y Push de imagen SQL Server Low RAM a Docker Hub
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
IMAGE_NAME="andresgarcia0313/mssql-express-lowram"
|
|
||||||
VERSION="1.0"
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
|
|
||||||
echo "=== Building image: $IMAGE_NAME:$VERSION ==="
|
|
||||||
docker build -t "$IMAGE_NAME:$VERSION" -t "$IMAGE_NAME:latest" .
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Testing image locally ==="
|
|
||||||
docker rm -f mssql-test 2>/dev/null || true
|
|
||||||
|
|
||||||
docker run -d \
|
|
||||||
--name mssql-test \
|
|
||||||
--memory="384m" \
|
|
||||||
-e "MSSQL_SA_PASSWORD=TestPass123!" \
|
|
||||||
-p 1434:1433 \
|
|
||||||
"$IMAGE_NAME:latest"
|
|
||||||
|
|
||||||
echo "Esperando 30 segundos para que SQL Server inicie..."
|
|
||||||
sleep 30
|
|
||||||
|
|
||||||
if docker exec mssql-test /opt/mssql-tools/bin/sqlcmd \
|
|
||||||
-S localhost -U sa -P 'TestPass123!' \
|
|
||||||
-Q "SELECT 'TEST OK' AS Status" 2>/dev/null | grep -q "TEST OK"; then
|
|
||||||
echo "✓ Test passed!"
|
|
||||||
docker rm -f mssql-test
|
|
||||||
else
|
|
||||||
echo "✗ Test failed!"
|
|
||||||
docker logs mssql-test
|
|
||||||
docker rm -f mssql-test
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Push to Docker Hub ==="
|
|
||||||
echo "Ejecuta: docker login"
|
|
||||||
echo "Luego: docker push $IMAGE_NAME:$VERSION"
|
|
||||||
echo " docker push $IMAGE_NAME:latest"
|
|
||||||
echo ""
|
|
||||||
echo "O ejecuta este script con 'push' como argumento:"
|
|
||||||
echo " $0 push"
|
|
||||||
|
|
||||||
if [ "$1" == "push" ]; then
|
|
||||||
docker push "$IMAGE_NAME:$VERSION"
|
|
||||||
docker push "$IMAGE_NAME:latest"
|
|
||||||
echo "✓ Push completado!"
|
|
||||||
fi
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# =============================================================================
|
|
||||||
# SQL Server 2017 Express - Low RAM Edition
|
|
||||||
# Entrypoint que inicia SQL Server y aplica optimizaciones automáticamente
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colores para output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
log_info() { echo -e "${GREEN}[LOWRAM]${NC} $1"; }
|
|
||||||
log_warn() { echo -e "${YELLOW}[LOWRAM]${NC} $1"; }
|
|
||||||
log_error() { echo -e "${RED}[LOWRAM]${NC} $1"; }
|
|
||||||
|
|
||||||
# Validar que se proporcionó contraseña
|
|
||||||
if [ -z "$MSSQL_SA_PASSWORD" ]; then
|
|
||||||
log_error "ERROR: Variable MSSQL_SA_PASSWORD es requerida"
|
|
||||||
log_error ""
|
|
||||||
log_error "Uso correcto:"
|
|
||||||
log_error " docker run -e MSSQL_SA_PASSWORD=TuPassword123! ..."
|
|
||||||
log_error ""
|
|
||||||
log_error "Requisitos del password:"
|
|
||||||
log_error " - Mínimo 8 caracteres"
|
|
||||||
log_error " - Al menos una mayúscula"
|
|
||||||
log_error " - Al menos una minúscula"
|
|
||||||
log_error " - Al menos un número"
|
|
||||||
log_error " - Al menos un caracter especial (!@#\$%^&*)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Función para aplicar optimizaciones
|
|
||||||
apply_optimizations() {
|
|
||||||
log_info "Esperando que SQL Server esté listo..."
|
|
||||||
|
|
||||||
for i in {1..60}; do
|
|
||||||
if /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" \
|
|
||||||
-Q "SELECT 1" &>/dev/null; then
|
|
||||||
log_info "SQL Server listo - Aplicando optimizaciones..."
|
|
||||||
|
|
||||||
if /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$MSSQL_SA_PASSWORD" \
|
|
||||||
-i /opt/mssql/bin/optimize.sql 2>/dev/null; then
|
|
||||||
log_info "============================================="
|
|
||||||
log_info "SQL Server Low RAM Edition - LISTO"
|
|
||||||
log_info "============================================="
|
|
||||||
log_info "Conexión: localhost,1433"
|
|
||||||
log_info "Usuario: sa"
|
|
||||||
log_info "RAM: ~380MB (optimizado)"
|
|
||||||
log_info "============================================="
|
|
||||||
else
|
|
||||||
log_warn "Optimizaciones parcialmente aplicadas"
|
|
||||||
fi
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
log_warn "Timeout esperando SQL Server - optimizaciones no aplicadas"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mostrar configuración
|
|
||||||
log_info "============================================="
|
|
||||||
log_info "SQL Server 2017 Express - Low RAM Edition"
|
|
||||||
log_info "============================================="
|
|
||||||
log_info "Edición: ${MSSQL_PID:-Express}"
|
|
||||||
log_info "Memoria límite: ${MSSQL_MEMORY_LIMIT_MB:-340} MB"
|
|
||||||
log_info "============================================="
|
|
||||||
|
|
||||||
# Aplicar optimizaciones en background después de que SQL Server inicie
|
|
||||||
apply_optimizations &
|
|
||||||
|
|
||||||
# Iniciar SQL Server (foreground)
|
|
||||||
exec /opt/mssql/bin/sqlservr
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
-- =============================================================================
|
|
||||||
-- SQL Server 2017 Express - Optimizaciones para Bajo Consumo de RAM
|
|
||||||
-- =============================================================================
|
|
||||||
-- Aplicar automáticamente al iniciar el contenedor
|
|
||||||
-- RAM objetivo: 384MB - 512MB
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- Habilitar opciones avanzadas
|
|
||||||
EXEC sp_configure 'show advanced options', 1;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- MAX SERVER MEMORY: Limitar buffer pool (ajustar según contenedor)
|
|
||||||
-- Para 384MB container: usar 280-320MB
|
|
||||||
-- Para 512MB container: usar 380-420MB
|
|
||||||
EXEC sp_configure 'max server memory', 320;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- MIN SERVER MEMORY: Permitir liberar memoria bajo presión
|
|
||||||
EXEC sp_configure 'min server memory', 128;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- OPTIMIZE FOR AD HOC WORKLOADS
|
|
||||||
-- Reduce memoria del plan cache para queries únicos
|
|
||||||
-- Cachea stub pequeño en lugar de plan completo
|
|
||||||
EXEC sp_configure 'optimize for ad hoc workloads', 1;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- MAX DEGREE OF PARALLELISM (MAXDOP)
|
|
||||||
-- Limitar a 2 para reducir consumo en operaciones paralelas
|
|
||||||
EXEC sp_configure 'max degree of parallelism', 2;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- COST THRESHOLD FOR PARALLELISM
|
|
||||||
-- Subir umbral: solo queries costosas usan paralelismo
|
|
||||||
EXEC sp_configure 'cost threshold for parallelism', 50;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- RECOVERY INTERVAL
|
|
||||||
-- Checkpoints más frecuentes = recuperación más rápida tras crash
|
|
||||||
EXEC sp_configure 'recovery interval (min)', 1;
|
|
||||||
RECONFIGURE;
|
|
||||||
|
|
||||||
-- =============================================================================
|
|
||||||
-- TRACE FLAGS PARA ESTABILIDAD
|
|
||||||
-- =============================================================================
|
|
||||||
|
|
||||||
-- TF 3226: Suprime mensajes de backup exitosos en el log (reduce I/O)
|
|
||||||
DBCC TRACEON(3226, -1);
|
|
||||||
|
|
||||||
-- TF 1118: Reduce contención en tempdb (extents uniformes)
|
|
||||||
DBCC TRACEON(1118, -1);
|
|
||||||
|
|
||||||
-- TF 1117: Crecimiento uniforme de archivos de datos
|
|
||||||
DBCC TRACEON(1117, -1);
|
|
||||||
|
|
||||||
PRINT '[LOWRAM] Todas las optimizaciones aplicadas correctamente';
|
|
||||||
GO
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# =============================================================================
|
|
||||||
# Actualizar README en Docker Hub Overview
|
|
||||||
# =============================================================================
|
|
||||||
# Uso: ./update-dockerhub-readme.sh <username> <password>
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
DOCKER_USER="${1:-andresgarcia0313}"
|
|
||||||
DOCKER_PASS="$2"
|
|
||||||
REPO_NAME="mssql-express-lowram"
|
|
||||||
README_FILE="$(dirname "$0")/README.md"
|
|
||||||
|
|
||||||
if [ -z "$DOCKER_PASS" ]; then
|
|
||||||
echo "Uso: $0 <username> <password>"
|
|
||||||
echo " o: $0 <username> (te pedirá el password)"
|
|
||||||
read -s -p "Docker Hub Password: " DOCKER_PASS
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Obteniendo token de Docker Hub ==="
|
|
||||||
TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/users/login/" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"username\":\"$DOCKER_USER\",\"password\":\"$DOCKER_PASS\"}" | jq -r '.token')
|
|
||||||
|
|
||||||
if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then
|
|
||||||
echo "Error: No se pudo obtener token. Verifica credenciales."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== Token obtenido correctamente ==="
|
|
||||||
|
|
||||||
echo "=== Actualizando README en Docker Hub ==="
|
|
||||||
README_CONTENT=$(cat "$README_FILE" | jq -Rs .)
|
|
||||||
|
|
||||||
RESPONSE=$(curl -s -X PATCH "https://hub.docker.com/v2/repositories/$DOCKER_USER/$REPO_NAME/" \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"full_description\":$README_CONTENT}")
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | jq -e '.full_description' > /dev/null 2>&1; then
|
|
||||||
echo "✅ README actualizado exitosamente en Docker Hub"
|
|
||||||
echo " https://hub.docker.com/r/$DOCKER_USER/$REPO_NAME"
|
|
||||||
else
|
|
||||||
echo "❌ Error al actualizar:"
|
|
||||||
echo "$RESPONSE" | jq .
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
@ -4,26 +4,33 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: andresgarcia0313/mssql-express-lowram:latest
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
container_name: student-db
|
container_name: student-db
|
||||||
env_file:
|
env_file:
|
||||||
- ../../.env
|
- ../../.env
|
||||||
environment:
|
environment:
|
||||||
- MSSQL_SA_PASSWORD=${DB_PASSWORD:-Asde71.4Asde71.4}
|
- ACCEPT_EULA=Y
|
||||||
|
- MSSQL_SA_PASSWORD=${DB_PASSWORD}
|
||||||
|
- MSSQL_MEMORY_LIMIT_MB=${DB_MEMORY_LIMIT_MB:-512}
|
||||||
|
- MSSQL_AGENT_ENABLED=false
|
||||||
- MSSQL_PID=Express
|
- MSSQL_PID=Express
|
||||||
- MSSQL_MEMORY_LIMIT_MB=340
|
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-1433}:1433"
|
- "${DB_PORT:-1433}:1433"
|
||||||
volumes:
|
volumes:
|
||||||
- sqlserver-data:/var/opt/mssql/data:delegated
|
- sqlserver-data:/var/opt/mssql:delegated
|
||||||
mem_limit: 384m
|
deploy:
|
||||||
memswap_limit: 4g
|
resources:
|
||||||
mem_reservation: 256m
|
limits:
|
||||||
|
cpus: "1"
|
||||||
|
memory: ${DB_MEMORY_LIMIT:-1280M}
|
||||||
|
reservations:
|
||||||
|
cpus: "0.5"
|
||||||
|
memory: 1024M
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$${MSSQL_SA_PASSWORD}" -Q "SELECT 1" || exit 1
|
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$${MSSQL_SA_PASSWORD}" -Q "SELECT 1" -C || exit 1
|
||||||
interval: 30s
|
interval: 15s
|
||||||
timeout: 10s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 10
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- student-network
|
- student-network
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ docker compose up -d
|
||||||
echo -e "\n${YELLOW}► Esperando servicios...${NC}"
|
echo -e "\n${YELLOW}► Esperando servicios...${NC}"
|
||||||
|
|
||||||
echo -n " SQL Server: "
|
echo -n " SQL Server: "
|
||||||
timeout 60 bash -c 'until docker compose exec -T db /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${DB_PASSWORD:-Asde71.4Asde71.4}" -Q "SELECT 1" -C &>/dev/null; do sleep 2; done' && echo -e "${GREEN}✓${NC}" || echo -e "${RED}✗${NC}"
|
timeout 60 bash -c 'until docker compose exec -T db /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${DB_PASSWORD:-Your_Str0ng_P@ssword!}" -Q "SELECT 1" -C &>/dev/null; do sleep 2; done' && echo -e "${GREEN}✓${NC}" || echo -e "${RED}✗${NC}"
|
||||||
|
|
||||||
echo -n " API .NET: "
|
echo -n " API .NET: "
|
||||||
timeout 60 bash -c 'until curl -sf http://localhost:5000/health &>/dev/null; do sleep 2; done' && echo -e "${GREEN}✓${NC}" || echo -e "${RED}✗${NC}"
|
timeout 60 bash -c 'until curl -sf http://localhost:5000/health &>/dev/null; do sleep 2; done' && echo -e "${GREEN}✓${NC}" || echo -e "${RED}✗${NC}"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ spec:
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: student-api
|
name: student-api
|
||||||
minReplicas: 1
|
minReplicas: 2
|
||||||
maxReplicas: 5
|
maxReplicas: 5
|
||||||
metrics:
|
metrics:
|
||||||
- type: Resource
|
- type: Resource
|
||||||
|
|
@ -50,7 +50,7 @@ spec:
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
name: student-frontend
|
name: student-frontend
|
||||||
minReplicas: 1
|
minReplicas: 2
|
||||||
maxReplicas: 4
|
maxReplicas: 4
|
||||||
metrics:
|
metrics:
|
||||||
- type: Resource
|
- type: Resource
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ resources:
|
||||||
- api.yaml
|
- api.yaml
|
||||||
- frontend.yaml
|
- frontend.yaml
|
||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
- networkpolicy.yaml
|
|
||||||
|
|
||||||
labels:
|
labels:
|
||||||
- pairs:
|
- pairs:
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,7 @@ metadata:
|
||||||
namespace: academia
|
namespace: academia
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
db-password: "Asde71.4Asde71.4"
|
# IMPORTANTE: Cambiar en producción
|
||||||
db-connection-string: "Server=sqlserver;Database=StudentEnrollment;User Id=sa;Password=Asde71.4Asde71.4;TrustServerCertificate=True"
|
# Generar con: openssl rand -base64 32
|
||||||
|
db-password: "YourStr0ngP4ssword2026"
|
||||||
|
db-connection-string: "Server=sqlserver;Database=StudentEnrollment;User Id=sa;Password=YourStr0ngP4ssword2026;TrustServerCertificate=True"
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ volumes:
|
||||||
cd deploy/docker
|
cd deploy/docker
|
||||||
|
|
||||||
# Crear archivo .env
|
# Crear archivo .env
|
||||||
echo "DB_PASSWORD=Asde71.4Asde71.4" > .env
|
echo "DB_PASSWORD=Your_Str0ng_P@ssword!" > .env
|
||||||
|
|
||||||
# Build e iniciar
|
# Build e iniciar
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
@ -254,7 +254,7 @@ Script que levanta backend + frontend con **SQLite** (sin necesidad de SQL Serve
|
||||||
```yaml
|
```yaml
|
||||||
# Variables de entorno en el workflow
|
# Variables de entorno en el workflow
|
||||||
K3S_HOST: 100.67.198.92 # IP del master (hp62a)
|
K3S_HOST: 100.67.198.92 # IP del master (hp62a)
|
||||||
NAMESPACE: academia
|
NAMESPACE: student-enrollment
|
||||||
DOMAIN: academia.ingeniumcodex.com
|
DOMAIN: academia.ingeniumcodex.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -366,25 +366,20 @@ dotnet ef database update <MigrationName>
|
||||||
|
|
||||||
```
|
```
|
||||||
deploy/k3s/
|
deploy/k3s/
|
||||||
├── namespace.yaml # Namespace: academia
|
├── namespace.yaml # Namespace dedicado
|
||||||
├── secrets.yaml # Credenciales BD
|
├── secrets.yaml # Credenciales BD
|
||||||
├── configmap.yaml # Configuración
|
├── configmap.yaml # Configuración
|
||||||
├── sqlserver.yaml # Base de datos
|
├── sqlserver.yaml # Base de datos
|
||||||
├── api.yaml # Backend GraphQL
|
├── api.yaml # Backend GraphQL
|
||||||
├── frontend.yaml # Frontend Angular
|
├── frontend.yaml # Frontend Angular
|
||||||
├── ingress.yaml # Traefik IngressRoute + TLS
|
├── ingress.yaml # Traefik ingress
|
||||||
├── networkpolicy.yaml # Seguridad de red (incluido en kustomize)
|
├── ingress-tls.yaml # TLS con cert-manager
|
||||||
├── hpa.yaml # Autoscaling (opcional, no incluido)
|
├── hpa.yaml # Autoscaling
|
||||||
|
├── networkpolicy.yaml # Seguridad de red
|
||||||
├── kustomization.yaml # Kustomize config
|
├── kustomization.yaml # Kustomize config
|
||||||
└── deploy.sh # Script de despliegue
|
└── deploy.sh # Script de despliegue
|
||||||
```
|
```
|
||||||
|
|
||||||
**Nota:** `networkpolicy.yaml` está incluido en `kustomization.yaml` y aplica las siguientes reglas:
|
|
||||||
- Default deny: Bloquea todo tráfico entrante por defecto
|
|
||||||
- Frontend: Solo acepta tráfico desde Ingress
|
|
||||||
- API: Solo acepta tráfico desde Frontend e Ingress
|
|
||||||
- SQL Server: Solo acepta conexiones desde API
|
|
||||||
|
|
||||||
### Requisitos k3s
|
### Requisitos k3s
|
||||||
|
|
||||||
- k3s instalado y funcionando
|
- k3s instalado y funcionando
|
||||||
|
|
@ -404,7 +399,7 @@ cd deploy/k3s
|
||||||
kubectl apply -k .
|
kubectl apply -k .
|
||||||
|
|
||||||
# Verificar estado
|
# Verificar estado
|
||||||
kubectl get all -n academia
|
kubectl get all -n student-enrollment
|
||||||
```
|
```
|
||||||
|
|
||||||
### Comandos del Script
|
### Comandos del Script
|
||||||
|
|
@ -423,9 +418,9 @@ kubectl get all -n academia
|
||||||
```bash
|
```bash
|
||||||
# Editar secrets antes de desplegar
|
# Editar secrets antes de desplegar
|
||||||
kubectl create secret generic student-secrets \
|
kubectl create secret generic student-secrets \
|
||||||
--namespace=academia \
|
--namespace=student-enrollment \
|
||||||
--from-literal=db-password='Asde71.4Asde71.4' \
|
--from-literal=db-password='TuPasswordSeguro123!' \
|
||||||
--from-literal=db-connection-string='Server=sqlserver;Database=StudentEnrollment;User Id=sa;Password=Asde71.4Asde71.4;TrustServerCertificate=True' \
|
--from-literal=db-connection-string='Server=sqlserver;Database=StudentEnrollment;User Id=sa;Password=TuPasswordSeguro123!;TrustServerCertificate=True' \
|
||||||
--dry-run=client -o yaml > secrets.yaml
|
--dry-run=client -o yaml > secrets.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -452,61 +447,61 @@ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/
|
||||||
# 2. Editar ingress-tls.yaml con tu dominio y email
|
# 2. Editar ingress-tls.yaml con tu dominio y email
|
||||||
|
|
||||||
# 3. Aplicar ingress con TLS
|
# 3. Aplicar ingress con TLS
|
||||||
kubectl apply -f ingress-tls.yaml -n academia
|
kubectl apply -f ingress-tls.yaml -n student-enrollment
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scaling Manual
|
### Scaling Manual
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Escalar API
|
# Escalar API
|
||||||
kubectl scale deployment student-api -n academia --replicas=3
|
kubectl scale deployment student-api -n student-enrollment --replicas=3
|
||||||
|
|
||||||
# Escalar Frontend
|
# Escalar Frontend
|
||||||
kubectl scale deployment student-frontend -n academia --replicas=2
|
kubectl scale deployment student-frontend -n student-enrollment --replicas=2
|
||||||
```
|
```
|
||||||
|
|
||||||
### Monitoreo
|
### Monitoreo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Estado de pods
|
# Estado de pods
|
||||||
kubectl get pods -n academia -w
|
kubectl get pods -n student-enrollment -w
|
||||||
|
|
||||||
# Logs en tiempo real
|
# Logs en tiempo real
|
||||||
kubectl logs -n academia -l app=student-api -f
|
kubectl logs -n student-enrollment -l app=student-api -f
|
||||||
|
|
||||||
# Eventos
|
# Eventos
|
||||||
kubectl get events -n academia --sort-by='.lastTimestamp'
|
kubectl get events -n student-enrollment --sort-by='.lastTimestamp'
|
||||||
|
|
||||||
# Recursos
|
# Recursos
|
||||||
kubectl top pods -n academia
|
kubectl top pods -n student-enrollment
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rollback en k3s
|
### Rollback en k3s
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver historial de deployments
|
# Ver historial de deployments
|
||||||
kubectl rollout history deployment/student-api -n academia
|
kubectl rollout history deployment/student-api -n student-enrollment
|
||||||
|
|
||||||
# Rollback a versión anterior
|
# Rollback a versión anterior
|
||||||
kubectl rollout undo deployment/student-api -n academia
|
kubectl rollout undo deployment/student-api -n student-enrollment
|
||||||
|
|
||||||
# Rollback a revisión específica
|
# Rollback a revisión específica
|
||||||
kubectl rollout undo deployment/student-api -n academia --to-revision=2
|
kubectl rollout undo deployment/student-api -n student-enrollment --to-revision=2
|
||||||
```
|
```
|
||||||
|
|
||||||
### Troubleshooting
|
### Troubleshooting
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pod no inicia
|
# Pod no inicia
|
||||||
kubectl describe pod <pod-name> -n academia
|
kubectl describe pod <pod-name> -n student-enrollment
|
||||||
|
|
||||||
# Conectar a pod
|
# Conectar a pod
|
||||||
kubectl exec -it <pod-name> -n academia -- /bin/sh
|
kubectl exec -it <pod-name> -n student-enrollment -- /bin/sh
|
||||||
|
|
||||||
# Verificar conectividad BD
|
# Verificar conectividad BD
|
||||||
kubectl exec -it <api-pod> -n academia -- \
|
kubectl exec -it <api-pod> -n student-enrollment -- \
|
||||||
curl -v telnet://sqlserver:1433
|
curl -v telnet://sqlserver:1433
|
||||||
|
|
||||||
# Verificar ingress
|
# Verificar ingress
|
||||||
kubectl describe ingress student-ingress -n academia
|
kubectl describe ingress student-ingress -n student-enrollment
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,6 @@ Sistema web completo para gestión de inscripciones de estudiantes con las sigui
|
||||||
| 7 | Validación de inscripciones | ✅ |
|
| 7 | Validación de inscripciones | ✅ |
|
||||||
| 8 | UI responsiva | ✅ |
|
| 8 | UI responsiva | ✅ |
|
||||||
| 9 | Manejo de errores | ✅ |
|
| 9 | Manejo de errores | ✅ |
|
||||||
| 10 | Autenticación JWT | ✅ |
|
|
||||||
| 11 | Flujo de activación de estudiantes | ✅ |
|
|
||||||
| 12 | Control de acceso por roles (Admin/Student) | ✅ |
|
|
||||||
|
|
||||||
### Reglas de Negocio
|
### Reglas de Negocio
|
||||||
|
|
||||||
|
|
@ -63,14 +60,6 @@ Sistema web completo para gestión de inscripciones de estudiantes con las sigui
|
||||||
- ✅ Validación en Domain Layer (pura, testeable)
|
- ✅ Validación en Domain Layer (pura, testeable)
|
||||||
- ✅ Mensajes de error descriptivos
|
- ✅ Mensajes de error descriptivos
|
||||||
|
|
||||||
### Sistema de Autenticación
|
|
||||||
|
|
||||||
- ✅ **JWT** con HMAC-SHA256
|
|
||||||
- ✅ **Flujo de Activación:** Admin crea estudiante → Código de activación → Estudiante activa cuenta
|
|
||||||
- ✅ **Roles:** Admin (gestión completa) / Student (dashboard personal)
|
|
||||||
- ✅ **Recuperación:** Código de recuperación generado en activación
|
|
||||||
- ✅ **Seguridad:** PBKDF2-SHA256 para hashing de contraseñas
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
|
|
@ -96,33 +85,6 @@ Host → Adapters → Application → Domain
|
||||||
| DataLoader | Evitar N+1 en GraphQL |
|
| DataLoader | Evitar N+1 en GraphQL |
|
||||||
| Specification | Consultas reutilizables |
|
| Specification | Consultas reutilizables |
|
||||||
|
|
||||||
### Diagramas de Arquitectura
|
|
||||||
|
|
||||||
Todos los diagramas están disponibles en `/docs/architecture/diagrams/` en formatos PNG y SVG.
|
|
||||||
|
|
||||||
| # | Diagrama | Archivo | Descripción |
|
|
||||||
|---|----------|---------|-------------|
|
|
||||||
| 1 | **Casos de Uso** | `01-use-cases` | Actores (Estudiante, Admin), funcionalidades del sistema, reglas de negocio |
|
|
||||||
| 2 | **Modelo de Dominio** | `02-domain-model` | Entidades (User, Student, Professor, Subject, Enrollment), Value Objects, Domain Services |
|
|
||||||
| 3 | **Secuencia Inscripción** | `03-sequence-enrollment` | Flujo completo de inscripción con JWT, validaciones y persistencia |
|
|
||||||
| 4 | **Componentes** | `04-components` | Arquitectura Clean Architecture: Frontend Angular 21, Backend .NET 10, GraphQL |
|
|
||||||
| 5 | **Entidad-Relación** | `05-entity-relationship` | Modelo de base de datos: tablas, relaciones, restricciones |
|
|
||||||
| 6 | **Estados** | `06-state-enrollment` | Estados de cuenta (activación) e inscripciones (0-9 créditos) |
|
|
||||||
| 7 | **Despliegue** | `07-deployment` | Infraestructura K3s: Nginx, ASP.NET Core, SQL Server, Traefik Ingress |
|
|
||||||
| 8 | **C4 Contexto** | `08-c4-context` | Vista de alto nivel: actores, sistema, sistemas externos |
|
|
||||||
|
|
||||||
#### Requisitos de la Prueba Técnica Cubiertos
|
|
||||||
|
|
||||||
| Requisito | Diagrama(s) |
|
|
||||||
|-----------|-------------|
|
|
||||||
| CRUD de estudiantes | 01, 04 |
|
|
||||||
| Programa de créditos | 02, 06 |
|
|
||||||
| 10 materias, 3 créditos c/u | 02, 05 |
|
|
||||||
| Máximo 3 materias (9 créditos) | 01, 02, 03, 06 |
|
|
||||||
| 5 profesores, 2 materias c/u | 02, 05 |
|
|
||||||
| No repetir profesor | 01, 02, 03, 05 |
|
|
||||||
| Ver compañeros de clase | 01, 04 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
@ -132,56 +94,24 @@ Todos los diagramas están disponibles en `/docs/architecture/diagrams/` en form
|
||||||
| Tipo | Cantidad | Cobertura |
|
| Tipo | Cantidad | Cobertura |
|
||||||
|------|----------|-----------|
|
|------|----------|-----------|
|
||||||
| Domain Tests | 30 | Entidades, Value Objects, Services |
|
| Domain Tests | 30 | Entidades, Value Objects, Services |
|
||||||
| Application Tests | 98 | Commands, Queries, Validators, **Auth** |
|
| Application Tests | 66 | Commands, Queries, Validators |
|
||||||
| Integration Tests | 5 | GraphQL API completa |
|
| Integration Tests | 5 | GraphQL API completa |
|
||||||
| Angular Unit Tests | 24 | Services, Pipes |
|
| Angular Unit Tests | 24 | Services, Pipes |
|
||||||
| E2E Tests (Playwright) | 97 | Flujos de usuario completos |
|
| E2E Tests (Playwright) | 20 | Flujos de usuario |
|
||||||
| **Total** | **254** | |
|
| **Total** | **145** | |
|
||||||
|
|
||||||
### Tests E2E por Categoría
|
|
||||||
|
|
||||||
| Categoría | Tests | Descripción |
|
|
||||||
|-----------|-------|-------------|
|
|
||||||
| Autenticación | 15 | Login, registro, reset password, logout |
|
|
||||||
| Control de Acceso | 16 | Roles Admin/Student, guards, protección rutas |
|
|
||||||
| Reglas de Negocio | 16 | Max 3 materias, mismo profesor, inscribir/cancelar |
|
|
||||||
| Flujo Activación | 18 | Creación estudiante, código, activación cuenta |
|
|
||||||
| CRUD Estudiantes | 6 | Crear, listar, validaciones |
|
|
||||||
| Inscripciones | 7 | Navegar, inscribir, cancelar |
|
|
||||||
| Compañeros | 7 | Listar, navegar |
|
|
||||||
| Otros | 12 | Estados UI, edge cases |
|
|
||||||
|
|
||||||
### Tests Backend de Auth (Nuevos)
|
|
||||||
|
|
||||||
| Handler | Tests | Casos Cubiertos |
|
|
||||||
|---------|-------|-----------------|
|
|
||||||
| LoginCommand | 6 | Credenciales válidas/inválidas, normalización, lastLogin |
|
|
||||||
| RegisterCommand | 8 | Registro exitoso, usuario duplicado, validaciones, recovery code |
|
|
||||||
| ResetPasswordCommand | 8 | Reset válido/inválido, validaciones, hashing |
|
|
||||||
| ActivateAccountCommand | 10 | Activación, expiración, username duplicado, JWT |
|
|
||||||
|
|
||||||
### Ejecutar Tests
|
### Ejecutar Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend - Todos
|
# Backend
|
||||||
dotnet test tests/Application.Tests
|
dotnet test
|
||||||
dotnet test tests/Domain.Tests
|
|
||||||
dotnet test tests/Integration.Tests
|
|
||||||
|
|
||||||
# Backend - Solo Auth
|
# Frontend
|
||||||
dotnet test tests/Application.Tests --filter "FullyQualifiedName~Auth"
|
cd src/frontend
|
||||||
|
ng test --watch=false
|
||||||
|
|
||||||
# Frontend Unit Tests
|
# E2E
|
||||||
cd src/frontend && ng test --watch=false
|
npx playwright test
|
||||||
|
|
||||||
# E2E - Todos
|
|
||||||
cd src/frontend && npx playwright test
|
|
||||||
|
|
||||||
# E2E - Por categoría
|
|
||||||
npx playwright test auth.spec.ts
|
|
||||||
npx playwright test role-access.spec.ts
|
|
||||||
npx playwright test enrollment-restrictions.spec.ts
|
|
||||||
npx playwright test activation.spec.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -210,24 +140,9 @@ npx playwright test activation.spec.ts
|
||||||
| Diseño BD | `/docs/entregables/02-diseno/base-datos/` |
|
| Diseño BD | `/docs/entregables/02-diseno/base-datos/` |
|
||||||
| Esquema GraphQL | `/docs/entregables/02-diseno/esquema-graphql/` |
|
| Esquema GraphQL | `/docs/entregables/02-diseno/esquema-graphql/` |
|
||||||
| ADRs | `/docs/architecture/decisions/` |
|
| ADRs | `/docs/architecture/decisions/` |
|
||||||
| **Diagramas UML** | `/docs/architecture/diagrams/` |
|
|
||||||
| OWASP Checklist | `/docs/OWASP_CHECKLIST.md` |
|
| OWASP Checklist | `/docs/OWASP_CHECKLIST.md` |
|
||||||
| Manual Despliegue | `/docs/DEPLOYMENT.md` |
|
| Manual Despliegue | `/docs/DEPLOYMENT.md` |
|
||||||
|
|
||||||
### Diagramas Incluidos
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/architecture/diagrams/
|
|
||||||
├── 01-use-cases.png # Casos de uso
|
|
||||||
├── 02-domain-model.png # Modelo de dominio
|
|
||||||
├── 03-sequence-enrollment.png # Secuencia inscripción
|
|
||||||
├── 04-components.png # Arquitectura componentes
|
|
||||||
├── 05-entity-relationship.png # Diagrama E-R
|
|
||||||
├── 06-state-enrollment.png # Estados inscripción
|
|
||||||
├── 07-deployment.png # Despliegue K3s
|
|
||||||
└── 08-c4-context.png # Contexto C4
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Despliegue
|
## Despliegue
|
||||||
|
|
|
||||||
|
|
@ -152,10 +152,9 @@ El sistema está **listo para demostración** y cumple con todos los requisitos
|
||||||
- Health check post-deploy
|
- Health check post-deploy
|
||||||
|
|
||||||
#### 2. Kubernetes (k3s) Deployment
|
#### 2. Kubernetes (k3s) Deployment
|
||||||
- **Namespace:** `academia`
|
- **Namespace:** `student-enrollment`
|
||||||
- **Servicios:** student-api, student-frontend, sqlserver
|
- **Servicios:** student-api, student-frontend, mssql
|
||||||
- **Ingress:** `academia.ingeniumcodex.com` (Traefik)
|
- **Ingress:** `students.ingeniumcodex.com` (Traefik)
|
||||||
- **Seguridad:** NetworkPolicy (default-deny + allow rules)
|
|
||||||
- **TLS:** Configurado con cert-manager
|
- **TLS:** Configurado con cert-manager
|
||||||
|
|
||||||
#### 3. Optimizaciones de Deployment
|
#### 3. Optimizaciones de Deployment
|
||||||
|
|
@ -175,10 +174,10 @@ El sistema está **listo para demostración** y cumple con todos los requisitos
|
||||||
### URLs de Producción
|
### URLs de Producción
|
||||||
| Servicio | URL |
|
| Servicio | URL |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
| Frontend | https://academia.ingeniumcodex.com |
|
| Frontend | https://students.ingeniumcodex.com |
|
||||||
| GraphQL API | https://academia.ingeniumcodex.com/graphql |
|
| GraphQL API | https://students.ingeniumcodex.com/graphql |
|
||||||
| Health Check | https://academia.ingeniumcodex.com/health |
|
| Health Check | https://students.ingeniumcodex.com/health |
|
||||||
|
|
||||||
### Repositorio Git
|
### Repositorio Git
|
||||||
- **URL:** https://devops.ingeniumcodex.com/andresgarcia0313/academia.git
|
- **URL:** https://devops.ingeniumcodex.com/andresgarcia0313/student-enrollment.git
|
||||||
- **CI/CD:** Auto-deploy en push a main
|
- **CI/CD:** Auto-deploy en push a main
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 70 KiB |
|
|
@ -9,84 +9,43 @@ skinparam actorBackgroundColor #007AFF
|
||||||
title Sistema de Registro de Estudiantes - Diagrama de Casos de Uso
|
title Sistema de Registro de Estudiantes - Diagrama de Casos de Uso
|
||||||
|
|
||||||
actor "Estudiante" as student
|
actor "Estudiante" as student
|
||||||
actor "Administrador" as admin
|
|
||||||
|
|
||||||
rectangle "Sistema de Inscripción Académica" {
|
rectangle "Sistema de Inscripción" {
|
||||||
' Autenticación (ambos actores)
|
usecase "Registrarse en el sistema" as UC1
|
||||||
usecase "Iniciar sesión" as UC_LOGIN
|
usecase "Iniciar sesión" as UC2
|
||||||
usecase "Recuperar contraseña" as UC_RECOVER
|
usecase "Ver materias disponibles" as UC3
|
||||||
|
usecase "Inscribirse en materia" as UC4
|
||||||
|
usecase "Cancelar inscripción" as UC5
|
||||||
|
usecase "Ver mis inscripciones" as UC6
|
||||||
|
usecase "Ver compañeros de clase" as UC7
|
||||||
|
usecase "Actualizar perfil" as UC8
|
||||||
|
|
||||||
' Solo estudiantes
|
usecase "Validar límite de créditos\n(máx 9 créditos)" as UC4a
|
||||||
usecase "Registrarse" as UC_REGISTER
|
usecase "Validar restricción de profesor\n(no repetir profesor)" as UC4b
|
||||||
usecase "Activar cuenta" as UC_ACTIVATE
|
|
||||||
usecase "Ver dashboard personal" as UC_DASHBOARD
|
|
||||||
usecase "Ver materias disponibles" as UC_SUBJECTS
|
|
||||||
usecase "Inscribirse en materia" as UC_ENROLL
|
|
||||||
usecase "Cancelar inscripción" as UC_UNENROLL
|
|
||||||
usecase "Ver mis inscripciones" as UC_MY_ENROLL
|
|
||||||
usecase "Ver compañeros de clase" as UC_CLASSMATES
|
|
||||||
|
|
||||||
' Solo administrador
|
|
||||||
usecase "Gestionar estudiantes\n(CRUD)" as UC_CRUD
|
|
||||||
usecase "Ver todos los estudiantes" as UC_LIST
|
|
||||||
usecase "Crear estudiante" as UC_CREATE
|
|
||||||
usecase "Editar estudiante" as UC_EDIT
|
|
||||||
usecase "Eliminar estudiante" as UC_DELETE
|
|
||||||
|
|
||||||
' Validaciones (includes)
|
|
||||||
usecase "Validar límite de créditos\n(máx 9 créditos)" as UC_VAL_CREDITS
|
|
||||||
usecase "Validar restricción de profesor\n(no repetir profesor)" as UC_VAL_PROF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
' Relaciones Estudiante
|
student --> UC1
|
||||||
student --> UC_REGISTER
|
student --> UC2
|
||||||
student --> UC_LOGIN
|
student --> UC3
|
||||||
student --> UC_ACTIVATE
|
student --> UC4
|
||||||
student --> UC_RECOVER
|
student --> UC5
|
||||||
student --> UC_DASHBOARD
|
student --> UC6
|
||||||
student --> UC_SUBJECTS
|
student --> UC7
|
||||||
student --> UC_ENROLL
|
student --> UC8
|
||||||
student --> UC_UNENROLL
|
|
||||||
student --> UC_MY_ENROLL
|
|
||||||
student --> UC_CLASSMATES
|
|
||||||
|
|
||||||
' Relaciones Admin
|
UC4 ..> UC4a : <<include>>
|
||||||
admin --> UC_LOGIN
|
UC4 ..> UC4b : <<include>>
|
||||||
admin --> UC_RECOVER
|
|
||||||
admin --> UC_CRUD
|
|
||||||
admin --> UC_LIST
|
|
||||||
|
|
||||||
' Extensiones CRUD
|
note right of UC4
|
||||||
UC_CRUD ..> UC_CREATE : <<include>>
|
|
||||||
UC_CRUD ..> UC_EDIT : <<include>>
|
|
||||||
UC_CRUD ..> UC_DELETE : <<include>>
|
|
||||||
|
|
||||||
' Validaciones inscripción
|
|
||||||
UC_ENROLL ..> UC_VAL_CREDITS : <<include>>
|
|
||||||
UC_ENROLL ..> UC_VAL_PROF : <<include>>
|
|
||||||
|
|
||||||
note right of UC_ENROLL
|
|
||||||
Reglas de negocio:
|
Reglas de negocio:
|
||||||
- Máximo 3 materias (9 créditos)
|
- Máximo 3 materias (9 créditos)
|
||||||
- No puede tener 2 materias
|
- No puede tener 2 materias
|
||||||
del mismo profesor
|
del mismo profesor
|
||||||
- Requiere cuenta activada
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of UC_CLASSMATES
|
note right of UC7
|
||||||
Solo muestra nombres
|
Solo muestra nombres
|
||||||
de compañeros por materia
|
de compañeros por materia
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of UC_ACTIVATE
|
|
||||||
El estudiante recibe
|
|
||||||
código de activación
|
|
||||||
al registrarse
|
|
||||||
end note
|
|
||||||
|
|
||||||
note bottom of admin
|
|
||||||
Acceso completo al
|
|
||||||
CRUD de estudiantes
|
|
||||||
end note
|
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
|
@ -9,40 +9,16 @@ title Sistema de Registro de Estudiantes - Modelo de Dominio
|
||||||
|
|
||||||
package "Domain" {
|
package "Domain" {
|
||||||
|
|
||||||
class User <<Entity>> {
|
|
||||||
- id: int
|
|
||||||
- username: string
|
|
||||||
- passwordHash: string
|
|
||||||
- recoveryCodeHash: string
|
|
||||||
- role: string
|
|
||||||
- studentId: int?
|
|
||||||
- createdAt: DateTime
|
|
||||||
- lastLoginAt: DateTime?
|
|
||||||
--
|
|
||||||
+ {static} Create(username, passwordHash, ...): User
|
|
||||||
+ UpdatePassword(newHash): void
|
|
||||||
+ UpdateLastLogin(): void
|
|
||||||
+ IsAdmin: bool
|
|
||||||
+ IsStudent: bool
|
|
||||||
}
|
|
||||||
|
|
||||||
class Student <<Entity>> {
|
class Student <<Entity>> {
|
||||||
- id: int
|
- id: int
|
||||||
- name: string
|
- name: string
|
||||||
- email: Email
|
- email: Email
|
||||||
- activationCodeHash: string?
|
|
||||||
- activationExpiresAt: DateTime?
|
|
||||||
- enrollments: List<Enrollment>
|
- enrollments: List<Enrollment>
|
||||||
--
|
--
|
||||||
+ getTotalCredits(): int
|
+ getTotalCredits(): int
|
||||||
+ canEnroll(): bool
|
+ canEnrollIn(subject: Subject): bool
|
||||||
+ hasProfessor(professorId): bool
|
+ enroll(subject: Subject): Enrollment
|
||||||
+ addEnrollment(enrollment): void
|
+ unenroll(enrollmentId: int): void
|
||||||
+ removeEnrollment(enrollment): void
|
|
||||||
+ setActivationCode(hash, expiresIn): void
|
|
||||||
+ clearActivationCode(): void
|
|
||||||
+ isActivated: bool
|
|
||||||
+ isActivationExpired(): bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Subject <<Entity>> {
|
class Subject <<Entity>> {
|
||||||
|
|
@ -86,14 +62,7 @@ package "Domain" {
|
||||||
- checkProfessorConstraint(student: Student, subject: Subject): void
|
- checkProfessorConstraint(student: Student, subject: Subject): void
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserRoles <<Enumeration>> {
|
|
||||||
Admin
|
|
||||||
Student
|
|
||||||
}
|
|
||||||
|
|
||||||
' Relaciones
|
' Relaciones
|
||||||
User "0..1" -- "0..1" Student : vinculado a
|
|
||||||
User --> UserRoles : tiene
|
|
||||||
Student "1" *-- "0..3" Enrollment : tiene
|
Student "1" *-- "0..3" Enrollment : tiene
|
||||||
Subject "1" *-- "0..*" Enrollment : inscripciones
|
Subject "1" *-- "0..*" Enrollment : inscripciones
|
||||||
Professor "1" *-- "2" Subject : imparte
|
Professor "1" *-- "2" Subject : imparte
|
||||||
|
|
@ -103,19 +72,11 @@ package "Domain" {
|
||||||
EnrollmentDomainService ..> Subject : valida
|
EnrollmentDomainService ..> Subject : valida
|
||||||
}
|
}
|
||||||
|
|
||||||
note bottom of User
|
|
||||||
<b>Autenticación:</b>
|
|
||||||
- PBKDF2-SHA256 (100k iter)
|
|
||||||
- JWT para sesiones
|
|
||||||
- Recovery code para reset
|
|
||||||
end note
|
|
||||||
|
|
||||||
note bottom of Student
|
note bottom of Student
|
||||||
<b>Invariantes:</b>
|
<b>Invariantes:</b>
|
||||||
- Máximo 3 inscripciones
|
- Máximo 3 inscripciones
|
||||||
- Email válido y único
|
- Email válido y único
|
||||||
- No repetir profesor
|
- No repetir profesor
|
||||||
- Requiere activación
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note bottom of Subject
|
note bottom of Subject
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
|
@ -5,12 +5,11 @@ skinparam responseMessageBelowArrow true
|
||||||
skinparam sequenceParticipantBackgroundColor #F8F9FA
|
skinparam sequenceParticipantBackgroundColor #F8F9FA
|
||||||
skinparam sequenceParticipantBorderColor #495057
|
skinparam sequenceParticipantBorderColor #495057
|
||||||
|
|
||||||
title Secuencia: Inscripción de Estudiante en Materia (con JWT)
|
title Secuencia: Inscripción de Estudiante en Materia
|
||||||
|
|
||||||
actor "Estudiante" as user
|
actor "Estudiante" as user
|
||||||
participant "Frontend\n(Angular)" as frontend
|
participant "Frontend\n(Angular)" as frontend
|
||||||
participant "API GraphQL\n(HotChocolate)" as api
|
participant "API GraphQL\n(HotChocolate)" as api
|
||||||
participant "JWT Middleware" as jwt
|
|
||||||
participant "EnrollStudentHandler" as handler
|
participant "EnrollStudentHandler" as handler
|
||||||
participant "EnrollmentDomainService" as domainService
|
participant "EnrollmentDomainService" as domainService
|
||||||
participant "StudentRepository" as studentRepo
|
participant "StudentRepository" as studentRepo
|
||||||
|
|
@ -18,27 +17,14 @@ participant "SubjectRepository" as subjectRepo
|
||||||
participant "EnrollmentRepository" as enrollRepo
|
participant "EnrollmentRepository" as enrollRepo
|
||||||
database "SQL Server" as db
|
database "SQL Server" as db
|
||||||
|
|
||||||
== Autenticación (previo) ==
|
|
||||||
|
|
||||||
note over user, frontend
|
|
||||||
El estudiante ya inició sesión
|
|
||||||
y tiene un JWT válido almacenado
|
|
||||||
end note
|
|
||||||
|
|
||||||
== Solicitud de Inscripción ==
|
== Solicitud de Inscripción ==
|
||||||
|
|
||||||
user -> frontend : Selecciona materia\ny hace clic en "Inscribir"
|
user -> frontend : Selecciona materia\ny hace clic en "Inscribir"
|
||||||
activate frontend
|
activate frontend
|
||||||
|
|
||||||
frontend -> api : mutation enrollStudent(\n studentId, subjectId)\n[Authorization: Bearer <JWT>]
|
frontend -> api : mutation enrollStudent(\n studentId, subjectId)
|
||||||
activate api
|
activate api
|
||||||
|
|
||||||
api -> jwt : Validate JWT
|
|
||||||
activate jwt
|
|
||||||
jwt -> jwt : Verify signature\n& expiration
|
|
||||||
jwt --> api : ClaimsPrincipal
|
|
||||||
deactivate jwt
|
|
||||||
|
|
||||||
api -> handler : Handle(EnrollStudentCommand)
|
api -> handler : Handle(EnrollStudentCommand)
|
||||||
activate handler
|
activate handler
|
||||||
|
|
||||||
|
|
@ -51,12 +37,6 @@ db --> studentRepo : Student data
|
||||||
studentRepo --> handler : Student
|
studentRepo --> handler : Student
|
||||||
deactivate studentRepo
|
deactivate studentRepo
|
||||||
|
|
||||||
alt Cuenta no activada
|
|
||||||
handler --> api : Error: "Cuenta no activada"
|
|
||||||
api --> frontend : { errors: [...] }
|
|
||||||
frontend --> user : Muestra mensaje:\n"Activa tu cuenta primero"
|
|
||||||
end
|
|
||||||
|
|
||||||
handler -> subjectRepo : GetByIdAsync(subjectId)
|
handler -> subjectRepo : GetByIdAsync(subjectId)
|
||||||
activate subjectRepo
|
activate subjectRepo
|
||||||
subjectRepo -> db : SELECT Subject
|
subjectRepo -> db : SELECT Subject
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
|
@ -11,43 +11,19 @@ title Sistema de Registro de Estudiantes - Arquitectura de Componentes
|
||||||
|
|
||||||
package "Frontend (Angular 21)" as frontend {
|
package "Frontend (Angular 21)" as frontend {
|
||||||
[App Component] as app
|
[App Component] as app
|
||||||
|
|
||||||
package "Features" {
|
|
||||||
package "Auth" {
|
|
||||||
[Login Page] as loginPage
|
|
||||||
[Register Page] as registerPage
|
|
||||||
[Reset Password] as resetPage
|
|
||||||
[Activate Account] as activatePage
|
|
||||||
}
|
|
||||||
package "Dashboard" {
|
|
||||||
[Student Dashboard] as studentDash
|
|
||||||
[Admin Dashboard] as adminDash
|
|
||||||
}
|
|
||||||
package "Students" {
|
|
||||||
[Student List] as studentList
|
[Student List] as studentList
|
||||||
[Student Form] as studentForm
|
[Student Form] as studentForm
|
||||||
}
|
|
||||||
package "Enrollment" {
|
|
||||||
[Enrollment Page] as enrollPage
|
[Enrollment Page] as enrollPage
|
||||||
[Classmates Page] as classmates
|
[Classmates Page] as classmates
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
package "Core" {
|
package "Core" {
|
||||||
[Apollo Client] as apollo
|
[Apollo Client] as apollo
|
||||||
[Auth Service] as authSvc
|
|
||||||
[Student Service] as studentSvc
|
[Student Service] as studentSvc
|
||||||
[Enrollment Service] as enrollSvc
|
[Enrollment Service] as enrollSvc
|
||||||
[Connectivity Service] as connSvc
|
[Connectivity Service] as connSvc
|
||||||
[Error Handler] as errorHandler
|
[Error Handler] as errorHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
package "Guards" {
|
|
||||||
[Auth Guard] as authGuard
|
|
||||||
[Admin Guard] as adminGuard
|
|
||||||
[Guest Guard] as guestGuard
|
|
||||||
}
|
|
||||||
|
|
||||||
package "Shared" {
|
package "Shared" {
|
||||||
[Connectivity Overlay] as overlay
|
[Connectivity Overlay] as overlay
|
||||||
[Loading Spinner] as spinner
|
[Loading Spinner] as spinner
|
||||||
|
|
@ -67,42 +43,26 @@ package "Backend (.NET 10)" as backend {
|
||||||
[GraphQL API\n(HotChocolate)] as graphql
|
[GraphQL API\n(HotChocolate)] as graphql
|
||||||
[Query] as query
|
[Query] as query
|
||||||
[Mutation] as mutation
|
[Mutation] as mutation
|
||||||
[Auth Types] as authTypes
|
[Types] as types
|
||||||
[Student Types] as studentTypes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
package "Driven (Secondary)" {
|
package "Driven (Secondary)" {
|
||||||
[Repositories] as repos
|
[Repositories] as repos
|
||||||
[DataLoaders] as loaders
|
[DataLoaders] as loaders
|
||||||
[DbContext] as dbContext
|
[DbContext] as dbContext
|
||||||
[JWT Service] as jwtSvc
|
|
||||||
[Password Service] as passSvc
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
package "Application" as application {
|
package "Application" as application {
|
||||||
package "Auth" {
|
[Commands] as commands
|
||||||
[Login Command] as loginCmd
|
[Queries] as queries
|
||||||
[Register Command] as registerCmd
|
[Handlers] as handlers
|
||||||
[Reset Password] as resetCmd
|
|
||||||
}
|
|
||||||
package "Students" {
|
|
||||||
[Student Commands] as studentCmds
|
|
||||||
[Student Queries] as studentQs
|
|
||||||
}
|
|
||||||
package "Enrollments" {
|
|
||||||
[Enroll Commands] as enrollCmds
|
|
||||||
[Classmates Query] as classmatesQ
|
|
||||||
}
|
|
||||||
[Validators] as validators
|
[Validators] as validators
|
||||||
[DTOs] as dtos
|
[DTOs] as dtos
|
||||||
}
|
}
|
||||||
|
|
||||||
package "Domain" as domain {
|
package "Domain" as domain {
|
||||||
[User Entity] as userEntity
|
[Entities] as entities
|
||||||
[Student Entity] as studentEntity
|
|
||||||
[Subject Entity] as subjectEntity
|
|
||||||
[Enrollment Entity] as enrollEntity
|
|
||||||
[Value Objects] as valueObjects
|
[Value Objects] as valueObjects
|
||||||
[Domain Services] as domainSvc
|
[Domain Services] as domainSvc
|
||||||
[Ports (Interfaces)] as ports
|
[Ports (Interfaces)] as ports
|
||||||
|
|
@ -110,67 +70,46 @@ package "Backend (.NET 10)" as backend {
|
||||||
}
|
}
|
||||||
|
|
||||||
database "SQL Server 2022" as sqlserver {
|
database "SQL Server 2022" as sqlserver {
|
||||||
[Users Table] as tblUsers
|
[Students]
|
||||||
[Students Table] as tblStudents
|
[Subjects]
|
||||||
[Subjects Table] as tblSubjects
|
[Professors]
|
||||||
[Professors Table] as tblProf
|
[Enrollments]
|
||||||
[Enrollments Table] as tblEnroll
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cloud "Browser" as browser
|
cloud "Browser" as browser
|
||||||
|
|
||||||
' Conexiones Frontend
|
' Conexiones Frontend
|
||||||
browser --> app
|
browser --> app
|
||||||
app --> loginPage
|
|
||||||
app --> registerPage
|
|
||||||
app --> studentDash
|
|
||||||
app --> adminDash
|
|
||||||
app --> studentList
|
app --> studentList
|
||||||
|
app --> studentForm
|
||||||
app --> enrollPage
|
app --> enrollPage
|
||||||
|
app --> classmates
|
||||||
app --> overlay
|
app --> overlay
|
||||||
|
|
||||||
loginPage --> authSvc
|
|
||||||
registerPage --> authSvc
|
|
||||||
resetPage --> authSvc
|
|
||||||
activatePage --> authSvc
|
|
||||||
studentDash --> studentSvc
|
|
||||||
adminDash --> studentSvc
|
|
||||||
studentList --> studentSvc
|
studentList --> studentSvc
|
||||||
|
studentForm --> studentSvc
|
||||||
enrollPage --> enrollSvc
|
enrollPage --> enrollSvc
|
||||||
classmates --> enrollSvc
|
classmates --> enrollSvc
|
||||||
overlay --> connSvc
|
overlay --> connSvc
|
||||||
|
|
||||||
authSvc --> apollo
|
|
||||||
studentSvc --> apollo
|
studentSvc --> apollo
|
||||||
enrollSvc --> apollo
|
enrollSvc --> apollo
|
||||||
|
connSvc ..> errorHandler
|
||||||
' Guards
|
|
||||||
authGuard ..> authSvc
|
|
||||||
adminGuard ..> authSvc
|
|
||||||
guestGuard ..> authSvc
|
|
||||||
|
|
||||||
' Conexiones Backend
|
' Conexiones Backend
|
||||||
apollo --> graphql : HTTP/GraphQL\n+ JWT Header
|
apollo --> graphql : HTTP/GraphQL
|
||||||
|
|
||||||
graphql --> query
|
graphql --> query
|
||||||
graphql --> mutation
|
graphql --> mutation
|
||||||
graphql --> authTypes
|
query --> handlers
|
||||||
|
mutation --> handlers
|
||||||
|
handlers --> validators
|
||||||
|
handlers --> commands
|
||||||
|
handlers --> queries
|
||||||
|
|
||||||
mutation --> loginCmd
|
commands --> domainSvc
|
||||||
mutation --> registerCmd
|
queries --> repos
|
||||||
mutation --> resetCmd
|
domainSvc --> entities
|
||||||
mutation --> studentCmds
|
|
||||||
mutation --> enrollCmds
|
|
||||||
query --> studentQs
|
|
||||||
query --> classmatesQ
|
|
||||||
|
|
||||||
loginCmd --> jwtSvc
|
|
||||||
loginCmd --> passSvc
|
|
||||||
registerCmd --> passSvc
|
|
||||||
studentCmds --> domainSvc
|
|
||||||
enrollCmds --> domainSvc
|
|
||||||
|
|
||||||
domainSvc --> studentEntity
|
|
||||||
domainSvc --> valueObjects
|
domainSvc --> valueObjects
|
||||||
|
|
||||||
repos --> dbContext
|
repos --> dbContext
|
||||||
|
|
@ -179,7 +118,6 @@ dbContext --> sqlserver
|
||||||
|
|
||||||
' Implementación de puertos
|
' Implementación de puertos
|
||||||
repos ..|> ports : implements
|
repos ..|> ports : implements
|
||||||
jwtSvc ..|> ports : implements
|
|
||||||
|
|
||||||
note right of domain
|
note right of domain
|
||||||
<b>Regla de Dependencia:</b>
|
<b>Regla de Dependencia:</b>
|
||||||
|
|
@ -191,12 +129,6 @@ note bottom of graphql
|
||||||
Endpoints:
|
Endpoints:
|
||||||
- /graphql
|
- /graphql
|
||||||
- /health
|
- /health
|
||||||
Auth: JWT Bearer
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of jwtSvc
|
|
||||||
HMAC-SHA256
|
|
||||||
Configurable expiration
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
|
@ -6,25 +6,11 @@ skinparam classBorderColor #495057
|
||||||
|
|
||||||
title Sistema de Registro de Estudiantes - Diagrama Entidad-Relación
|
title Sistema de Registro de Estudiantes - Diagrama Entidad-Relación
|
||||||
|
|
||||||
entity "Users" as users {
|
|
||||||
* **Id** : int <<PK>>
|
|
||||||
--
|
|
||||||
* Username : nvarchar(50) <<unique>>
|
|
||||||
* PasswordHash : nvarchar(255)
|
|
||||||
* RecoveryCodeHash : nvarchar(255)
|
|
||||||
* Role : nvarchar(20)
|
|
||||||
StudentId : int <<FK, nullable>>
|
|
||||||
* CreatedAt : datetime2
|
|
||||||
LastLoginAt : datetime2
|
|
||||||
}
|
|
||||||
|
|
||||||
entity "Students" as students {
|
entity "Students" as students {
|
||||||
* **Id** : int <<PK>>
|
* **Id** : int <<PK>>
|
||||||
--
|
--
|
||||||
* Name : nvarchar(100)
|
* Name : nvarchar(100)
|
||||||
* Email : nvarchar(255) <<unique>>
|
* Email : nvarchar(255) <<unique>>
|
||||||
ActivationCodeHash : nvarchar(255)
|
|
||||||
ActivationExpiresAt : datetime2
|
|
||||||
* CreatedAt : datetime2
|
* CreatedAt : datetime2
|
||||||
UpdatedAt : datetime2
|
UpdatedAt : datetime2
|
||||||
}
|
}
|
||||||
|
|
@ -54,23 +40,14 @@ entity "Enrollments" as enrollments {
|
||||||
}
|
}
|
||||||
|
|
||||||
' Relaciones
|
' Relaciones
|
||||||
users ||--o| students : "vinculado a"
|
|
||||||
students ||--o{ enrollments : "tiene"
|
students ||--o{ enrollments : "tiene"
|
||||||
subjects ||--o{ enrollments : "inscripciones"
|
subjects ||--o{ enrollments : "inscripciones"
|
||||||
professors ||--|| subjects : "imparte 2"
|
professors ||--|| subjects : "imparte 2"
|
||||||
|
|
||||||
note right of users
|
|
||||||
<b>Autenticación:</b>
|
|
||||||
- Password: PBKDF2-SHA256
|
|
||||||
- Roles: Admin, Student
|
|
||||||
- Recovery code para reset
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of students
|
note right of students
|
||||||
<b>Restricciones:</b>
|
<b>Restricciones:</b>
|
||||||
- Email único
|
- Email único
|
||||||
- Máximo 3 enrollments
|
- Máximo 3 enrollments
|
||||||
- Activación requerida
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of subjects
|
note right of subjects
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
|
@ -3,19 +3,10 @@
|
||||||
skinparam stateBackgroundColor #F8F9FA
|
skinparam stateBackgroundColor #F8F9FA
|
||||||
skinparam stateBorderColor #495057
|
skinparam stateBorderColor #495057
|
||||||
|
|
||||||
title Estados del Estudiante y sus Inscripciones
|
title Estado de Inscripción del Estudiante
|
||||||
|
|
||||||
[*] --> Registrado : Registro inicial
|
[*] --> SinMaterias : Registro inicial
|
||||||
|
|
||||||
state "Cuenta" as cuenta {
|
|
||||||
state "Registrado\n(Pendiente Activación)" as Registrado
|
|
||||||
state "Activo" as Activo
|
|
||||||
|
|
||||||
Registrado --> Activo : activar(código)
|
|
||||||
Registrado --> Registrado : código expirado\n[regenerar código]
|
|
||||||
}
|
|
||||||
|
|
||||||
state "Inscripciones" as inscripciones {
|
|
||||||
state "Sin Materias" as SinMaterias {
|
state "Sin Materias" as SinMaterias {
|
||||||
state "0 créditos" as cred0
|
state "0 créditos" as cred0
|
||||||
}
|
}
|
||||||
|
|
@ -34,23 +25,6 @@ state "Inscripciones" as inscripciones {
|
||||||
Parcial --> Completa : inscribir(materia)\n[créditos = 6]
|
Parcial --> Completa : inscribir(materia)\n[créditos = 6]
|
||||||
Completa --> Parcial : cancelar(inscripción)
|
Completa --> Parcial : cancelar(inscripción)
|
||||||
Parcial --> SinMaterias : cancelar(inscripción)\n[única materia]
|
Parcial --> SinMaterias : cancelar(inscripción)\n[única materia]
|
||||||
}
|
|
||||||
|
|
||||||
Activo --> SinMaterias : cuenta activa
|
|
||||||
|
|
||||||
state validacion <<choice>>
|
|
||||||
|
|
||||||
SinMaterias --> validacion : intenta inscribir
|
|
||||||
Parcial --> validacion : intenta inscribir
|
|
||||||
|
|
||||||
validacion --> Parcial : [válido y cuenta activa]
|
|
||||||
validacion --> [*] : [inválido]\nmuestra error
|
|
||||||
|
|
||||||
note right of Registrado
|
|
||||||
El estudiante recibe
|
|
||||||
código de activación
|
|
||||||
por email (24h validez)
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of Completa
|
note right of Completa
|
||||||
No puede inscribir
|
No puede inscribir
|
||||||
|
|
@ -59,16 +33,17 @@ end note
|
||||||
|
|
||||||
note left of Parcial
|
note left of Parcial
|
||||||
<b>Validaciones en cada inscripción:</b>
|
<b>Validaciones en cada inscripción:</b>
|
||||||
- Cuenta activa
|
|
||||||
- Límite de créditos
|
- Límite de créditos
|
||||||
- No repetir profesor
|
- No repetir profesor
|
||||||
- Materia no duplicada
|
- Materia no duplicada
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note bottom of validacion
|
state validacion <<choice>>
|
||||||
Si la cuenta no está
|
|
||||||
activa, redirige a
|
SinMaterias --> validacion : intenta inscribir
|
||||||
página de activación
|
Parcial --> validacion : intenta inscribir
|
||||||
end note
|
|
||||||
|
validacion --> Parcial : [válido]
|
||||||
|
validacion --> [*] : [inválido]\nmuestra error
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -12,58 +12,35 @@ node "Cliente" as client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node "K3s Cluster (Namespace: academia)" as k3s {
|
node "Docker Host" as docker {
|
||||||
|
|
||||||
node "frontend-deployment" as frontendPod <<Pod>> {
|
node "student-frontend" as frontendContainer <<container>> {
|
||||||
component "Nginx" as nginx {
|
component "Nginx" as nginx {
|
||||||
[Static Files]
|
[Static Files]
|
||||||
[Reverse Proxy]
|
[Reverse Proxy]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node "api-deployment" as apiPod <<Pod>> {
|
node "student-api" as apiContainer <<container>> {
|
||||||
component "ASP.NET Core" as aspnet {
|
component "ASP.NET Core" as aspnet {
|
||||||
[Kestrel Server]
|
[Kestrel Server]
|
||||||
[GraphQL Endpoint]
|
[GraphQL Endpoint]
|
||||||
[JWT Auth]
|
|
||||||
[Health Check]
|
[Health Check]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node "sqlserver-statefulset" as dbPod <<StatefulSet>> {
|
node "student-db" as dbContainer <<container>> {
|
||||||
database "SQL Server 2022" as sqlserver {
|
database "SQL Server 2022" as sqlserver {
|
||||||
[StudentEnrollment DB]
|
[StudentEnrollment DB]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node "Traefik Ingress" as ingress <<Ingress>> {
|
|
||||||
[TLS Termination]
|
|
||||||
[Routing Rules]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component "NetworkPolicy" as netpol <<Security>> {
|
|
||||||
[default-deny-ingress]
|
|
||||||
[allow-frontend-ingress]
|
|
||||||
[allow-api-ingress]
|
|
||||||
[allow-sqlserver-from-api]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cloud "Internet" as internet
|
|
||||||
|
|
||||||
' Conexiones
|
' Conexiones
|
||||||
browser --> internet : HTTPS
|
browser --> nginx : HTTP :80
|
||||||
internet --> ingress : HTTPS :443
|
|
||||||
ingress --> nginx : HTTP :80
|
|
||||||
nginx --> aspnet : HTTP :5000\n/graphql
|
nginx --> aspnet : HTTP :5000\n/graphql
|
||||||
aspnet --> sqlserver : TCP :1433
|
aspnet --> sqlserver : TCP :1433
|
||||||
|
|
||||||
note right of ingress
|
|
||||||
<b>Dominio:</b>
|
|
||||||
academia.ingeniumcodex.com
|
|
||||||
<b>TLS:</b> Let's Encrypt
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of nginx
|
note right of nginx
|
||||||
<b>Nginx Config:</b>
|
<b>Nginx Config:</b>
|
||||||
- Gzip/Brotli compression
|
- Gzip/Brotli compression
|
||||||
|
|
@ -78,7 +55,6 @@ note right of aspnet
|
||||||
- ReadyToRun
|
- ReadyToRun
|
||||||
- Connection pooling
|
- Connection pooling
|
||||||
- Rate limiting
|
- Rate limiting
|
||||||
- JWT validation
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of sqlserver
|
note right of sqlserver
|
||||||
|
|
@ -88,16 +64,4 @@ note right of sqlserver
|
||||||
- Persistent volume
|
- Persistent volume
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note bottom of k3s
|
|
||||||
<b>CI/CD:</b> Gitea Actions
|
|
||||||
<b>Namespace:</b> academia
|
|
||||||
<b>Seguridad:</b> NetworkPolicy
|
|
||||||
end note
|
|
||||||
|
|
||||||
note right of netpol
|
|
||||||
<b>Flujo permitido:</b>
|
|
||||||
Ingress → Frontend → API → SQL
|
|
||||||
(Todo otro tráfico bloqueado)
|
|
||||||
end note
|
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
|
@ -10,44 +10,28 @@ skinparam rectangle {
|
||||||
}
|
}
|
||||||
|
|
||||||
actor "Estudiante" as student <<Persona>>
|
actor "Estudiante" as student <<Persona>>
|
||||||
actor "Administrador" as admin <<Persona>>
|
|
||||||
|
|
||||||
rectangle "Sistema de Inscripción\nAcadémica" as system <<Software System>> #lightblue {
|
rectangle "Sistema de Registro\nde Estudiantes" as system <<Software System>> #lightblue {
|
||||||
}
|
}
|
||||||
|
|
||||||
rectangle "Base de Datos\nSQL Server" as database <<External System>> #lightgray {
|
rectangle "Base de Datos\nSQL Server" as database <<External System>> #lightgray {
|
||||||
}
|
}
|
||||||
|
|
||||||
rectangle "Servidor SMTP\n(Email)" as smtp <<External System>> #lightgray {
|
student --> system : Usa para registrarse\ne inscribirse en materias
|
||||||
}
|
system --> database : Lee y escribe\ndatos de inscripciones
|
||||||
|
|
||||||
student --> system : Se registra, activa cuenta,\nse inscribe en materias,\nve compañeros de clase
|
|
||||||
admin --> system : Gestiona estudiantes\n(CRUD completo)
|
|
||||||
system --> database : Lee y escribe\ndatos de usuarios,\nestudiantes e inscripciones
|
|
||||||
system --> smtp : Envía códigos\nde activación
|
|
||||||
|
|
||||||
note right of student
|
note right of student
|
||||||
<b>Estudiante</b>
|
<b>Estudiante</b>
|
||||||
Usuario del sistema que:
|
Usuario del sistema que:
|
||||||
- Se registra y activa cuenta
|
- Se registra en el sistema
|
||||||
- Se inscribe en materias (máx 3)
|
- Se inscribe en materias (máx 3)
|
||||||
- Ve sus compañeros de clase
|
- Ve sus compañeros de clase
|
||||||
- Consulta sus inscripciones
|
- Consulta inscripciones
|
||||||
- Accede a su dashboard personal
|
|
||||||
end note
|
|
||||||
|
|
||||||
note left of admin
|
|
||||||
<b>Administrador</b>
|
|
||||||
Usuario privilegiado que:
|
|
||||||
- Gestiona todos los estudiantes
|
|
||||||
- Crea, edita, elimina registros
|
|
||||||
- Visualiza todo el sistema
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of system
|
note right of system
|
||||||
<b>Sistema de Inscripción Académica</b>
|
<b>Sistema de Registro</b>
|
||||||
Aplicación web que permite:
|
Aplicación web que permite:
|
||||||
- Autenticación (JWT + PBKDF2)
|
|
||||||
- CRUD de estudiantes
|
- CRUD de estudiantes
|
||||||
- Inscripción en materias
|
- Inscripción en materias
|
||||||
- Validación de reglas de negocio
|
- Validación de reglas de negocio
|
||||||
|
|
@ -56,24 +40,15 @@ note right of system
|
||||||
<b>Stack:</b>
|
<b>Stack:</b>
|
||||||
Frontend: Angular 21
|
Frontend: Angular 21
|
||||||
Backend: .NET 10 + GraphQL
|
Backend: .NET 10 + GraphQL
|
||||||
Auth: JWT + Roles (Admin/Student)
|
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of database
|
note right of database
|
||||||
<b>SQL Server 2022</b>
|
<b>SQL Server 2022</b>
|
||||||
Almacena:
|
Almacena:
|
||||||
- Usuarios (auth)
|
|
||||||
- Estudiantes
|
- Estudiantes
|
||||||
- Profesores
|
- Profesores
|
||||||
- Materias
|
- Materias
|
||||||
- Inscripciones
|
- Inscripciones
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note right of smtp
|
|
||||||
<b>Servicio de Email</b>
|
|
||||||
Para:
|
|
||||||
- Códigos de activación
|
|
||||||
- Notificaciones
|
|
||||||
end note
|
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 10 KiB |
|
|
@ -340,144 +340,21 @@ Scenario: Eliminación con confirmación
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### US-010: Activación de Cuenta de Estudiante
|
|
||||||
|
|
||||||
| Campo | Valor |
|
|
||||||
|-------|-------|
|
|
||||||
| **ID** | US-010 |
|
|
||||||
| **Épica** | EP-001 |
|
|
||||||
| **Prioridad** | Alta |
|
|
||||||
| **Story Points** | 8 |
|
|
||||||
| **Sprint** | 1 |
|
|
||||||
|
|
||||||
**Historia:**
|
|
||||||
> Como **estudiante nuevo**,
|
|
||||||
> quiero **activar mi cuenta usando el código proporcionado por el administrador**,
|
|
||||||
> para **crear mis credenciales y acceder al sistema**.
|
|
||||||
|
|
||||||
**Criterios de Aceptación:**
|
|
||||||
|
|
||||||
```gherkin
|
|
||||||
Scenario: Validación de código de activación
|
|
||||||
Given tengo un código de activación válido
|
|
||||||
When accedo a la URL de activación con el código
|
|
||||||
Then el sistema valida el código
|
|
||||||
And veo un mensaje de bienvenida con mi nombre
|
|
||||||
|
|
||||||
Scenario: Creación de credenciales
|
|
||||||
Given mi código de activación fue validado
|
|
||||||
When ingreso un nombre de usuario único
|
|
||||||
And ingreso una contraseña válida (mín. 6 caracteres)
|
|
||||||
And confirmo la contraseña
|
|
||||||
And presiono "Activar Cuenta"
|
|
||||||
Then mi cuenta se activa exitosamente
|
|
||||||
And veo mi código de recuperación (una sola vez)
|
|
||||||
And soy redirigido al login
|
|
||||||
|
|
||||||
Scenario: Código de activación expirado
|
|
||||||
Given mi código de activación expiró (>48 horas)
|
|
||||||
When accedo a la URL de activación
|
|
||||||
Then veo el mensaje "Código de activación expirado"
|
|
||||||
And veo instrucciones para contactar al administrador
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### US-011: Creación de Estudiante por Administrador
|
|
||||||
|
|
||||||
| Campo | Valor |
|
|
||||||
|-------|-------|
|
|
||||||
| **ID** | US-011 |
|
|
||||||
| **Épica** | EP-001 |
|
|
||||||
| **Prioridad** | Alta |
|
|
||||||
| **Story Points** | 5 |
|
|
||||||
| **Sprint** | 1 |
|
|
||||||
|
|
||||||
**Historia:**
|
|
||||||
> Como **administrador del sistema**,
|
|
||||||
> quiero **crear estudiantes y obtener su código de activación**,
|
|
||||||
> para **permitirles activar sus cuentas de forma segura**.
|
|
||||||
|
|
||||||
**Criterios de Aceptación:**
|
|
||||||
|
|
||||||
```gherkin
|
|
||||||
Scenario: Crear estudiante con código de activación
|
|
||||||
Given estoy autenticado como administrador
|
|
||||||
When navego al panel de gestión de estudiantes
|
|
||||||
And presiono "Nuevo Estudiante"
|
|
||||||
And ingreso nombre y email válidos
|
|
||||||
And presiono "Crear"
|
|
||||||
Then el estudiante se crea sin credenciales
|
|
||||||
And veo un modal con el código de activación
|
|
||||||
And veo la URL de activación completa
|
|
||||||
And veo la fecha de expiración del código
|
|
||||||
|
|
||||||
Scenario: Copiar código de activación
|
|
||||||
Given se muestra el modal de activación
|
|
||||||
When presiono el botón de copiar código
|
|
||||||
Then el código se copia al portapapeles
|
|
||||||
And veo confirmación visual
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### US-012: Control de Acceso por Roles
|
|
||||||
|
|
||||||
| Campo | Valor |
|
|
||||||
|-------|-------|
|
|
||||||
| **ID** | US-012 |
|
|
||||||
| **Épica** | EP-001 |
|
|
||||||
| **Prioridad** | Alta |
|
|
||||||
| **Story Points** | 5 |
|
|
||||||
| **Sprint** | 1 |
|
|
||||||
|
|
||||||
**Historia:**
|
|
||||||
> Como **sistema**,
|
|
||||||
> quiero **restringir el acceso según el rol del usuario**,
|
|
||||||
> para **garantizar que cada usuario solo acceda a funcionalidades autorizadas**.
|
|
||||||
|
|
||||||
**Criterios de Aceptación:**
|
|
||||||
|
|
||||||
```gherkin
|
|
||||||
Scenario: Estudiante accede a su dashboard
|
|
||||||
Given estoy autenticado como estudiante
|
|
||||||
When accedo a /dashboard
|
|
||||||
Then veo mi información personal
|
|
||||||
And veo opciones: "Mi Portal", "Mis Materias", "Compañeros"
|
|
||||||
And NO veo: "Panel Admin", "Gestión Estudiantes"
|
|
||||||
|
|
||||||
Scenario: Estudiante intenta acceder a rutas de admin
|
|
||||||
Given estoy autenticado como estudiante
|
|
||||||
When intento navegar a /admin o /students
|
|
||||||
Then soy redirigido automáticamente a /dashboard
|
|
||||||
|
|
||||||
Scenario: Administrador accede al panel completo
|
|
||||||
Given estoy autenticado como administrador
|
|
||||||
When accedo a /admin
|
|
||||||
Then veo el panel de administración completo
|
|
||||||
And puedo gestionar todos los estudiantes
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Backlog Priorizado
|
## 3. Backlog Priorizado
|
||||||
|
|
||||||
| Prioridad | Historia | Story Points | Sprint |
|
| Prioridad | Historia | Story Points | Sprint |
|
||||||
|-----------|----------|--------------|--------|
|
|-----------|----------|--------------|--------|
|
||||||
| 1 | US-001: Registro de Estudiante | 5 | 1 |
|
| 1 | US-001: Registro de Estudiante | 5 | 1 |
|
||||||
| 2 | US-011: Creación por Admin | 5 | 1 |
|
| 2 | US-002: Consulta de Materias | 3 | 1 |
|
||||||
| 3 | US-010: Activación de Cuenta | 8 | 1 |
|
| 3 | US-003: Inscripción en Materia | 8 | 1 |
|
||||||
| 4 | US-012: Control de Acceso | 5 | 1 |
|
| 4 | US-004: Cancelación de Inscripción | 3 | 1 |
|
||||||
| 5 | US-002: Consulta de Materias | 3 | 1 |
|
| 5 | US-005: Materias No Disponibles | 3 | 2 |
|
||||||
| 6 | US-003: Inscripción en Materia | 8 | 1 |
|
| 6 | US-007: Ver Compañeros | 5 | 2 |
|
||||||
| 7 | US-004: Cancelación de Inscripción | 3 | 1 |
|
| 7 | US-006: Consulta de Estudiantes | 2 | 2 |
|
||||||
| 8 | US-005: Materias No Disponibles | 3 | 2 |
|
| 8 | US-008: Actualizar Datos | 2 | 2 |
|
||||||
| 9 | US-007: Ver Compañeros | 5 | 2 |
|
| 9 | US-009: Eliminar Cuenta | 2 | 2 |
|
||||||
| 10 | US-006: Consulta de Estudiantes | 2 | 2 |
|
|
||||||
| 11 | US-008: Actualizar Datos | 2 | 2 |
|
|
||||||
| 12 | US-009: Eliminar Cuenta | 2 | 2 |
|
|
||||||
|
|
||||||
**Total Story Points:** 51
|
**Total Story Points:** 33
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -167,64 +167,6 @@ Sistema web para gestión de inscripciones estudiantiles con programa de crédit
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### RF-010: Autenticación y Roles
|
|
||||||
|
|
||||||
| Atributo | Descripción |
|
|
||||||
|----------|-------------|
|
|
||||||
| **ID** | RF-010 |
|
|
||||||
| **Nombre** | Sistema de Autenticación con Roles |
|
|
||||||
| **Descripción** | El sistema debe soportar autenticación JWT con roles Admin y Student |
|
|
||||||
| **Prioridad** | Alta |
|
|
||||||
| **Fuente** | Requisito de seguridad implícito |
|
|
||||||
|
|
||||||
**Criterios de Aceptación:**
|
|
||||||
- [ ] CA-010.1: Usuario puede iniciar sesión con credenciales válidas
|
|
||||||
- [ ] CA-010.2: Sistema genera token JWT con claims de rol
|
|
||||||
- [ ] CA-010.3: Rutas protegidas requieren autenticación
|
|
||||||
- [ ] CA-010.4: Admin puede acceder a gestión de estudiantes
|
|
||||||
- [ ] CA-010.5: Student solo puede acceder a su dashboard personal
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### RF-011: Flujo de Activación de Estudiantes
|
|
||||||
|
|
||||||
| Atributo | Descripción |
|
|
||||||
|----------|-------------|
|
|
||||||
| **ID** | RF-011 |
|
|
||||||
| **Nombre** | Activación de Cuenta de Estudiante |
|
|
||||||
| **Descripción** | Admin crea estudiantes con código de activación. Estudiantes activan su cuenta para crear credenciales |
|
|
||||||
| **Prioridad** | Alta |
|
|
||||||
| **Fuente** | Requisito de seguridad y UX |
|
|
||||||
|
|
||||||
**Criterios de Aceptación:**
|
|
||||||
- [ ] CA-011.1: Admin puede crear estudiante y obtener código de activación
|
|
||||||
- [ ] CA-011.2: Código de activación es de 12 caracteres alfanuméricos
|
|
||||||
- [ ] CA-011.3: Código de activación expira en 48 horas
|
|
||||||
- [ ] CA-011.4: Estudiante puede validar código y crear credenciales
|
|
||||||
- [ ] CA-011.5: Sistema genera código de recuperación al activar
|
|
||||||
- [ ] CA-011.6: Código de recuperación se muestra solo una vez
|
|
||||||
- [ ] CA-011.7: Admin puede regenerar código si expira
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### RF-012: Control de Acceso por Rol
|
|
||||||
|
|
||||||
| Atributo | Descripción |
|
|
||||||
|----------|-------------|
|
|
||||||
| **ID** | RF-012 |
|
|
||||||
| **Nombre** | Restricción de Acceso por Rol |
|
|
||||||
| **Descripción** | El sistema debe restringir funcionalidades según el rol del usuario |
|
|
||||||
| **Prioridad** | Alta |
|
|
||||||
| **Fuente** | Requisito de seguridad |
|
|
||||||
|
|
||||||
**Criterios de Aceptación:**
|
|
||||||
- [ ] CA-012.1: Admin puede ver y gestionar todos los estudiantes
|
|
||||||
- [ ] CA-012.2: Student no puede acceder a rutas de administración
|
|
||||||
- [ ] CA-012.3: Student solo ve su información y materias
|
|
||||||
- [ ] CA-012.4: Intentos de acceso no autorizado redirigen al dashboard
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Matriz de Trazabilidad
|
## 3. Matriz de Trazabilidad
|
||||||
|
|
||||||
| Requisito | Historia | Componente Backend | Componente Frontend |
|
| Requisito | Historia | Componente Backend | Componente Frontend |
|
||||||
|
|
@ -238,21 +180,14 @@ Sistema web para gestión de inscripciones estudiantiles con programa de crédit
|
||||||
| RF-007 | US-005 | EnrollmentDomainService | EnrollmentComponent |
|
| RF-007 | US-005 | EnrollmentDomainService | EnrollmentComponent |
|
||||||
| RF-008 | US-006 | StudentQuery | StudentListComponent |
|
| RF-008 | US-006 | StudentQuery | StudentListComponent |
|
||||||
| RF-009 | US-007 | ClassmatesQuery | ClassmatesComponent |
|
| RF-009 | US-007 | ClassmatesQuery | ClassmatesComponent |
|
||||||
| RF-010 | US-010 | LoginCommand, JwtService | LoginComponent |
|
|
||||||
| RF-011 | US-011 | ActivateAccountCommand | ActivateComponent |
|
|
||||||
| RF-012 | US-012 | AuthorizationMiddleware | auth.guard.ts |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Dependencias entre Requisitos
|
## 4. Dependencias entre Requisitos
|
||||||
|
|
||||||
```
|
```
|
||||||
RF-010 (Autenticación)
|
RF-001 (Estudiantes)
|
||||||
↓
|
↓
|
||||||
RF-011 (Activación) ──► RF-012 (Control Acceso)
|
|
||||||
↓ ↓
|
|
||||||
RF-001 (Estudiantes) │
|
|
||||||
↓ ▼
|
|
||||||
RF-002 (Programa Créditos) ← RF-004 (3 créditos/materia)
|
RF-002 (Programa Créditos) ← RF-004 (3 créditos/materia)
|
||||||
↓
|
↓
|
||||||
RF-005 (Max 3 materias) ← RF-003 (10 materias)
|
RF-005 (Max 3 materias) ← RF-003 (10 materias)
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,9 @@ type Student {
|
||||||
name: String!
|
name: String!
|
||||||
email: String!
|
email: String!
|
||||||
totalCredits: Int!
|
totalCredits: Int!
|
||||||
isActivated: Boolean!
|
|
||||||
activationExpiresAt: DateTime
|
|
||||||
enrollments: [Enrollment!]!
|
enrollments: [Enrollment!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type User {
|
|
||||||
id: Int!
|
|
||||||
username: String!
|
|
||||||
role: String! # "Admin" | "Student"
|
|
||||||
studentId: Int
|
|
||||||
studentName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Subject {
|
type Subject {
|
||||||
id: Int!
|
id: Int!
|
||||||
name: String!
|
name: String!
|
||||||
|
|
@ -67,10 +57,6 @@ type Classmate {
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
type Query {
|
type Query {
|
||||||
# Autenticación
|
|
||||||
me: User # Usuario autenticado actual
|
|
||||||
validateActivationCode(code: String!): ActivationValidation!
|
|
||||||
|
|
||||||
# Estudiantes
|
# Estudiantes
|
||||||
students: [Student!]!
|
students: [Student!]!
|
||||||
student(id: Int!): Student
|
student(id: Int!): Student
|
||||||
|
|
@ -88,27 +74,15 @@ type Query {
|
||||||
classmates(studentId: Int!): [Classmate!]!
|
classmates(studentId: Int!): [Classmate!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActivationValidation {
|
|
||||||
isValid: Boolean!
|
|
||||||
studentName: String
|
|
||||||
error: String
|
|
||||||
}
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# MUTATIONS
|
# MUTATIONS
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
# Autenticación
|
# Estudiantes
|
||||||
login(input: LoginInput!): AuthPayload!
|
createStudent(input: CreateStudentInput!): StudentPayload!
|
||||||
activateAccount(input: ActivateAccountInput!): AuthPayload!
|
|
||||||
resetPassword(input: ResetPasswordInput!): ResetPayload!
|
|
||||||
|
|
||||||
# Estudiantes (Admin crea con código de activación)
|
|
||||||
createStudent(input: CreateStudentInput!): CreateStudentPayload!
|
|
||||||
updateStudent(id: Int!, input: UpdateStudentInput!): StudentPayload!
|
updateStudent(id: Int!, input: UpdateStudentInput!): StudentPayload!
|
||||||
deleteStudent(id: Int!): DeletePayload!
|
deleteStudent(id: Int!): DeletePayload!
|
||||||
regenerateActivationCode(studentId: Int!): ActivationCodePayload!
|
|
||||||
|
|
||||||
# Inscripciones
|
# Inscripciones
|
||||||
enrollStudent(input: EnrollInput!): EnrollmentPayload!
|
enrollStudent(input: EnrollInput!): EnrollmentPayload!
|
||||||
|
|
@ -119,25 +93,6 @@ type Mutation {
|
||||||
# INPUTS
|
# INPUTS
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# Autenticación
|
|
||||||
input LoginInput {
|
|
||||||
username: String!
|
|
||||||
password: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
input ActivateAccountInput {
|
|
||||||
activationCode: String!
|
|
||||||
username: String!
|
|
||||||
password: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
input ResetPasswordInput {
|
|
||||||
username: String!
|
|
||||||
recoveryCode: String!
|
|
||||||
newPassword: String!
|
|
||||||
}
|
|
||||||
|
|
||||||
# Estudiantes
|
|
||||||
input CreateStudentInput {
|
input CreateStudentInput {
|
||||||
name: String!
|
name: String!
|
||||||
email: String!
|
email: String!
|
||||||
|
|
@ -157,35 +112,6 @@ input EnrollInput {
|
||||||
# PAYLOADS (Union para errores)
|
# PAYLOADS (Union para errores)
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
# Autenticación
|
|
||||||
type AuthPayload {
|
|
||||||
success: Boolean!
|
|
||||||
token: String
|
|
||||||
recoveryCode: String # Solo en activación (se muestra una vez)
|
|
||||||
user: User
|
|
||||||
error: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResetPayload {
|
|
||||||
success: Boolean!
|
|
||||||
error: String
|
|
||||||
}
|
|
||||||
|
|
||||||
# Estudiantes
|
|
||||||
type CreateStudentPayload {
|
|
||||||
student: Student
|
|
||||||
activationCode: String # Código para activar cuenta
|
|
||||||
activationUrl: String # URL completa de activación
|
|
||||||
expiresAt: DateTime # Cuándo expira el código
|
|
||||||
errors: [String!]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ActivationCodePayload {
|
|
||||||
activationCode: String!
|
|
||||||
activationUrl: String!
|
|
||||||
expiresAt: DateTime!
|
|
||||||
}
|
|
||||||
|
|
||||||
type StudentPayload {
|
type StudentPayload {
|
||||||
student: Student
|
student: Student
|
||||||
errors: [String!]
|
errors: [String!]
|
||||||
|
|
@ -298,54 +224,3 @@ mutation Enroll {
|
||||||
| `SAME_PROFESSOR` | "Ya tienes materia con este profesor" | enrollStudent |
|
| `SAME_PROFESSOR` | "Ya tienes materia con este profesor" | enrollStudent |
|
||||||
| `DUPLICATE_EMAIL` | "Email ya registrado" | createStudent |
|
| `DUPLICATE_EMAIL` | "Email ya registrado" | createStudent |
|
||||||
| `NOT_FOUND` | "Estudiante no encontrado" | updateStudent |
|
| `NOT_FOUND` | "Estudiante no encontrado" | updateStudent |
|
||||||
| `INVALID_CREDENTIALS` | "Usuario o contraseña incorrectos" | login |
|
|
||||||
| `USERNAME_EXISTS` | "El nombre de usuario ya está en uso" | activateAccount |
|
|
||||||
| `INVALID_ACTIVATION_CODE` | "Código de activación inválido o expirado" | activateAccount |
|
|
||||||
| `INVALID_RECOVERY_CODE` | "Código de recuperación inválido" | resetPassword |
|
|
||||||
| `UNAUTHORIZED` | "No tienes permiso para esta acción" | Operaciones protegidas |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Ejemplos de Autenticación
|
|
||||||
|
|
||||||
### Login
|
|
||||||
```graphql
|
|
||||||
mutation Login {
|
|
||||||
login(input: { username: "admin", password: "Admin123!" }) {
|
|
||||||
success
|
|
||||||
token
|
|
||||||
user { id username role studentId studentName }
|
|
||||||
error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Activar Cuenta
|
|
||||||
```graphql
|
|
||||||
mutation ActivateAccount {
|
|
||||||
activateAccount(input: {
|
|
||||||
activationCode: "MSAGDM5DNLAF"
|
|
||||||
username: "juan.perez"
|
|
||||||
password: "MiPassword123"
|
|
||||||
}) {
|
|
||||||
success
|
|
||||||
token
|
|
||||||
recoveryCode # Solo se muestra UNA vez
|
|
||||||
user { id username role studentId studentName }
|
|
||||||
error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Crear Estudiante (Admin)
|
|
||||||
```graphql
|
|
||||||
mutation CreateStudent {
|
|
||||||
createStudent(input: { name: "Juan Pérez", email: "juan@email.com" }) {
|
|
||||||
student { id name email isActivated }
|
|
||||||
activationCode # "MSAGDM5DNLAF"
|
|
||||||
activationUrl # "https://app.com/activate?code=MSAGDM5DNLAF"
|
|
||||||
expiresAt # "2026-01-11T06:00:00Z"
|
|
||||||
errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -8,36 +8,24 @@
|
||||||
## 1. Diagrama de Entidades
|
## 1. Diagrama de Entidades
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────────────┐
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
│ PROFESSOR │ │ STUDENT │
|
│ PROFESSOR │ │ STUDENT │
|
||||||
├─────────────────┤ ├─────────────────────────┤
|
├─────────────────┤ ├─────────────────┤
|
||||||
│ Id: int (PK) │ │ Id: int (PK) │
|
│ Id: int (PK) │ │ Id: int (PK) │
|
||||||
│ Name: string │ │ Name: string │
|
│ Name: string │ │ Name: string │
|
||||||
└────────┬────────┘ │ Email: Email │
|
└────────┬────────┘ │ Email: Email │
|
||||||
│ │ ActivationCodeHash? │ ← Nuevo
|
│ │ RowVersion │
|
||||||
│ 1:2 │ ActivationExpiresAt? │ ← Nuevo
|
│ 1:2 └────────┬────────┘
|
||||||
▼ │ IsActivated (computed) │ ← Nuevo
|
▼ │
|
||||||
┌─────────────────┐ │ RowVersion │
|
┌─────────────────┐ │ 0..3
|
||||||
│ SUBJECT │ └────────┬────────────────┘
|
│ SUBJECT │ ▼
|
||||||
├─────────────────┤ │
|
├─────────────────┤ ┌─────────────────┐
|
||||||
│ Id: int (PK) │ │ 0..3
|
│ Id: int (PK) │◄──────│ ENROLLMENT │
|
||||||
│ Name: string │ ▼
|
│ Name: string │ 1:N ├─────────────────┤
|
||||||
│ Credits: 3 │ ┌─────────────────┐
|
│ Credits: 3 │ │ Id: int (PK) │
|
||||||
│ ProfessorId: FK │◄──────│ ENROLLMENT │
|
│ ProfessorId: FK │ │ StudentId: FK │
|
||||||
└─────────────────┘ 1:N ├─────────────────┤
|
└─────────────────┘ │ SubjectId: FK │
|
||||||
│ Id: int (PK) │
|
│ EnrolledAt │
|
||||||
┌─────────────────┐ │ StudentId: FK │
|
|
||||||
│ USER │ │ SubjectId: FK │
|
|
||||||
├─────────────────┤ │ EnrolledAt │
|
|
||||||
│ Id: int (PK) │ └─────────────────┘
|
|
||||||
│ Username │
|
|
||||||
│ PasswordHash │
|
|
||||||
│ RecoveryCodeHash│
|
|
||||||
│ Role (Admin/ │
|
|
||||||
│ Student) │
|
|
||||||
│ StudentId?: FK │───────► 0..1 Student
|
|
||||||
│ CreatedAt │
|
|
||||||
│ LastLoginAt? │
|
|
||||||
└─────────────────┘
|
└─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -50,16 +38,10 @@
|
||||||
```csharp
|
```csharp
|
||||||
public class Student
|
public class Student
|
||||||
{
|
{
|
||||||
public const int MaxEnrollments = 3;
|
|
||||||
|
|
||||||
public int Id { get; private set; }
|
public int Id { get; private set; }
|
||||||
public string Name { get; private set; }
|
public string Name { get; private set; }
|
||||||
public Email Email { get; private set; }
|
public Email Email { get; private set; }
|
||||||
|
public byte[] RowVersion { get; private set; }
|
||||||
// Campos de Activación (nuevo flujo)
|
|
||||||
public string? ActivationCodeHash { get; private set; }
|
|
||||||
public DateTime? ActivationExpiresAt { get; private set; }
|
|
||||||
public bool IsActivated => ActivationCodeHash == null;
|
|
||||||
|
|
||||||
private readonly List<Enrollment> _enrollments = new();
|
private readonly List<Enrollment> _enrollments = new();
|
||||||
public IReadOnlyCollection<Enrollment> Enrollments => _enrollments;
|
public IReadOnlyCollection<Enrollment> Enrollments => _enrollments;
|
||||||
|
|
@ -77,40 +59,6 @@ public class Student
|
||||||
var enrollment = _enrollments.FirstOrDefault(e => e.SubjectId == subjectId);
|
var enrollment = _enrollments.FirstOrDefault(e => e.SubjectId == subjectId);
|
||||||
if (enrollment != null) _enrollments.Remove(enrollment);
|
if (enrollment != null) _enrollments.Remove(enrollment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Métodos de activación
|
|
||||||
public void SetActivationCode(string codeHash, TimeSpan expiresIn)
|
|
||||||
{
|
|
||||||
ActivationCodeHash = codeHash;
|
|
||||||
ActivationExpiresAt = DateTime.UtcNow.Add(expiresIn);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ClearActivationCode()
|
|
||||||
{
|
|
||||||
ActivationCodeHash = null;
|
|
||||||
ActivationExpiresAt = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsActivationExpired() =>
|
|
||||||
ActivationExpiresAt.HasValue && DateTime.UtcNow > ActivationExpiresAt.Value;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### User (Autenticación)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class User
|
|
||||||
{
|
|
||||||
public int Id { get; private set; }
|
|
||||||
public string Username { get; private set; } // Almacenado en minúsculas
|
|
||||||
public string PasswordHash { get; private set; } // PBKDF2-SHA256
|
|
||||||
public string RecoveryCodeHash { get; private set; }
|
|
||||||
public string Role { get; private set; } // "Admin" | "Student"
|
|
||||||
public int? StudentId { get; private set; } // FK opcional a Student
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
public DateTime? LastLoginAt { get; private set; }
|
|
||||||
|
|
||||||
public Student? Student { get; private set; } // Navegación
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -239,33 +187,3 @@ public class SameProfessorConstraintException : DomainException
|
||||||
| No repetir profesor | Student | Domain Service |
|
| No repetir profesor | Student | Domain Service |
|
||||||
| 3 créditos/materia | Subject | Constante |
|
| 3 créditos/materia | Subject | Constante |
|
||||||
| 2 materias/profesor | Professor | Seed Data |
|
| 2 materias/profesor | Professor | Seed Data |
|
||||||
| Username único | User | DB Constraint |
|
|
||||||
| Código activación expira | Student | ActivationExpiresAt |
|
|
||||||
| Student ↔ User (1:0..1) | User | StudentId nullable |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Flujo de Activación
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
||||||
│ ADMIN │ │ STUDENT │ │ USER │
|
|
||||||
│ (creates) │───►│ (pending) │ │ (not yet) │
|
|
||||||
└──────────────┘ └──────┬───────┘ └──────────────┘
|
|
||||||
│
|
|
||||||
SetActivationCode()
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐
|
|
||||||
│ STUDENT │
|
|
||||||
│ (has code) │
|
|
||||||
└──────┬───────┘
|
|
||||||
│
|
|
||||||
Activate(username, password)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
||||||
│ ADMIN │ │ STUDENT │◄───│ USER │
|
|
||||||
│ (creates) │ │ (activated) │ │ StudentId=X │
|
|
||||||
└──────────────┘ └──────────────┘ └──────────────┘
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ services:
|
||||||
container_name: sqlserver-students
|
container_name: sqlserver-students
|
||||||
environment:
|
environment:
|
||||||
- ACCEPT_EULA=Y
|
- ACCEPT_EULA=Y
|
||||||
- SA_PASSWORD=${DB_PASSWORD:-Asde71.4Asde71.4}
|
- SA_PASSWORD=${DB_PASSWORD:-YourStrong@Passw0rd}
|
||||||
- MSSQL_PID=Developer
|
- MSSQL_PID=Developer
|
||||||
ports:
|
ports:
|
||||||
- "1433:1433"
|
- "1433:1433"
|
||||||
|
|
@ -44,7 +44,7 @@ docker logs sqlserver-students
|
||||||
|
|
||||||
# Conectar a SQL Server
|
# Conectar a SQL Server
|
||||||
docker exec -it sqlserver-students /opt/mssql-tools18/bin/sqlcmd \
|
docker exec -it sqlserver-students /opt/mssql-tools18/bin/sqlcmd \
|
||||||
-S localhost -U sa -P 'Asde71.4Asde71.4' -C
|
-S localhost -U sa -P 'YourStrong@Passw0rd' -C
|
||||||
|
|
||||||
# Detener
|
# Detener
|
||||||
docker-compose -f deploy/docker/docker-compose.yml down
|
docker-compose -f deploy/docker/docker-compose.yml down
|
||||||
|
|
@ -58,7 +58,7 @@ docker-compose -f deploy/docker/docker-compose.yml down
|
||||||
// appsettings.Development.json
|
// appsettings.Development.json
|
||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=localhost;Database=StudentEnrollment;User Id=sa;Password=Asde71.4Asde71.4;TrustServerCertificate=True"
|
"DefaultConnection": "Server=localhost;Database=StudentEnrollment;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Server=localhost;Database=StudentEnrollment;User Id=sa;Password=Asde71.4Asde71.4;TrustServerCertificate=True"
|
"DefaultConnection": "Server=localhost;Database=StudentEnrollment;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True"
|
||||||
},
|
},
|
||||||
"GraphQL": {
|
"GraphQL": {
|
||||||
"EnableIntrospection": true
|
"EnableIntrospection": true
|
||||||
|
|
@ -63,7 +63,7 @@ dotnet user-secrets init
|
||||||
|
|
||||||
# Guardar connection string
|
# Guardar connection string
|
||||||
dotnet user-secrets set "ConnectionStrings:DefaultConnection" \
|
dotnet user-secrets set "ConnectionStrings:DefaultConnection" \
|
||||||
"Server=localhost;Database=StudentEnrollment;User Id=sa;Password=Asde71.4Asde71.4;TrustServerCertificate=True"
|
"Server=localhost;Database=StudentEnrollment;User Id=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=True"
|
||||||
|
|
||||||
# Listar secrets
|
# Listar secrets
|
||||||
dotnet user-secrets list
|
dotnet user-secrets list
|
||||||
|
|
@ -104,7 +104,7 @@ export const environment = {
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Database
|
# Database
|
||||||
DB_PASSWORD=Asde71.4Asde71.4
|
DB_PASSWORD=YourStrong@Passw0rd
|
||||||
DB_NAME=StudentEnrollment
|
DB_NAME=StudentEnrollment
|
||||||
|
|
||||||
# API
|
# API
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
|
@ -1,229 +0,0 @@
|
||||||
# Reporte de Tests Automatizados
|
|
||||||
|
|
||||||
**Fecha:** 2026-01-09
|
|
||||||
**Versión:** 1.0
|
|
||||||
**Ejecutor:** Claude AI + Playwright
|
|
||||||
**Ambiente:** Desarrollo Local (localhost:4200 / localhost:5000)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
| Métrica | Backend | E2E | Total |
|
|
||||||
|---------|---------|-----|-------|
|
|
||||||
| Tests Implementados | 133 | 97 | **230** |
|
|
||||||
| Tests Pasados | 133 | 25 | 158 |
|
|
||||||
| Tests Fallidos | 0 | 72 | 72 |
|
|
||||||
| Porcentaje Éxito | 100% | 26% | 69% |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests Backend (.NET)
|
|
||||||
|
|
||||||
### Resultados por Proyecto
|
|
||||||
|
|
||||||
| Proyecto | Tests | Pasados | Duración |
|
|
||||||
|----------|-------|---------|----------|
|
|
||||||
| Application.Tests | 98 | 98 ✅ | 321 ms |
|
|
||||||
| Domain.Tests | 30 | 30 ✅ | 142 ms |
|
|
||||||
| Integration.Tests | 5 | 5 ✅ | 2 s |
|
|
||||||
| **Total** | **133** | **133** | ~3 s |
|
|
||||||
|
|
||||||
### Tests de Auth (Nuevos)
|
|
||||||
|
|
||||||
| Clase | Tests | Estado |
|
|
||||||
|-------|-------|--------|
|
|
||||||
| LoginCommandTests | 6 | ✅ 100% |
|
|
||||||
| RegisterCommandTests | 8 | ✅ 100% |
|
|
||||||
| ResetPasswordCommandTests | 8 | ✅ 100% |
|
|
||||||
| ActivateAccountCommandTests | 10 | ✅ 100% |
|
|
||||||
| **Total Auth** | **32** | **✅ 100%** |
|
|
||||||
|
|
||||||
### Cobertura de Funcionalidad Backend
|
|
||||||
|
|
||||||
| Funcionalidad | Tests | Cobertura |
|
|
||||||
|---------------|-------|-----------|
|
|
||||||
| Login con credenciales válidas | ✅ | Usuario, token, lastLogin |
|
|
||||||
| Login con credenciales inválidas | ✅ | Usuario inexistente, password incorrecto |
|
|
||||||
| Normalización de username | ✅ | Conversión a lowercase |
|
|
||||||
| Registro de usuario | ✅ | Creación, recovery code, validaciones |
|
|
||||||
| Usuario duplicado | ✅ | Mensaje de error apropiado |
|
|
||||||
| Validación de password | ✅ | Mínimo 6 caracteres |
|
|
||||||
| Reset de contraseña | ✅ | Código válido/inválido |
|
|
||||||
| Activación de cuenta | ✅ | Código válido, expirado, username duplicado |
|
|
||||||
| Generación JWT | ✅ | Token post-activación |
|
|
||||||
| Inscripción en materias | ✅ | Creación, límites, restricciones |
|
|
||||||
| Regla máximo 3 materias | ✅ | MaxEnrollmentsExceededException |
|
|
||||||
| Regla mismo profesor | ✅ | SameProfessorConstraintException |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tests E2E (Playwright)
|
|
||||||
|
|
||||||
### Resultados por Archivo
|
|
||||||
|
|
||||||
| Archivo | Tests | Pasados | Fallidos |
|
|
||||||
|---------|-------|---------|----------|
|
|
||||||
| auth.spec.ts | 15 | 2 | 13 |
|
|
||||||
| role-access.spec.ts | 16 | 0 | 16 |
|
|
||||||
| enrollment-restrictions.spec.ts | 16 | 0 | 16 |
|
|
||||||
| activation.spec.ts | 18 | 10 | 8 |
|
|
||||||
| student-crud.spec.ts | 6 | 6 | 0 |
|
|
||||||
| enrollment.spec.ts | 7 | 7 | 0 |
|
|
||||||
| classmates.spec.ts | 7 | 0 | 7 |
|
|
||||||
| **Total** | **97** | **25** | **72** |
|
|
||||||
|
|
||||||
### Análisis de Fallos E2E
|
|
||||||
|
|
||||||
Los tests E2E que fallan se deben principalmente a:
|
|
||||||
|
|
||||||
1. **Selectores de UI no coinciden** - Los tests usan selectores genéricos (`getByRole`, `getByLabel`) que no encuentran los elementos exactos en la UI actual.
|
|
||||||
|
|
||||||
2. **Tests con mocks vs servidor real** - Los tests existentes (student-crud, enrollment) usan mocks GraphQL y pasan. Los nuevos tests intentan interactuar con la UI real.
|
|
||||||
|
|
||||||
3. **Timeouts** - Algunos tests tienen timeouts de 30s esperando elementos que no aparecen.
|
|
||||||
|
|
||||||
### Tests E2E Pasados (25)
|
|
||||||
|
|
||||||
| Test | Descripción |
|
|
||||||
|------|-------------|
|
|
||||||
| ✅ activation.spec.ts | debe mostrar error con código inválido |
|
|
||||||
| ✅ activation.spec.ts | formulario de activación debe tener campos requeridos |
|
|
||||||
| ✅ activation.spec.ts | admin puede regenerar código para estudiante |
|
|
||||||
| ✅ activation.spec.ts | debe mostrar código de recuperación después de activar |
|
|
||||||
| ✅ activation.spec.ts | debe validar contraseña mínima en activación |
|
|
||||||
| ✅ activation.spec.ts | debe validar usuario único en activación |
|
|
||||||
| ✅ activation.spec.ts | estudiante activado ve su dashboard |
|
|
||||||
| ✅ activation.spec.ts | página de activación no requiere autenticación |
|
|
||||||
| ✅ activation.spec.ts | código de recuperación solo se muestra una vez |
|
|
||||||
| ✅ auth.spec.ts | debe redirigir a login si no está autenticado |
|
|
||||||
| ✅ auth.spec.ts | debe cerrar sesión correctamente |
|
|
||||||
| ✅ student-crud.spec.ts | debe mostrar el listado de estudiantes |
|
|
||||||
| ✅ student-crud.spec.ts | debe navegar al formulario de nuevo estudiante |
|
|
||||||
| ✅ student-crud.spec.ts | debe crear un estudiante |
|
|
||||||
| ✅ student-crud.spec.ts | debe mostrar errores de validación |
|
|
||||||
| ✅ student-crud.spec.ts | debe mostrar error si el nombre es muy corto |
|
|
||||||
| ✅ student-crud.spec.ts | debe mostrar error si el email es inválido |
|
|
||||||
| ✅ enrollment.spec.ts | todos (7 tests) |
|
|
||||||
|
|
||||||
### Acciones Requeridas para E2E
|
|
||||||
|
|
||||||
Para que los tests E2E pasen al 100%, se requiere:
|
|
||||||
|
|
||||||
1. **Ajustar selectores** - Actualizar los selectores para que coincidan con los elementos reales de la UI:
|
|
||||||
```typescript
|
|
||||||
// Actual (genérico)
|
|
||||||
page.getByRole('heading', { name: /iniciar sesión/i })
|
|
||||||
|
|
||||||
// Ajustado (específico)
|
|
||||||
page.getByTestId('login-heading')
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Agregar data-testid** - Agregar atributos `data-testid` a los componentes Angular para facilitar la selección.
|
|
||||||
|
|
||||||
3. **Configurar datos de prueba** - Crear fixtures con usuarios de prueba (admin, student) para tests de autenticación.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cobertura de Reglas de Negocio
|
|
||||||
|
|
||||||
| Regla | Backend | E2E | Estado |
|
|
||||||
|-------|---------|-----|--------|
|
|
||||||
| Máximo 3 materias (9 créditos) | ✅ | ⚠️ | Parcial |
|
|
||||||
| No repetir profesor | ✅ | ⚠️ | Parcial |
|
|
||||||
| 10 materias disponibles | ✅ | ⚠️ | Parcial |
|
|
||||||
| 3 créditos por materia | ✅ | ⚠️ | Parcial |
|
|
||||||
| 5 profesores con 2 materias | ✅ | ⚠️ | Parcial |
|
|
||||||
| Autenticación JWT | ✅ | ✅ | Completo |
|
|
||||||
| Activación de cuenta | ✅ | ✅ | Completo |
|
|
||||||
| Control de acceso por roles | ✅ | ⚠️ | Parcial |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estructura de Tests Implementados
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── Application.Tests/
|
|
||||||
│ ├── Auth/ # ✨ NUEVO
|
|
||||||
│ │ ├── LoginCommandTests.cs
|
|
||||||
│ │ ├── RegisterCommandTests.cs
|
|
||||||
│ │ ├── ResetPasswordCommandTests.cs
|
|
||||||
│ │ └── ActivateAccountCommandTests.cs
|
|
||||||
│ ├── Students/
|
|
||||||
│ ├── Enrollments/
|
|
||||||
│ ├── Subjects/
|
|
||||||
│ ├── Professors/
|
|
||||||
│ └── Validators/
|
|
||||||
├── Domain.Tests/
|
|
||||||
│ ├── Entities/
|
|
||||||
│ ├── ValueObjects/
|
|
||||||
│ └── Services/
|
|
||||||
└── Integration.Tests/
|
|
||||||
|
|
||||||
src/frontend/e2e/
|
|
||||||
├── auth.spec.ts # ✨ NUEVO
|
|
||||||
├── role-access.spec.ts # ✨ NUEVO
|
|
||||||
├── enrollment-restrictions.spec.ts # ✨ NUEVO
|
|
||||||
├── activation.spec.ts # ✨ NUEVO
|
|
||||||
├── student-crud.spec.ts
|
|
||||||
├── enrollment.spec.ts
|
|
||||||
├── classmates.spec.ts
|
|
||||||
└── mocks/graphql.mock.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comandos de Ejecución
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend - Todos los tests
|
|
||||||
dotnet test tests/Application.Tests
|
|
||||||
dotnet test tests/Domain.Tests
|
|
||||||
dotnet test tests/Integration.Tests
|
|
||||||
|
|
||||||
# Backend - Solo Auth
|
|
||||||
dotnet test tests/Application.Tests --filter "FullyQualifiedName~Auth"
|
|
||||||
|
|
||||||
# E2E - Todos
|
|
||||||
cd src/frontend && npx playwright test
|
|
||||||
|
|
||||||
# E2E - Con reporter HTML
|
|
||||||
npx playwright test --reporter=html
|
|
||||||
|
|
||||||
# E2E - Solo tests que pasan (mocks)
|
|
||||||
npx playwright test student-crud.spec.ts enrollment.spec.ts
|
|
||||||
|
|
||||||
# E2E - Por categoría
|
|
||||||
npx playwright test auth.spec.ts
|
|
||||||
npx playwright test activation.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusiones
|
|
||||||
|
|
||||||
1. **Backend 100% funcional** - Todos los 133 tests de backend pasan correctamente, incluyendo los 32 nuevos tests de autenticación.
|
|
||||||
|
|
||||||
2. **E2E parcialmente funcional** - 25 de 97 tests E2E pasan. Los tests existentes con mocks funcionan, los nuevos requieren ajustes de selectores.
|
|
||||||
|
|
||||||
3. **Cobertura de Auth completa** - Los handlers de Login, Register, ResetPassword y ActivateAccount tienen cobertura de tests unitarios al 100%.
|
|
||||||
|
|
||||||
4. **Reglas de negocio cubiertas** - Las restricciones de máximo 3 materias y no repetir profesor están completamente testeadas en backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Próximos Pasos
|
|
||||||
|
|
||||||
1. [ ] Agregar `data-testid` a componentes Angular
|
|
||||||
2. [ ] Ajustar selectores en tests E2E
|
|
||||||
3. [ ] Crear fixtures de usuarios de prueba
|
|
||||||
4. [ ] Configurar test database para E2E
|
|
||||||
5. [ ] Agregar tests E2E al CI/CD pipeline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Firma Digital:**
|
|
||||||
QA Engineer: Claude AI
|
|
||||||
Fecha: 2026-01-09T07:45:00Z
|
|
||||||
Herramienta: Playwright + xUnit + NSubstitute
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
# QA Report: Student Activation Flow
|
|
||||||
|
|
||||||
**Fecha:** 2026-01-09
|
|
||||||
**Tester:** Claude AI (QA Automation)
|
|
||||||
**Ambiente:** localhost:4200 (Frontend) / localhost:5000 (Backend)
|
|
||||||
**Navegador:** Chromium (Playwright MCP)
|
|
||||||
**Version:** 1.0.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumen Ejecutivo
|
|
||||||
|
|
||||||
| Metrica | Resultado |
|
|
||||||
|---------|-----------|
|
|
||||||
| **Casos de Prueba** | 5 |
|
|
||||||
| **Pasados** | 5 |
|
|
||||||
| **Fallidos** | 0 |
|
|
||||||
| **Bloqueados** | 0 |
|
|
||||||
| **Cobertura** | Flujo completo de activacion |
|
|
||||||
|
|
||||||
### Estado General: **PASSED**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flujo Probado
|
|
||||||
|
|
||||||
```
|
|
||||||
Admin Login → Crear Estudiante → Modal Activacion →
|
|
||||||
Validar Codigo → Activar Cuenta → Login Estudiante →
|
|
||||||
Dashboard Estudiante → Verificar Restricciones de Rol
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Casos de Prueba
|
|
||||||
|
|
||||||
### TC-001: Admin - Crear Nuevo Estudiante
|
|
||||||
|
|
||||||
**Objetivo:** Verificar que un administrador puede crear un estudiante y recibir codigo de activacion
|
|
||||||
|
|
||||||
**Precondiciones:**
|
|
||||||
- Usuario admin logueado
|
|
||||||
- Backend y frontend funcionando
|
|
||||||
|
|
||||||
**Pasos:**
|
|
||||||
1. Navegar a Panel Admin
|
|
||||||
2. Click en "Nuevo Estudiante"
|
|
||||||
3. Completar formulario con datos validos
|
|
||||||
4. Click en "Crear Estudiante"
|
|
||||||
|
|
||||||
**Resultado Esperado:** Modal de activacion con codigo y URL
|
|
||||||
|
|
||||||
**Resultado Actual:** **PASSED**
|
|
||||||
|
|
||||||
#### Evidencia:
|
|
||||||
|
|
||||||
**Paso 1-2: Formulario vacio**
|
|
||||||

|
|
||||||
|
|
||||||
**Paso 3: Formulario completado**
|
|
||||||

|
|
||||||
|
|
||||||
**Paso 4: Modal de activacion**
|
|
||||||

|
|
||||||
|
|
||||||
**Datos del test:**
|
|
||||||
- Nombre: Laura Garcia QA
|
|
||||||
- Email: laura.garcia.qa@example.com
|
|
||||||
- Codigo generado: `MSAGDM5DNLAF`
|
|
||||||
- URL: `http://localhost:4200/activate?code=MSAGDM5DNLAF`
|
|
||||||
- Expiracion: 11/01/2026, 1:22 a.m.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-002: Validacion de Codigo de Activacion
|
|
||||||
|
|
||||||
**Objetivo:** Verificar que la pagina /activate valida correctamente el codigo
|
|
||||||
|
|
||||||
**Precondiciones:**
|
|
||||||
- Codigo de activacion valido generado
|
|
||||||
- Usuario no autenticado
|
|
||||||
|
|
||||||
**Pasos:**
|
|
||||||
1. Navegar a URL de activacion con codigo
|
|
||||||
2. Esperar validacion del servidor
|
|
||||||
3. Verificar que muestra nombre del estudiante
|
|
||||||
|
|
||||||
**Resultado Esperado:** Pagina muestra "Bienvenido, [Nombre del Estudiante]"
|
|
||||||
|
|
||||||
**Resultado Actual:** **PASSED**
|
|
||||||
|
|
||||||
#### Evidencia:
|
|
||||||
|
|
||||||
**Pagina de activacion validada**
|
|
||||||

|
|
||||||
|
|
||||||
**Observacion:** El sistema correctamente identifica al estudiante "Laura Garcia QA" a partir del codigo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-003: Activacion de Cuenta de Estudiante
|
|
||||||
|
|
||||||
**Objetivo:** Verificar que el estudiante puede crear credenciales y activar su cuenta
|
|
||||||
|
|
||||||
**Precondiciones:**
|
|
||||||
- Codigo validado exitosamente
|
|
||||||
- Formulario de activacion visible
|
|
||||||
|
|
||||||
**Pasos:**
|
|
||||||
1. Ingresar nombre de usuario unico
|
|
||||||
2. Ingresar contrasena (min 6 caracteres)
|
|
||||||
3. Confirmar contrasena
|
|
||||||
4. Click en "Activar Cuenta"
|
|
||||||
|
|
||||||
**Resultado Esperado:**
|
|
||||||
- Cuenta activada exitosamente
|
|
||||||
- Codigo de recuperacion mostrado
|
|
||||||
- Redireccion a login
|
|
||||||
|
|
||||||
**Resultado Actual:** **PASSED**
|
|
||||||
|
|
||||||
#### Evidencia:
|
|
||||||
|
|
||||||
**Formulario de activacion completado**
|
|
||||||

|
|
||||||
|
|
||||||
**Activacion exitosa con codigo de recuperacion**
|
|
||||||

|
|
||||||
|
|
||||||
**Datos del test:**
|
|
||||||
- Usuario creado: `laura.garcia`
|
|
||||||
- Contrasena: `Laura123!`
|
|
||||||
- Codigo de recuperacion: `HC6DU858GVMQ`
|
|
||||||
|
|
||||||
**Nota de Seguridad:** El codigo de recuperacion se muestra solo UNA vez. El sistema advierte correctamente al usuario.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-004: Login de Estudiante Activado
|
|
||||||
|
|
||||||
**Objetivo:** Verificar que el estudiante puede iniciar sesion con las credenciales creadas
|
|
||||||
|
|
||||||
**Precondiciones:**
|
|
||||||
- Cuenta activada exitosamente
|
|
||||||
- Credenciales conocidas
|
|
||||||
|
|
||||||
**Pasos:**
|
|
||||||
1. Navegar a pagina de login
|
|
||||||
2. Ingresar usuario y contrasena
|
|
||||||
3. Click en "Iniciar Sesion"
|
|
||||||
|
|
||||||
**Resultado Esperado:**
|
|
||||||
- Login exitoso
|
|
||||||
- Redireccion a dashboard de estudiante
|
|
||||||
- Navegacion especifica de rol Student
|
|
||||||
|
|
||||||
**Resultado Actual:** **PASSED**
|
|
||||||
|
|
||||||
#### Evidencia:
|
|
||||||
|
|
||||||
**Login page**
|
|
||||||

|
|
||||||
|
|
||||||
**Dashboard de estudiante**
|
|
||||||

|
|
||||||
|
|
||||||
**Observaciones:**
|
|
||||||
- Navegacion muestra: "Mi Portal", "Mis Materias", "Compañeros" (rol Student)
|
|
||||||
- NO muestra: "Panel Admin", "Gestion Estudiantes" (rol Admin)
|
|
||||||
- Banner muestra nombre correcto: "Bienvenido, Laura Garcia QA"
|
|
||||||
- Email mostrado correctamente
|
|
||||||
- Contador de creditos: 0 (nuevo estudiante)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TC-005: Control de Acceso Basado en Roles
|
|
||||||
|
|
||||||
**Objetivo:** Verificar que estudiantes NO pueden acceder a rutas de administrador
|
|
||||||
|
|
||||||
**Precondiciones:**
|
|
||||||
- Usuario estudiante logueado (laura.garcia)
|
|
||||||
|
|
||||||
**Pasos:**
|
|
||||||
1. Intentar navegar a /admin
|
|
||||||
2. Intentar navegar a /students
|
|
||||||
|
|
||||||
**Resultado Esperado:** Redireccion a /dashboard en ambos casos
|
|
||||||
|
|
||||||
**Resultado Actual:** **PASSED**
|
|
||||||
|
|
||||||
#### Evidencia:
|
|
||||||
|
|
||||||
**Intento de acceso a /admin - Redirigido a dashboard**
|
|
||||||

|
|
||||||
|
|
||||||
**Observacion:** El sistema correctamente redirige al estudiante a su dashboard cuando intenta acceder a rutas protegidas de administrador. La URL final es `/dashboard` en lugar de `/admin` o `/students`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Comparacion de Roles
|
|
||||||
|
|
||||||
### Navegacion por Rol
|
|
||||||
|
|
||||||
| Elemento | Admin | Student |
|
|
||||||
|----------|-------|---------|
|
|
||||||
| Panel Admin | Si | No |
|
|
||||||
| Gestion Estudiantes | Si | No |
|
|
||||||
| Mi Portal | No | Si |
|
|
||||||
| Mis Materias | No | Si |
|
|
||||||
| Compañeros | No | Si |
|
|
||||||
|
|
||||||
### Panel Admin vs Dashboard Estudiante
|
|
||||||
|
|
||||||
**Admin Panel:**
|
|
||||||

|
|
||||||
|
|
||||||
**Student Dashboard:**
|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Funcionalidades Verificadas
|
|
||||||
|
|
||||||
| Funcionalidad | Estado | Notas |
|
|
||||||
|---------------|--------|-------|
|
|
||||||
| Crear estudiante (Admin) | PASS | Genera codigo de 12 caracteres |
|
|
||||||
| Modal de activacion | PASS | Muestra codigo, URL, expiracion |
|
|
||||||
| Boton copiar codigo | PASS | Funcionalidad de clipboard |
|
|
||||||
| Validacion de codigo | PASS | Identifica estudiante correctamente |
|
|
||||||
| Formulario de activacion | PASS | Validaciones de password |
|
|
||||||
| Codigo de recuperacion | PASS | Se muestra solo una vez |
|
|
||||||
| Advertencia de seguridad | PASS | Warning visible |
|
|
||||||
| Login post-activacion | PASS | Credenciales funcionan |
|
|
||||||
| Dashboard de estudiante | PASS | Vista correcta por rol |
|
|
||||||
| Guards de rutas | PASS | Redireccion automatica |
|
|
||||||
| JWT con claims de rol | PASS | Backend valida correctamente |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Defectos Encontrados
|
|
||||||
|
|
||||||
**No se encontraron defectos en este flujo.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recomendaciones
|
|
||||||
|
|
||||||
1. **Expiracion del codigo:** El codigo expira en 48 horas. Considerar agregar opcion para regenerar codigo desde el panel de admin.
|
|
||||||
|
|
||||||
2. **Validacion de password:** Actualmente solo valida minimo 6 caracteres. Considerar agregar requisitos de complejidad (mayusculas, numeros, caracteres especiales).
|
|
||||||
|
|
||||||
3. **Feedback de copia:** Cuando se copia el codigo, mostrar confirmacion visual mas prominente.
|
|
||||||
|
|
||||||
4. **Accesibilidad:** Los campos de password deberian tener `autocomplete="new-password"` (warning en consola).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
El flujo de activacion de estudiantes funciona correctamente de principio a fin. Todas las pruebas pasaron satisfactoriamente, incluyendo:
|
|
||||||
|
|
||||||
- Generacion de codigo de activacion
|
|
||||||
- Validacion del codigo
|
|
||||||
- Creacion de credenciales
|
|
||||||
- Autenticacion post-activacion
|
|
||||||
- Control de acceso basado en roles
|
|
||||||
|
|
||||||
El sistema esta **listo para produccion** en cuanto a este flujo.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Firma Digital:**
|
|
||||||
QA Engineer: Claude AI
|
|
||||||
Fecha: 2026-01-09T06:30:00Z
|
|
||||||
Herramienta: Playwright MCP + Claude Code
|
|
||||||
1554
generate-docs.sh
1482
index.html
|
|
@ -35,14 +35,6 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
|
||||||
// Use raw column name for index to avoid value object issues
|
// Use raw column name for index to avoid value object issues
|
||||||
builder.HasIndex("Email").IsUnique();
|
builder.HasIndex("Email").IsUnique();
|
||||||
|
|
||||||
// Activation fields
|
|
||||||
builder.Property(s => s.ActivationCodeHash)
|
|
||||||
.HasMaxLength(256);
|
|
||||||
|
|
||||||
builder.Property(s => s.ActivationExpiresAt);
|
|
||||||
|
|
||||||
builder.Ignore(s => s.IsActivated);
|
|
||||||
|
|
||||||
builder.HasMany(s => s.Enrollments)
|
builder.HasMany(s => s.Enrollments)
|
||||||
.WithOne(e => e.Student)
|
.WithOne(e => e.Student)
|
||||||
.HasForeignKey(e => e.StudentId)
|
.HasForeignKey(e => e.StudentId)
|
||||||
|
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Adapters.Driven.Persistence.Context;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Adapters.Driven.Persistence.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(AppDbContext))]
|
|
||||||
[Migration("20260109055746_AddStudentActivation")]
|
|
||||||
partial class AddStudentActivation
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.1")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Enrollment", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("EnrolledAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<int>("StudentId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<int>("SubjectId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("SubjectId");
|
|
||||||
|
|
||||||
b.HasIndex("StudentId", "SubjectId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Enrollments", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Professor", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("nvarchar(100)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("Professors", (string)null);
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
Name = "Dr. García"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 2,
|
|
||||||
Name = "Dra. Martínez"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 3,
|
|
||||||
Name = "Dr. López"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 4,
|
|
||||||
Name = "Dra. Rodríguez"
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 5,
|
|
||||||
Name = "Dr. Hernández"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Student", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<string>("ActivationCodeHash")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("nvarchar(256)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("ActivationExpiresAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(150)
|
|
||||||
.HasColumnType("nvarchar(150)");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("nvarchar(100)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Email")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Students", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Subject", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<int>("Credits")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int")
|
|
||||||
.HasDefaultValue(3);
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("nvarchar(100)");
|
|
||||||
|
|
||||||
b.Property<int>("ProfessorId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("ProfessorId");
|
|
||||||
|
|
||||||
b.ToTable("Subjects", (string)null);
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Matemáticas I",
|
|
||||||
ProfessorId = 1
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 2,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Matemáticas II",
|
|
||||||
ProfessorId = 1
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 3,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Física I",
|
|
||||||
ProfessorId = 2
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 4,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Física II",
|
|
||||||
ProfessorId = 2
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 5,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Programación I",
|
|
||||||
ProfessorId = 3
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 6,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Programación II",
|
|
||||||
ProfessorId = 3
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 7,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Base de Datos I",
|
|
||||||
ProfessorId = 4
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 8,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Base de Datos II",
|
|
||||||
ProfessorId = 4
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 9,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Redes I",
|
|
||||||
ProfessorId = 5
|
|
||||||
},
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 10,
|
|
||||||
Credits = 3,
|
|
||||||
Name = "Redes II",
|
|
||||||
ProfessorId = 5
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("LastLoginAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("nvarchar(256)");
|
|
||||||
|
|
||||||
b.Property<string>("RecoveryCodeHash")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("nvarchar(256)");
|
|
||||||
|
|
||||||
b.Property<string>("Role")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("nvarchar(20)");
|
|
||||||
|
|
||||||
b.Property<int?>("StudentId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("Username")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("nvarchar(50)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("StudentId");
|
|
||||||
|
|
||||||
b.HasIndex("Username")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Users", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Enrollment", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Domain.Entities.Student", "Student")
|
|
||||||
.WithMany("Enrollments")
|
|
||||||
.HasForeignKey("StudentId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("Domain.Entities.Subject", "Subject")
|
|
||||||
.WithMany("Enrollments")
|
|
||||||
.HasForeignKey("SubjectId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Student");
|
|
||||||
|
|
||||||
b.Navigation("Subject");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Subject", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Domain.Entities.Professor", "Professor")
|
|
||||||
.WithMany("Subjects")
|
|
||||||
.HasForeignKey("ProfessorId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Professor");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.User", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Domain.Entities.Student", "Student")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("StudentId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.Navigation("Student");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Professor", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Subjects");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Student", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Enrollments");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Domain.Entities.Subject", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Enrollments");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Adapters.Driven.Persistence.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddStudentActivation : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "ActivationCodeHash",
|
|
||||||
table: "Students",
|
|
||||||
type: "nvarchar(256)",
|
|
||||||
maxLength: 256,
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<DateTime>(
|
|
||||||
name: "ActivationExpiresAt",
|
|
||||||
table: "Students",
|
|
||||||
type: "datetime2",
|
|
||||||
nullable: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ActivationCodeHash",
|
|
||||||
table: "Students");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ActivationExpiresAt",
|
|
||||||
table: "Students");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -102,13 +102,6 @@ namespace Adapters.Driven.Persistence.Migrations
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
b.Property<string>("ActivationCodeHash")
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("nvarchar(256)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("ActivationExpiresAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
b.Property<string>("Email")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(150)
|
.HasMaxLength(150)
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,6 @@ public class StudentRepository(AppDbContext context) : IStudentRepository
|
||||||
return (resultItems, nextCursor, totalCount);
|
return (resultItems, nextCursor, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Student>> GetPendingActivationAsync(CancellationToken ct = default) =>
|
|
||||||
await context.Students
|
|
||||||
.Where(s => s.ActivationCodeHash != null && s.ActivationExpiresAt > DateTime.UtcNow)
|
|
||||||
.AsNoTracking()
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
public void Add(Student student) => context.Students.Add(student);
|
public void Add(Student student) => context.Students.Add(student);
|
||||||
public void Update(Student student) => context.Students.Update(student);
|
public void Update(Student student) => context.Students.Update(student);
|
||||||
public void Delete(Student student) => context.Students.Remove(student);
|
public void Delete(Student student) => context.Students.Remove(student);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
namespace Adapters.Driving.Api.Types;
|
namespace Adapters.Driving.Api.Types;
|
||||||
|
|
||||||
using Application.Auth.Commands;
|
|
||||||
using Application.Enrollments.Commands;
|
using Application.Enrollments.Commands;
|
||||||
using Application.Students.Commands;
|
using Application.Students.Commands;
|
||||||
using Application.Students.DTOs;
|
using Application.Students.DTOs;
|
||||||
|
|
@ -14,37 +13,15 @@ using System.Security.Claims;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Mutation
|
public class Mutation
|
||||||
{
|
{
|
||||||
[Authorize(Roles = ["Admin"])]
|
[Authorize]
|
||||||
[GraphQLDescription("Create a new student with activation code (admin only)")]
|
[GraphQLDescription("Create a new student (requires authentication)")]
|
||||||
public async Task<CreateStudentWithActivationPayload> CreateStudent(
|
public async Task<CreateStudentPayload> CreateStudent(
|
||||||
CreateStudentInput input,
|
CreateStudentInput input,
|
||||||
[Service] IMediator mediator,
|
[Service] IMediator mediator,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct);
|
var result = await mediator.Send(new CreateStudentCommand(input.Name, input.Email), ct);
|
||||||
return new CreateStudentWithActivationPayload(result);
|
return new CreateStudentPayload(result);
|
||||||
}
|
|
||||||
|
|
||||||
[Authorize(Roles = ["Admin"])]
|
|
||||||
[GraphQLDescription("Regenerate activation code for a student (admin only)")]
|
|
||||||
public async Task<CreateStudentWithActivationPayload?> RegenerateActivationCode(
|
|
||||||
int studentId,
|
|
||||||
[Service] IMediator mediator,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(new RegenerateActivationCodeCommand(studentId), ct);
|
|
||||||
return result != null ? new CreateStudentWithActivationPayload(result) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[GraphQLDescription("Activate a student account using activation code (public)")]
|
|
||||||
public async Task<ActivateAccountPayload> ActivateAccount(
|
|
||||||
ActivateAccountInput input,
|
|
||||||
[Service] IMediator mediator,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(
|
|
||||||
new ActivateAccountCommand(input.ActivationCode, input.Username, input.Password), ct);
|
|
||||||
return new ActivateAccountPayload(result.Success, result.Token, result.RecoveryCode, result.Error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
|
@ -131,7 +108,6 @@ public class Mutation
|
||||||
public record CreateStudentInput(string Name, string Email);
|
public record CreateStudentInput(string Name, string Email);
|
||||||
public record UpdateStudentInput(string Name, string Email);
|
public record UpdateStudentInput(string Name, string Email);
|
||||||
public record EnrollStudentInput(int StudentId, int SubjectId);
|
public record EnrollStudentInput(int StudentId, int SubjectId);
|
||||||
public record ActivateAccountInput(string ActivationCode, string Username, string Password);
|
|
||||||
|
|
||||||
// Error Types
|
// Error Types
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -195,25 +171,3 @@ public record UnenrollStudentPayload(
|
||||||
{
|
{
|
||||||
public UnenrollStudentPayload(bool success) : this(success, null) { }
|
public UnenrollStudentPayload(bool success) : this(success, null) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Payload for CreateStudent mutation with activation code.
|
|
||||||
/// </summary>
|
|
||||||
public record CreateStudentWithActivationPayload(
|
|
||||||
StudentDto Student,
|
|
||||||
string ActivationCode,
|
|
||||||
string ActivationUrl,
|
|
||||||
DateTime ExpiresAt)
|
|
||||||
{
|
|
||||||
public CreateStudentWithActivationPayload(CreateStudentResult result)
|
|
||||||
: this(result.Student, result.ActivationCode, result.ActivationUrl, result.ExpiresAt) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Payload for ActivateAccount mutation.
|
|
||||||
/// </summary>
|
|
||||||
public record ActivateAccountPayload(
|
|
||||||
bool Success,
|
|
||||||
string? Token,
|
|
||||||
string? RecoveryCode,
|
|
||||||
string? Error);
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
namespace Adapters.Driving.Api.Types;
|
namespace Adapters.Driving.Api.Types;
|
||||||
|
|
||||||
using Application.Admin.DTOs;
|
|
||||||
using Application.Admin.Queries;
|
|
||||||
using Application.Auth.Queries;
|
|
||||||
using Application.Enrollments.DTOs;
|
using Application.Enrollments.DTOs;
|
||||||
using Application.Enrollments.Queries;
|
using Application.Enrollments.Queries;
|
||||||
using Application.Professors.DTOs;
|
using Application.Professors.DTOs;
|
||||||
|
|
@ -11,7 +8,6 @@ using Application.Students.DTOs;
|
||||||
using Application.Students.Queries;
|
using Application.Students.Queries;
|
||||||
using Application.Subjects.DTOs;
|
using Application.Subjects.DTOs;
|
||||||
using Application.Subjects.Queries;
|
using Application.Subjects.Queries;
|
||||||
using HotChocolate.Authorization;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
public class Query
|
public class Query
|
||||||
|
|
@ -62,18 +58,4 @@ public class Query
|
||||||
[Service] IMediator mediator,
|
[Service] IMediator mediator,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
await mediator.Send(new GetClassmatesQuery(studentId), ct);
|
await mediator.Send(new GetClassmatesQuery(studentId), ct);
|
||||||
|
|
||||||
[Authorize(Roles = ["Admin"])]
|
|
||||||
[GraphQLDescription("Get admin statistics (requires Admin role)")]
|
|
||||||
public async Task<AdminStatsDto> GetAdminStats(
|
|
||||||
[Service] IMediator mediator,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
await mediator.Send(new GetAdminStatsQuery(), ct);
|
|
||||||
|
|
||||||
[GraphQLDescription("Validate an activation code (public)")]
|
|
||||||
public async Task<ActivationValidationResult> ValidateActivationCode(
|
|
||||||
string code,
|
|
||||||
[Service] IMediator mediator,
|
|
||||||
CancellationToken ct) =>
|
|
||||||
await mediator.Send(new ValidateActivationCodeQuery(code), ct);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
namespace Application.Admin.DTOs;
|
|
||||||
|
|
||||||
public record AdminStatsDto(
|
|
||||||
int TotalStudents,
|
|
||||||
int TotalEnrollments,
|
|
||||||
double AverageCreditsPerStudent,
|
|
||||||
SubjectStatsDto? MostPopularSubject,
|
|
||||||
IReadOnlyList<SubjectStatsDto> SubjectStats);
|
|
||||||
|
|
||||||
public record SubjectStatsDto(
|
|
||||||
int SubjectId,
|
|
||||||
string SubjectName,
|
|
||||||
string ProfessorName,
|
|
||||||
int EnrollmentCount);
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
namespace Application.Admin.Queries;
|
|
||||||
|
|
||||||
using Application.Admin.DTOs;
|
|
||||||
using Domain.Ports.Repositories;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
public record GetAdminStatsQuery : IRequest<AdminStatsDto>;
|
|
||||||
|
|
||||||
public class GetAdminStatsHandler(
|
|
||||||
IStudentRepository studentRepository,
|
|
||||||
ISubjectRepository subjectRepository)
|
|
||||||
: IRequestHandler<GetAdminStatsQuery, AdminStatsDto>
|
|
||||||
{
|
|
||||||
public async Task<AdminStatsDto> Handle(GetAdminStatsQuery request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var students = await studentRepository.GetAllWithEnrollmentsAsync(ct);
|
|
||||||
var totalStudents = students.Count;
|
|
||||||
|
|
||||||
var totalEnrollments = students.Sum(s => s.Enrollments.Count);
|
|
||||||
|
|
||||||
var avgCredits = totalStudents > 0
|
|
||||||
? students.Average(s => s.Enrollments.Sum(e => e.Subject?.Credits ?? 3))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
var subjects = await subjectRepository.GetAllWithProfessorsAsync(ct);
|
|
||||||
|
|
||||||
var enrollmentCounts = students
|
|
||||||
.SelectMany(s => s.Enrollments)
|
|
||||||
.GroupBy(e => e.SubjectId)
|
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
|
||||||
|
|
||||||
var subjectStats = subjects
|
|
||||||
.Select(s => new SubjectStatsDto(
|
|
||||||
s.Id,
|
|
||||||
s.Name,
|
|
||||||
s.Professor?.Name ?? "Sin profesor",
|
|
||||||
enrollmentCounts.GetValueOrDefault(s.Id, 0)))
|
|
||||||
.OrderByDescending(s => s.EnrollmentCount)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var mostPopular = subjectStats.FirstOrDefault();
|
|
||||||
|
|
||||||
return new AdminStatsDto(
|
|
||||||
totalStudents,
|
|
||||||
totalEnrollments,
|
|
||||||
Math.Round(avgCredits, 1),
|
|
||||||
mostPopular,
|
|
||||||
subjectStats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
<PackageReference Include="FluentValidation" Version="*" />
|
<PackageReference Include="FluentValidation" Version="*" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="*" />
|
||||||
<PackageReference Include="MediatR" Version="*" />
|
<PackageReference Include="MediatR" Version="*" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="*" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Application.Students.DTOs;
|
|
||||||
using Domain.Entities;
|
|
||||||
using Domain.Ports.Repositories;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace Application.Auth.Commands;
|
|
||||||
|
|
||||||
public record ActivateAccountCommand(
|
|
||||||
string ActivationCode,
|
|
||||||
string Username,
|
|
||||||
string Password
|
|
||||||
) : IRequest<ActivateAccountResult>;
|
|
||||||
|
|
||||||
public class ActivateAccountHandler(
|
|
||||||
IStudentRepository studentRepository,
|
|
||||||
IUserRepository userRepository,
|
|
||||||
IPasswordService passwordService,
|
|
||||||
IJwtService jwtService,
|
|
||||||
IUnitOfWork unitOfWork
|
|
||||||
) : IRequestHandler<ActivateAccountCommand, ActivateAccountResult>
|
|
||||||
{
|
|
||||||
public async Task<ActivateAccountResult> Handle(ActivateAccountCommand request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
// Find student with matching activation code
|
|
||||||
var students = await studentRepository.GetPendingActivationAsync(ct);
|
|
||||||
var student = students.FirstOrDefault(s =>
|
|
||||||
s.ActivationCodeHash != null &&
|
|
||||||
passwordService.VerifyPassword(request.ActivationCode, s.ActivationCodeHash));
|
|
||||||
|
|
||||||
if (student == null)
|
|
||||||
return new ActivateAccountResult(false, null, null, "Codigo de activacion invalido o expirado");
|
|
||||||
|
|
||||||
if (student.IsActivationExpired())
|
|
||||||
return new ActivateAccountResult(false, null, null, "El codigo de activacion ha expirado");
|
|
||||||
|
|
||||||
// Check if username already exists
|
|
||||||
if (await userRepository.ExistsAsync(request.Username, ct))
|
|
||||||
return new ActivateAccountResult(false, null, null, "El nombre de usuario ya existe");
|
|
||||||
|
|
||||||
// Validate password
|
|
||||||
if (request.Password.Length < 6)
|
|
||||||
return new ActivateAccountResult(false, null, null, "La contrasena debe tener al menos 6 caracteres");
|
|
||||||
|
|
||||||
// Generate recovery code
|
|
||||||
var recoveryCode = GenerateRecoveryCode();
|
|
||||||
var recoveryCodeHash = passwordService.HashPassword(recoveryCode);
|
|
||||||
|
|
||||||
// Create user account
|
|
||||||
var passwordHash = passwordService.HashPassword(request.Password);
|
|
||||||
var user = User.Create(
|
|
||||||
request.Username,
|
|
||||||
passwordHash,
|
|
||||||
recoveryCodeHash,
|
|
||||||
UserRoles.Student,
|
|
||||||
student.Id
|
|
||||||
);
|
|
||||||
|
|
||||||
await userRepository.AddAsync(user, ct);
|
|
||||||
|
|
||||||
// Clear activation code (mark as activated)
|
|
||||||
// Need to re-fetch with tracking
|
|
||||||
var trackedStudent = await studentRepository.GetByIdAsync(student.Id, ct);
|
|
||||||
trackedStudent?.ClearActivationCode();
|
|
||||||
|
|
||||||
await unitOfWork.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
// Generate JWT token for auto-login
|
|
||||||
var token = jwtService.GenerateToken(user);
|
|
||||||
|
|
||||||
return new ActivateAccountResult(
|
|
||||||
Success: true,
|
|
||||||
Token: token,
|
|
||||||
RecoveryCode: recoveryCode,
|
|
||||||
Error: null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateRecoveryCode()
|
|
||||||
{
|
|
||||||
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(12);
|
|
||||||
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
using Application.Students.DTOs;
|
|
||||||
using Domain.Ports.Repositories;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace Application.Auth.Queries;
|
|
||||||
|
|
||||||
public record ValidateActivationCodeQuery(string Code) : IRequest<ActivationValidationResult>;
|
|
||||||
|
|
||||||
public class ValidateActivationCodeHandler(
|
|
||||||
IStudentRepository studentRepository,
|
|
||||||
IPasswordService passwordService
|
|
||||||
) : IRequestHandler<ValidateActivationCodeQuery, ActivationValidationResult>
|
|
||||||
{
|
|
||||||
public async Task<ActivationValidationResult> Handle(ValidateActivationCodeQuery request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var students = await studentRepository.GetPendingActivationAsync(ct);
|
|
||||||
|
|
||||||
var student = students.FirstOrDefault(s =>
|
|
||||||
s.ActivationCodeHash != null &&
|
|
||||||
passwordService.VerifyPassword(request.Code, s.ActivationCodeHash));
|
|
||||||
|
|
||||||
if (student == null)
|
|
||||||
return new ActivationValidationResult(false, null, "Codigo de activacion invalido");
|
|
||||||
|
|
||||||
if (student.IsActivationExpired())
|
|
||||||
return new ActivationValidationResult(false, null, "El codigo de activacion ha expirado");
|
|
||||||
|
|
||||||
return new ActivationValidationResult(true, student.Name, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +1,26 @@
|
||||||
namespace Application.Students.Commands;
|
namespace Application.Students.Commands;
|
||||||
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Application.Auth;
|
|
||||||
using Application.Students.DTOs;
|
using Application.Students.DTOs;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Ports.Repositories;
|
using Domain.Ports.Repositories;
|
||||||
using Domain.ValueObjects;
|
using Domain.ValueObjects;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
public record CreateStudentCommand(string Name, string Email, string? BaseUrl = null)
|
public record CreateStudentCommand(string Name, string Email) : IRequest<StudentDto>;
|
||||||
: IRequest<CreateStudentResult>;
|
|
||||||
|
|
||||||
public class CreateStudentHandler(
|
public class CreateStudentHandler(
|
||||||
IStudentRepository studentRepository,
|
IStudentRepository studentRepository,
|
||||||
IPasswordService passwordService,
|
IUnitOfWork unitOfWork)
|
||||||
IUnitOfWork unitOfWork,
|
: IRequestHandler<CreateStudentCommand, StudentDto>
|
||||||
IConfiguration configuration)
|
|
||||||
: IRequestHandler<CreateStudentCommand, CreateStudentResult>
|
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48);
|
public async Task<StudentDto> Handle(CreateStudentCommand request, CancellationToken ct)
|
||||||
|
|
||||||
public async Task<CreateStudentResult> Handle(CreateStudentCommand request, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var email = Email.Create(request.Email);
|
var email = Email.Create(request.Email);
|
||||||
var student = new Student(request.Name, email);
|
var student = new Student(request.Name, email);
|
||||||
|
|
||||||
// Generate activation code
|
|
||||||
var activationCode = GenerateActivationCode();
|
|
||||||
var codeHash = passwordService.HashPassword(activationCode);
|
|
||||||
student.SetActivationCode(codeHash, ActivationExpiration);
|
|
||||||
|
|
||||||
studentRepository.Add(student);
|
studentRepository.Add(student);
|
||||||
await unitOfWork.SaveChangesAsync(ct);
|
await unitOfWork.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var baseUrl = request.BaseUrl ?? configuration["App:BaseUrl"] ?? "http://localhost:4200";
|
return new StudentDto(student.Id, student.Name, student.Email.Value, 0, []);
|
||||||
var activationUrl = $"{baseUrl}/activate?code={activationCode}";
|
|
||||||
|
|
||||||
var studentDto = new StudentDto(student.Id, student.Name, student.Email.Value, 0, []);
|
|
||||||
|
|
||||||
return new CreateStudentResult(
|
|
||||||
studentDto,
|
|
||||||
activationCode,
|
|
||||||
activationUrl,
|
|
||||||
student.ActivationExpiresAt!.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateActivationCode()
|
|
||||||
{
|
|
||||||
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(12);
|
|
||||||
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
using System.Security.Cryptography;
|
|
||||||
using Application.Auth;
|
|
||||||
using Application.Students.DTOs;
|
|
||||||
using Domain.Ports.Repositories;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace Application.Students.Commands;
|
|
||||||
|
|
||||||
public record RegenerateActivationCodeCommand(int StudentId, string? BaseUrl = null)
|
|
||||||
: IRequest<CreateStudentResult?>;
|
|
||||||
|
|
||||||
public class RegenerateActivationCodeHandler(
|
|
||||||
IStudentRepository studentRepository,
|
|
||||||
IPasswordService passwordService,
|
|
||||||
IUnitOfWork unitOfWork,
|
|
||||||
IConfiguration configuration
|
|
||||||
) : IRequestHandler<RegenerateActivationCodeCommand, CreateStudentResult?>
|
|
||||||
{
|
|
||||||
private static readonly TimeSpan ActivationExpiration = TimeSpan.FromHours(48);
|
|
||||||
|
|
||||||
public async Task<CreateStudentResult?> Handle(RegenerateActivationCodeCommand request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var student = await studentRepository.GetByIdAsync(request.StudentId, ct);
|
|
||||||
|
|
||||||
if (student == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Only regenerate if student is not yet activated
|
|
||||||
if (student.IsActivated)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Generate new activation code
|
|
||||||
var activationCode = GenerateActivationCode();
|
|
||||||
var codeHash = passwordService.HashPassword(activationCode);
|
|
||||||
student.SetActivationCode(codeHash, ActivationExpiration);
|
|
||||||
|
|
||||||
studentRepository.Update(student);
|
|
||||||
await unitOfWork.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var baseUrl = request.BaseUrl ?? configuration["App:BaseUrl"] ?? "http://localhost:4200";
|
|
||||||
var activationUrl = $"{baseUrl}/activate?code={activationCode}";
|
|
||||||
|
|
||||||
var studentDto = new StudentDto(student.Id, student.Name, student.Email.Value, 0, []);
|
|
||||||
|
|
||||||
return new CreateStudentResult(
|
|
||||||
studentDto,
|
|
||||||
activationCode,
|
|
||||||
activationUrl,
|
|
||||||
student.ActivationExpiresAt!.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateActivationCode()
|
|
||||||
{
|
|
||||||
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
||||||
var bytes = RandomNumberGenerator.GetBytes(12);
|
|
||||||
return new string(bytes.Select(b => chars[b % chars.Length]).ToArray());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -31,26 +31,3 @@ public record StudentPagedDto(
|
||||||
string Name,
|
string Name,
|
||||||
string Email,
|
string Email,
|
||||||
int TotalCredits);
|
int TotalCredits);
|
||||||
|
|
||||||
// Activation DTOs
|
|
||||||
public record CreateStudentResult(
|
|
||||||
StudentDto Student,
|
|
||||||
string ActivationCode,
|
|
||||||
string ActivationUrl,
|
|
||||||
DateTime ExpiresAt);
|
|
||||||
|
|
||||||
public record ActivationValidationResult(
|
|
||||||
bool IsValid,
|
|
||||||
string? StudentName,
|
|
||||||
string? Error);
|
|
||||||
|
|
||||||
public record ActivateAccountRequest(
|
|
||||||
string ActivationCode,
|
|
||||||
string Username,
|
|
||||||
string Password);
|
|
||||||
|
|
||||||
public record ActivateAccountResult(
|
|
||||||
bool Success,
|
|
||||||
string? Token,
|
|
||||||
string? RecoveryCode,
|
|
||||||
string? Error);
|
|
||||||
|
|
|
||||||
|
|
@ -1,407 +0,0 @@
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E2E Tests: Flujo de Activación de Estudiantes
|
|
||||||
* Verifica el flujo completo de activación por código cuando un admin crea un estudiante.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Helper para simular sesión de admin
|
|
||||||
async function setAdminSession(page: Page) {
|
|
||||||
const mockToken = 'mock.admin.jwt.token';
|
|
||||||
const mockUser = {
|
|
||||||
id: 1,
|
|
||||||
username: 'admin',
|
|
||||||
role: 'Admin',
|
|
||||||
studentId: null,
|
|
||||||
studentName: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ token, user }) => {
|
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
|
||||||
},
|
|
||||||
{ token: mockToken, user: mockUser }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Activación - Admin Crea Estudiante', () => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setAdminSession(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin debe ver formulario de nuevo estudiante', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: /nuevo estudiante/i })
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await expect(page.getByLabel(/nombre/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/email/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe crear estudiante y mostrar modal de activación', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
// Llenar formulario
|
|
||||||
await page.getByLabel(/nombre/i).fill(`Test Student ${timestamp}`);
|
|
||||||
await page.getByLabel(/email/i).fill(`test_${timestamp}@example.com`);
|
|
||||||
|
|
||||||
// Enviar
|
|
||||||
await page.getByRole('button', { name: /crear|guardar/i }).click();
|
|
||||||
|
|
||||||
// Debe mostrar modal con código de activación
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*activación|activation.*code/i)
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Debe mostrar el código generado
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="activation-code"]').or(
|
|
||||||
page.locator('code, .activation-code, .code-display')
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('modal debe mostrar URL de activación', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
await page.getByLabel(/nombre/i).fill(`Test URL ${timestamp}`);
|
|
||||||
await page.getByLabel(/email/i).fill(`testurl_${timestamp}@example.com`);
|
|
||||||
await page.getByRole('button', { name: /crear|guardar/i }).click();
|
|
||||||
|
|
||||||
// Esperar modal
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*activación/i)
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Debe mostrar URL de activación
|
|
||||||
await expect(
|
|
||||||
page.getByText(/activate\?code=|activar\?codigo=/i)
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('modal debe mostrar fecha de expiración', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
await page.getByLabel(/nombre/i).fill(`Test Expiry ${timestamp}`);
|
|
||||||
await page.getByLabel(/email/i).fill(`testexp_${timestamp}@example.com`);
|
|
||||||
await page.getByRole('button', { name: /crear|guardar/i }).click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*activación/i)
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Debe mostrar expiración
|
|
||||||
await expect(
|
|
||||||
page.getByText(/expira|expiración|válido hasta/i)
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe poder copiar código al portapapeles', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
await page.getByLabel(/nombre/i).fill(`Test Copy ${timestamp}`);
|
|
||||||
await page.getByLabel(/email/i).fill(`testcopy_${timestamp}@example.com`);
|
|
||||||
await page.getByRole('button', { name: /crear|guardar/i }).click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*activación/i)
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Buscar botón de copiar
|
|
||||||
const copyButton = page.getByRole('button', { name: /copiar/i }).or(
|
|
||||||
page.locator('[data-testid="copy-code"]')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (await copyButton.isVisible()) {
|
|
||||||
await copyButton.click();
|
|
||||||
|
|
||||||
// Debe mostrar confirmación de copiado
|
|
||||||
await expect(
|
|
||||||
page.getByText(/copiado|copied/i)
|
|
||||||
).toBeVisible({ timeout: 5000 }).catch(() => {
|
|
||||||
// Puede que el feedback sea diferente
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe cerrar modal y volver al listado', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
await page.getByLabel(/nombre/i).fill(`Test Close ${timestamp}`);
|
|
||||||
await page.getByLabel(/email/i).fill(`testclose_${timestamp}@example.com`);
|
|
||||||
await page.getByRole('button', { name: /crear|guardar/i }).click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*activación/i)
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Cerrar modal
|
|
||||||
const closeButton = page.getByRole('button', { name: /cerrar|entendido|aceptar|continuar/i });
|
|
||||||
await closeButton.click();
|
|
||||||
|
|
||||||
// Debe redirigir al listado o mostrar el listado
|
|
||||||
await expect(page).toHaveURL(/\/students|\/admin/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Activación - Página de Activación', () => {
|
|
||||||
test('debe mostrar página de activación con código válido', async ({ page }) => {
|
|
||||||
// Navegar a página de activación con código de prueba
|
|
||||||
await page.goto('/activate?code=TESTCODE123');
|
|
||||||
|
|
||||||
// Debe mostrar formulario de activación o error de código inválido
|
|
||||||
await expect(
|
|
||||||
page.getByText(/activar.*cuenta|bienvenido|código.*inválido|expirado/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar error con código inválido', async ({ page }) => {
|
|
||||||
await page.goto('/activate?code=INVALID');
|
|
||||||
|
|
||||||
// Debe mostrar error
|
|
||||||
await expect(
|
|
||||||
page.getByText(/inválido|expirado|no encontrado|error/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formulario de activación debe tener campos requeridos', async ({ page }) => {
|
|
||||||
await page.goto('/activate?code=TESTCODE123');
|
|
||||||
|
|
||||||
// Si el código es válido, debe mostrar formulario
|
|
||||||
const usernameField = page.getByLabel(/usuario|username/i);
|
|
||||||
const passwordField = page.getByLabel(/contraseña|password/i).first();
|
|
||||||
|
|
||||||
// Si los campos existen (código válido), verificar
|
|
||||||
if (await usernameField.isVisible()) {
|
|
||||||
await expect(usernameField).toBeVisible();
|
|
||||||
await expect(passwordField).toBeVisible();
|
|
||||||
|
|
||||||
// Puede haber campo de confirmar contraseña
|
|
||||||
const confirmField = page.getByLabel(/confirmar/i);
|
|
||||||
if (await confirmField.isVisible()) {
|
|
||||||
await expect(confirmField).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar usuario único en activación', async ({ page }) => {
|
|
||||||
await page.goto('/activate?code=TESTCODE123');
|
|
||||||
|
|
||||||
const usernameField = page.getByLabel(/usuario|username/i);
|
|
||||||
|
|
||||||
if (await usernameField.isVisible()) {
|
|
||||||
// Usar un usuario que probablemente exista
|
|
||||||
await usernameField.fill('admin');
|
|
||||||
await page.getByLabel(/contraseña|password/i).first().fill('Test123!');
|
|
||||||
|
|
||||||
const confirmField = page.getByLabel(/confirmar/i);
|
|
||||||
if (await confirmField.isVisible()) {
|
|
||||||
await confirmField.fill('Test123!');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /activar|crear/i }).click();
|
|
||||||
|
|
||||||
// Debe mostrar error de usuario existente
|
|
||||||
await expect(
|
|
||||||
page.getByText(/usuario.*existe|ya está en uso|username.*taken/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar contraseña mínima en activación', async ({ page }) => {
|
|
||||||
await page.goto('/activate?code=TESTCODE123');
|
|
||||||
|
|
||||||
const passwordField = page.getByLabel(/contraseña|password/i).first();
|
|
||||||
|
|
||||||
if (await passwordField.isVisible()) {
|
|
||||||
await passwordField.fill('123');
|
|
||||||
await page.getByLabel(/usuario|username/i).focus();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/al menos 6 caracteres|mínimo.*6/i)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar código de recuperación después de activar', async ({ page }) => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
await page.goto('/activate?code=TESTCODE123');
|
|
||||||
|
|
||||||
const usernameField = page.getByLabel(/usuario|username/i);
|
|
||||||
|
|
||||||
if (await usernameField.isVisible()) {
|
|
||||||
await usernameField.fill(`newuser_${timestamp}`);
|
|
||||||
await page.getByLabel(/contraseña|password/i).first().fill('Test123!');
|
|
||||||
|
|
||||||
const confirmField = page.getByLabel(/confirmar/i);
|
|
||||||
if (await confirmField.isVisible()) {
|
|
||||||
await confirmField.fill('Test123!');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /activar|crear/i }).click();
|
|
||||||
|
|
||||||
// Si la activación es exitosa, debe mostrar código de recuperación
|
|
||||||
// o redirigir al dashboard
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*recuperación|recovery.*code|bienvenido|dashboard/i)
|
|
||||||
).toBeVisible({ timeout: 15000 }).catch(() => {
|
|
||||||
// Puede redirigir directamente
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Activación - Expiración de Código', () => {
|
|
||||||
test('debe mostrar error si código expiró', async ({ page }) => {
|
|
||||||
// Código expirado de prueba
|
|
||||||
await page.goto('/activate?code=EXPIRED123');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/expirado|expired|inválido|invalid/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin puede regenerar código para estudiante', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setAdminSession(page);
|
|
||||||
|
|
||||||
// Ir al listado de estudiantes
|
|
||||||
await page.goto('/students');
|
|
||||||
|
|
||||||
// Buscar botón de regenerar código (si existe)
|
|
||||||
const regenerateButton = page.getByRole('button', { name: /regenerar.*código|nuevo.*código/i });
|
|
||||||
|
|
||||||
if (await regenerateButton.first().isVisible()) {
|
|
||||||
await regenerateButton.first().click();
|
|
||||||
|
|
||||||
// Debe mostrar nuevo código
|
|
||||||
await expect(
|
|
||||||
page.getByText(/nuevo código|código regenerado/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Activación - Flujo Completo', () => {
|
|
||||||
test('estudiante activado puede iniciar sesión', async ({ page }) => {
|
|
||||||
// Este test asume que hay un estudiante ya activado
|
|
||||||
await page.goto('/login');
|
|
||||||
|
|
||||||
// Verificar que el formulario de login funciona
|
|
||||||
await expect(page.getByLabel(/usuario/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/contraseña/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /iniciar sesión/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante activado ve su dashboard', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// Simular sesión de estudiante activado
|
|
||||||
const mockUser = {
|
|
||||||
id: 2,
|
|
||||||
username: 'activated_student',
|
|
||||||
role: 'Student',
|
|
||||||
studentId: 10,
|
|
||||||
studentName: 'Estudiante Activado',
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ token, user }) => {
|
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
|
||||||
},
|
|
||||||
{ token: 'mock.jwt.token', user: mockUser }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
|
|
||||||
// Debe ver su nombre en el dashboard
|
|
||||||
await expect(
|
|
||||||
page.getByText(/bienvenido|estudiante/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante activado puede inscribirse en materias', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
const mockUser = {
|
|
||||||
id: 2,
|
|
||||||
username: 'activated_student',
|
|
||||||
role: 'Student',
|
|
||||||
studentId: 10,
|
|
||||||
studentName: 'Estudiante Activado',
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ token, user }) => {
|
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
|
||||||
},
|
|
||||||
{ token: 'mock.jwt.token', user: mockUser }
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.goto('/enrollment/10');
|
|
||||||
|
|
||||||
// Debe ver página de inscripción
|
|
||||||
await expect(
|
|
||||||
page.getByText(/materias|inscripción/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Activación - Seguridad', () => {
|
|
||||||
test('código de activación debe ser de un solo uso', async ({ page }) => {
|
|
||||||
// Intentar usar un código ya usado
|
|
||||||
await page.goto('/activate?code=USED_CODE_123');
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/ya fue usado|inválido|expirado|no encontrado/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('página de activación no requiere autenticación', async ({ page }) => {
|
|
||||||
// Limpiar sesión
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
|
|
||||||
// Acceder a página de activación
|
|
||||||
await page.goto('/activate?code=ANYCODE');
|
|
||||||
|
|
||||||
// No debe redirigir a login
|
|
||||||
await expect(page).toHaveURL(/\/activate/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('código de recuperación solo se muestra una vez', async ({ page }) => {
|
|
||||||
// Este es más un test de UI - verificar que hay advertencia
|
|
||||||
const timestamp = Date.now();
|
|
||||||
await page.goto('/activate?code=TESTCODE123');
|
|
||||||
|
|
||||||
const usernameField = page.getByLabel(/usuario|username/i);
|
|
||||||
|
|
||||||
if (await usernameField.isVisible()) {
|
|
||||||
await usernameField.fill(`secuser_${timestamp}`);
|
|
||||||
await page.getByLabel(/contraseña|password/i).first().fill('Test123!');
|
|
||||||
|
|
||||||
const confirmField = page.getByLabel(/confirmar/i);
|
|
||||||
if (await confirmField.isVisible()) {
|
|
||||||
await confirmField.fill('Test123!');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /activar|crear/i }).click();
|
|
||||||
|
|
||||||
// Si se muestra código de recuperación, debe haber advertencia
|
|
||||||
const recoverySection = page.getByText(/código.*recuperación/i);
|
|
||||||
if (await recoverySection.isVisible()) {
|
|
||||||
await expect(
|
|
||||||
page.getByText(/solo.*vez|guarda.*código|importante|advertencia|warning/i)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E2E Tests: Autenticación
|
|
||||||
* Tests de prioridad alta que cubren flujos críticos de login, registro y reset de contraseña.
|
|
||||||
* Estos tests corren contra el backend real (no mocks).
|
|
||||||
*/
|
|
||||||
|
|
||||||
test.describe('Autenticación - Login', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar el formulario de login', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('heading', { name: /iniciar sesión/i })).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/usuario/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/contraseña/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /iniciar sesión/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar error con credenciales inválidas', async ({ page }) => {
|
|
||||||
await page.getByLabel(/usuario/i).fill('usuario_inexistente');
|
|
||||||
await page.getByLabel(/contraseña/i).fill('password123');
|
|
||||||
await page.getByRole('button', { name: /iniciar sesión/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/usuario o contraseña incorrectos/i)).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe deshabilitar botón mientras carga', async ({ page }) => {
|
|
||||||
await page.getByLabel(/usuario/i).fill('test');
|
|
||||||
await page.getByLabel(/contraseña/i).fill('test123');
|
|
||||||
|
|
||||||
const submitButton = page.getByRole('button', { name: /iniciar sesión/i });
|
|
||||||
await submitButton.click();
|
|
||||||
|
|
||||||
// El botón debería estar deshabilitado durante la petición
|
|
||||||
await expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe navegar a página de registro', async ({ page }) => {
|
|
||||||
await page.getByRole('link', { name: /crear cuenta|registrarse/i }).click();
|
|
||||||
await expect(page).toHaveURL(/\/register/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe navegar a recuperación de contraseña', async ({ page }) => {
|
|
||||||
await page.getByRole('link', { name: /olvidaste tu contraseña/i }).click();
|
|
||||||
await expect(page).toHaveURL(/\/reset-password/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar campos requeridos', async ({ page }) => {
|
|
||||||
const submitButton = page.getByRole('button', { name: /iniciar sesión/i });
|
|
||||||
|
|
||||||
// Intentar submit sin datos
|
|
||||||
await page.getByLabel(/usuario/i).focus();
|
|
||||||
await page.getByLabel(/contraseña/i).focus();
|
|
||||||
await page.getByLabel(/usuario/i).blur();
|
|
||||||
|
|
||||||
// El botón debería estar deshabilitado
|
|
||||||
await expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Autenticación - Registro', () => {
|
|
||||||
const timestamp = Date.now();
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar el formulario de registro', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('heading', { name: /crear cuenta|registro/i })).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/usuario/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/nombre/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/email/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/contraseña/i).first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar email inválido', async ({ page }) => {
|
|
||||||
await page.getByLabel(/email/i).fill('email-invalido');
|
|
||||||
await page.getByLabel(/nombre/i).focus();
|
|
||||||
|
|
||||||
await expect(page.getByText(/email.*válido|correo.*válido/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar contraseña mínima', async ({ page }) => {
|
|
||||||
await page.getByLabel(/contraseña/i).first().fill('123');
|
|
||||||
await page.getByLabel(/nombre/i).focus();
|
|
||||||
|
|
||||||
await expect(page.getByText(/al menos 6 caracteres/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe registrar usuario y mostrar código de recuperación', async ({ page }) => {
|
|
||||||
const uniqueUser = `testuser_${timestamp}`;
|
|
||||||
const uniqueEmail = `test_${timestamp}@example.com`;
|
|
||||||
|
|
||||||
await page.getByLabel(/usuario/i).fill(uniqueUser);
|
|
||||||
await page.getByLabel(/nombre/i).fill('Usuario de Prueba E2E');
|
|
||||||
await page.getByLabel(/email/i).fill(uniqueEmail);
|
|
||||||
await page.getByLabel(/contraseña/i).first().fill('Test123!');
|
|
||||||
|
|
||||||
// Si hay campo de confirmar contraseña
|
|
||||||
const confirmPassword = page.getByLabel(/confirmar/i);
|
|
||||||
if (await confirmPassword.isVisible()) {
|
|
||||||
await confirmPassword.fill('Test123!');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /crear cuenta|registrar/i }).click();
|
|
||||||
|
|
||||||
// Debe mostrar código de recuperación o redirigir al dashboard
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código de recuperación|cuenta creada|bienvenido/i)
|
|
||||||
).toBeVisible({ timeout: 15000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar error si usuario ya existe', async ({ page }) => {
|
|
||||||
// Usar un usuario que probablemente exista
|
|
||||||
await page.getByLabel(/usuario/i).fill('admin');
|
|
||||||
await page.getByLabel(/nombre/i).fill('Test');
|
|
||||||
await page.getByLabel(/email/i).fill('admin@test.com');
|
|
||||||
await page.getByLabel(/contraseña/i).first().fill('Test123!');
|
|
||||||
|
|
||||||
const confirmPassword = page.getByLabel(/confirmar/i);
|
|
||||||
if (await confirmPassword.isVisible()) {
|
|
||||||
await confirmPassword.fill('Test123!');
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /crear cuenta|registrar/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/usuario ya existe|ya está en uso/i)).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe navegar a login desde registro', async ({ page }) => {
|
|
||||||
await page.getByRole('link', { name: /iniciar sesión|ya tienes cuenta/i }).click();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Autenticación - Reset de Contraseña', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/reset-password');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar el formulario de reset', async ({ page }) => {
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: /recuperar|restablecer|cambiar contraseña/i })
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/usuario/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/código.*recuperación/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/nueva contraseña/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar código de recuperación inválido', async ({ page }) => {
|
|
||||||
await page.getByLabel(/usuario/i).fill('testuser');
|
|
||||||
await page.getByLabel(/código.*recuperación/i).fill('CODIGO_INVALIDO');
|
|
||||||
await page.getByLabel(/nueva contraseña/i).fill('NewPass123!');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /cambiar|restablecer|actualizar/i }).click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(/código.*inválido|usuario no encontrado|error/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe validar nueva contraseña mínima', async ({ page }) => {
|
|
||||||
await page.getByLabel(/nueva contraseña/i).fill('123');
|
|
||||||
await page.getByLabel(/usuario/i).focus();
|
|
||||||
|
|
||||||
await expect(page.getByText(/al menos 6 caracteres/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe navegar a login desde reset', async ({ page }) => {
|
|
||||||
await page.getByRole('link', { name: /volver.*login|iniciar sesión/i }).click();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Autenticación - Logout', () => {
|
|
||||||
test('debe cerrar sesión correctamente', async ({ page }) => {
|
|
||||||
// Primero necesitamos estar logueados
|
|
||||||
// Simulamos token en localStorage para test rápido
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
// Si hay un botón de logout visible (usuario logueado)
|
|
||||||
const logoutButton = page.getByRole('button', { name: /cerrar sesión|logout/i });
|
|
||||||
const menuButton = page.getByRole('button', { name: /menú|usuario|perfil/i });
|
|
||||||
|
|
||||||
if (await menuButton.isVisible()) {
|
|
||||||
await menuButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await logoutButton.isVisible()) {
|
|
||||||
await logoutButton.click();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
} else {
|
|
||||||
// Si no hay usuario logueado, debería redirigir a login
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Autenticación - Protección de Rutas', () => {
|
|
||||||
test('debe redirigir a login si no está autenticado', async ({ page }) => {
|
|
||||||
// Limpiar cualquier token existente
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
|
|
||||||
// Intentar acceder a ruta protegida
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
|
|
||||||
// Debe redirigir a login
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe redirigir a login al acceder a enrollment sin auth', async ({ page }) => {
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe redirigir a login al acceder a classmates sin auth', async ({ page }) => {
|
|
||||||
await page.evaluate(() => localStorage.clear());
|
|
||||||
await page.goto('/classmates/1');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E2E Tests: Reglas de Negocio de Inscripción
|
|
||||||
* Tests críticos que verifican las restricciones del dominio:
|
|
||||||
* - Máximo 3 materias (9 créditos)
|
|
||||||
* - No repetir profesor
|
|
||||||
* - Cancelación de inscripciones
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Helper para simular sesión de estudiante
|
|
||||||
async function setStudentSession(page: Page, studentId: number = 1) {
|
|
||||||
const mockToken = 'mock.jwt.token';
|
|
||||||
const mockUser = {
|
|
||||||
id: 1,
|
|
||||||
username: 'student',
|
|
||||||
role: 'Student',
|
|
||||||
studentId,
|
|
||||||
studentName: 'Test Student',
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ token, user }) => {
|
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
|
||||||
},
|
|
||||||
{ token: mockToken, user: mockUser }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Reglas de Negocio - Restricción Máximo 3 Materias', () => {
|
|
||||||
test('debe mostrar límite de créditos 9 en total', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Verificar que se muestra el límite máximo de 9 créditos
|
|
||||||
await expect(page.getByText(/\/9|máximo.*9|límite.*9/i)).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar créditos actuales del estudiante', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Debe mostrar algún indicador de créditos
|
|
||||||
await expect(
|
|
||||||
page.getByText(/créditos|creditos/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe deshabilitar inscripción cuando se alcanza máximo de 3 materias', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Si el estudiante tiene 3 materias, todos los botones de inscribir deben estar deshabilitados
|
|
||||||
const enrollButtons = page.locator('[data-testid="btn-enroll-subject"]');
|
|
||||||
const enrolledSection = page.locator('[data-testid="enrolled-subjects"]');
|
|
||||||
|
|
||||||
// Verificar la sección de materias inscritas
|
|
||||||
await expect(enrolledSection.or(page.getByText(/materias inscritas/i))).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Si hay 3 materias inscritas, verificar mensaje de límite
|
|
||||||
const enrolledCount = await page.locator('[data-testid="enrolled-subject-name"]').count();
|
|
||||||
if (enrolledCount >= 3) {
|
|
||||||
// Todos los botones deberían mostrar "Máximo alcanzado" o estar deshabilitados
|
|
||||||
await expect(
|
|
||||||
page.getByText(/máximo.*alcanzado|límite.*materias/i)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar mensaje cuando se intenta exceder el límite', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Si hay botones deshabilitados por límite máximo, deben tener mensaje
|
|
||||||
const disabledButtons = page.locator('[data-testid="btn-enroll-subject"]:disabled');
|
|
||||||
const count = await disabledButtons.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
// Hover sobre el primer botón deshabilitado para ver tooltip o verificar texto cercano
|
|
||||||
await expect(
|
|
||||||
page.getByText(/máximo.*3.*materias|máximo.*alcanzado|límite/i)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Reglas de Negocio - Restricción Mismo Profesor', () => {
|
|
||||||
test('debe mostrar advertencia en materias del mismo profesor', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Verificar que existen advertencias de mismo profesor
|
|
||||||
await expect(page.locator('[data-testid="available-subjects"]').or(
|
|
||||||
page.getByText(/materias disponibles/i)
|
|
||||||
)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Buscar mensaje de restricción de profesor
|
|
||||||
const warningMessages = page.getByText(/mismo profesor|ya tienes.*materia.*profesor/i);
|
|
||||||
const count = await warningMessages.count();
|
|
||||||
|
|
||||||
// Si hay materias con el mismo profesor de alguna inscrita, debe haber advertencias
|
|
||||||
if (count > 0) {
|
|
||||||
await expect(warningMessages.first()).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe deshabilitar botón de materia con mismo profesor', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Esperar a que cargue la página
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
// Buscar cards de materias con advertencia
|
|
||||||
const warningCards = page.locator('.subject-card-warning, [data-testid="subject-restricted"]');
|
|
||||||
|
|
||||||
if ((await warningCards.count()) > 0) {
|
|
||||||
// El botón dentro de esa card debe estar deshabilitado
|
|
||||||
const disabledButton = warningCards.first().locator('button:disabled');
|
|
||||||
await expect(disabledButton).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe permitir inscribir materias con diferente profesor', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Buscar botones de inscribir habilitados
|
|
||||||
const enabledButtons = page.locator(
|
|
||||||
'[data-testid="btn-enroll-subject"]:not(:disabled)'
|
|
||||||
);
|
|
||||||
|
|
||||||
const count = await enabledButtons.count();
|
|
||||||
if (count > 0) {
|
|
||||||
// Debe haber al menos un botón habilitado si no se ha alcanzado el límite
|
|
||||||
await expect(enabledButtons.first()).toBeEnabled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Reglas de Negocio - Inscripción y Cancelación', () => {
|
|
||||||
test('debe poder inscribir una materia disponible', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Buscar botón de inscribir habilitado
|
|
||||||
const enrollButton = page.locator(
|
|
||||||
'[data-testid="btn-enroll-subject"]:not(:disabled)'
|
|
||||||
).first();
|
|
||||||
|
|
||||||
if (await enrollButton.isVisible()) {
|
|
||||||
await enrollButton.click();
|
|
||||||
|
|
||||||
// Debe mostrar mensaje de éxito o actualizar la lista
|
|
||||||
await expect(
|
|
||||||
page.getByText(/inscrito|inscripción.*exitosa|agregada/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe poder cancelar una inscripción existente', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Buscar botón de cancelar inscripción
|
|
||||||
const cancelButton = page.locator('[data-testid="btn-unenroll-subject"]').first();
|
|
||||||
|
|
||||||
if (await cancelButton.isVisible()) {
|
|
||||||
await cancelButton.click();
|
|
||||||
|
|
||||||
// Puede haber confirmación
|
|
||||||
const confirmButton = page.getByRole('button', { name: /confirmar|sí|aceptar/i });
|
|
||||||
if (await confirmButton.isVisible()) {
|
|
||||||
await confirmButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debe mostrar mensaje de éxito
|
|
||||||
await expect(
|
|
||||||
page.getByText(/cancelada|eliminada|removida/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe actualizar créditos al inscribir materia', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Obtener créditos iniciales
|
|
||||||
const creditsText = page.locator('[data-testid="credits-counter"]').or(
|
|
||||||
page.getByText(/\d+.*\/.*9/i)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (await creditsText.isVisible()) {
|
|
||||||
const initialText = await creditsText.textContent();
|
|
||||||
|
|
||||||
// Inscribir una materia si hay disponibles
|
|
||||||
const enrollButton = page.locator(
|
|
||||||
'[data-testid="btn-enroll-subject"]:not(:disabled)'
|
|
||||||
).first();
|
|
||||||
|
|
||||||
if (await enrollButton.isVisible()) {
|
|
||||||
await enrollButton.click();
|
|
||||||
|
|
||||||
// Esperar actualización
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Los créditos deberían haber aumentado
|
|
||||||
const newText = await creditsText.textContent();
|
|
||||||
|
|
||||||
// Si la inscripción fue exitosa, el texto debería haber cambiado
|
|
||||||
// (3 créditos más)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe actualizar créditos al cancelar inscripción', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
const creditsText = page.locator('[data-testid="credits-counter"]').or(
|
|
||||||
page.getByText(/\d+.*\/.*9/i)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (await creditsText.isVisible()) {
|
|
||||||
const cancelButton = page.locator('[data-testid="btn-unenroll-subject"]').first();
|
|
||||||
|
|
||||||
if (await cancelButton.isVisible()) {
|
|
||||||
await cancelButton.click();
|
|
||||||
|
|
||||||
// Confirmar si es necesario
|
|
||||||
const confirmButton = page.getByRole('button', { name: /confirmar|sí|aceptar/i });
|
|
||||||
if (await confirmButton.isVisible()) {
|
|
||||||
await confirmButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Esperar actualización
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Los créditos deberían haber disminuido
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Reglas de Negocio - Profesores y Materias', () => {
|
|
||||||
test('debe mostrar 10 materias disponibles en total', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// La página debe cargar
|
|
||||||
await expect(page.getByText(/materias/i)).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
// Debe haber hasta 10 materias en el sistema
|
|
||||||
// (algunas pueden estar en inscritas, otras en disponibles)
|
|
||||||
});
|
|
||||||
|
|
||||||
test('cada materia debe mostrar 3 créditos', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Verificar que las materias muestran créditos
|
|
||||||
await expect(page.getByText(/3 créditos|3 creditos/i).first()).toBeVisible({
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar nombre del profesor en cada materia', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Las materias deben mostrar información del profesor
|
|
||||||
await expect(
|
|
||||||
page.getByText(/profesor|dr\.|dra\./i).first()
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe existir 5 profesores con 2 materias cada uno', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Esta validación es más de integración, verificamos que hay profesores
|
|
||||||
await expect(page.getByText(/materias disponibles/i)).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Reglas de Negocio - Estados de UI', () => {
|
|
||||||
test('debe mostrar estado de carga mientras obtiene datos', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
|
|
||||||
// Navegar con throttling para ver estado de carga
|
|
||||||
await page.route('**/graphql', async (route) => {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
await route.continue();
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Debe mostrar algún indicador de carga
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="loading"]').or(
|
|
||||||
page.getByText(/cargando/i)
|
|
||||||
).or(
|
|
||||||
page.locator('.loading-spinner, .mat-progress-spinner, mat-spinner')
|
|
||||||
)
|
|
||||||
).toBeVisible({ timeout: 5000 }).catch(() => {
|
|
||||||
// Si no hay indicador visible, puede que cargue muy rápido - OK
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe mostrar mensaje si no hay materias disponibles', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// Si todas las materias están restringidas
|
|
||||||
const availableCount = await page.locator(
|
|
||||||
'[data-testid="btn-enroll-subject"]:not(:disabled)'
|
|
||||||
).count();
|
|
||||||
|
|
||||||
if (availableCount === 0) {
|
|
||||||
// Debe mostrar mensaje apropiado
|
|
||||||
await expect(
|
|
||||||
page.getByText(/no hay materias|máximo|todas.*restringidas/i)
|
|
||||||
).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debe reflejar cambios inmediatamente después de inscribir', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setStudentSession(page);
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
const enrollButton = page.locator(
|
|
||||||
'[data-testid="btn-enroll-subject"]:not(:disabled)'
|
|
||||||
).first();
|
|
||||||
|
|
||||||
if (await enrollButton.isVisible()) {
|
|
||||||
// Obtener nombre de la materia
|
|
||||||
const subjectCard = enrollButton.locator('xpath=ancestor::*[contains(@class, "subject-card")]');
|
|
||||||
const subjectName = await subjectCard.locator('[data-testid="subject-name"]').textContent();
|
|
||||||
|
|
||||||
await enrollButton.click();
|
|
||||||
|
|
||||||
// Esperar respuesta
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// La materia debería aparecer en la sección de inscritas
|
|
||||||
if (subjectName) {
|
|
||||||
const enrolledSection = page.locator('[data-testid="enrolled-subjects"]');
|
|
||||||
await expect(
|
|
||||||
enrolledSection.getByText(subjectName.trim())
|
|
||||||
).toBeVisible({ timeout: 5000 }).catch(() => {
|
|
||||||
// Si no está en la sección específica, verificar que hubo éxito de alguna forma
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
import { test, expect, Page } from '@playwright/test';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* E2E Tests: Control de Acceso por Roles
|
|
||||||
* Verifica que las rutas estén correctamente protegidas según el rol del usuario.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Helper para simular sesión de usuario
|
|
||||||
async function setUserSession(page: Page, role: 'Admin' | 'Student', studentId?: number) {
|
|
||||||
const mockToken = 'mock.jwt.token';
|
|
||||||
const mockUser = {
|
|
||||||
id: 1,
|
|
||||||
username: role === 'Admin' ? 'admin' : 'student',
|
|
||||||
role,
|
|
||||||
studentId: studentId || (role === 'Student' ? 1 : null),
|
|
||||||
studentName: role === 'Student' ? 'Test Student' : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.evaluate(
|
|
||||||
({ token, user }) => {
|
|
||||||
localStorage.setItem('auth_token', token);
|
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
|
||||||
},
|
|
||||||
{ token: mockToken, user: mockUser }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe('Control de Acceso - Rol Admin', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setUserSession(page, 'Admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin debe poder acceder al panel de administración', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
|
|
||||||
// No debe redirigir a otra página
|
|
||||||
await expect(page).toHaveURL(/\/admin/);
|
|
||||||
|
|
||||||
// Debe mostrar contenido de admin
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: /panel.*admin|administración|gestión/i })
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin debe poder acceder a gestión de estudiantes', async ({ page }) => {
|
|
||||||
await page.goto('/students');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/students/);
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: /estudiantes|listado/i })
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin debe poder crear estudiantes', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/students\/new/);
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: /nuevo estudiante/i })
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin debe ver menú de navegación completo', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
|
|
||||||
// Admin debe ver opciones de administración
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: /panel admin|administración/i }).or(
|
|
||||||
page.getByRole('button', { name: /panel admin|administración/i })
|
|
||||||
)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: /estudiantes|gestión/i }).or(
|
|
||||||
page.getByRole('button', { name: /estudiantes|gestión/i })
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Control de Acceso - Rol Student', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setUserSession(page, 'Student', 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante debe acceder a su dashboard', async ({ page }) => {
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
await expect(
|
|
||||||
page.getByText(/bienvenido|mi portal|dashboard/i)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante debe acceder a sus inscripciones', async ({ page }) => {
|
|
||||||
await page.goto('/enrollment/1');
|
|
||||||
|
|
||||||
// No debe redirigir si es su propio ID
|
|
||||||
await expect(page).toHaveURL(/\/enrollment/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante debe acceder a compañeros', async ({ page }) => {
|
|
||||||
await page.goto('/classmates/1');
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/classmates/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante NO debe acceder al panel de admin', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
|
|
||||||
// Debe redirigir a dashboard
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante NO debe acceder a gestión de estudiantes', async ({ page }) => {
|
|
||||||
await page.goto('/students');
|
|
||||||
|
|
||||||
// Debe redirigir a dashboard
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante NO debe poder crear estudiantes', async ({ page }) => {
|
|
||||||
await page.goto('/students/new');
|
|
||||||
|
|
||||||
// Debe redirigir a dashboard
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('estudiante debe ver menú de navegación limitado', async ({ page }) => {
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
|
|
||||||
// Estudiante debe ver opciones de estudiante
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: /mi portal|dashboard/i }).or(
|
|
||||||
page.getByRole('button', { name: /mi portal|dashboard/i })
|
|
||||||
)
|
|
||||||
).toBeVisible({ timeout: 10000 });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: /mis materias|inscripción/i }).or(
|
|
||||||
page.getByRole('button', { name: /mis materias|inscripción/i })
|
|
||||||
)
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
// NO debe ver opciones de admin
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: /panel admin/i })
|
|
||||||
).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Control de Acceso - Sin Autenticación', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.evaluate(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
sessionStorage.clear();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario no autenticado debe ir a login desde dashboard', async ({ page }) => {
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario no autenticado debe ir a login desde admin', async ({ page }) => {
|
|
||||||
await page.goto('/admin');
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario no autenticado debe ir a login desde students', async ({ page }) => {
|
|
||||||
await page.goto('/students');
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario no autenticado puede acceder a login', async ({ page }) => {
|
|
||||||
await page.goto('/login');
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
await expect(page.getByRole('heading', { name: /iniciar sesión/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario no autenticado puede acceder a registro', async ({ page }) => {
|
|
||||||
await page.goto('/register');
|
|
||||||
await expect(page).toHaveURL(/\/register/);
|
|
||||||
await expect(page.getByRole('heading', { name: /crear cuenta|registro/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario no autenticado puede acceder a reset password', async ({ page }) => {
|
|
||||||
await page.goto('/reset-password');
|
|
||||||
await expect(page).toHaveURL(/\/reset-password/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Control de Acceso - Navegación Post-Login', () => {
|
|
||||||
test('usuario autenticado no debe ver página de login', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setUserSession(page, 'Student', 1);
|
|
||||||
|
|
||||||
await page.goto('/login');
|
|
||||||
|
|
||||||
// Debe redirigir a dashboard
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('usuario autenticado no debe ver página de registro', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await setUserSession(page, 'Student', 1);
|
|
||||||
|
|
||||||
await page.goto('/register');
|
|
||||||
|
|
||||||
// Debe redirigir a dashboard
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||