diff --git a/.env.example b/.env.example
index a93c80f..22bb29c 100644
--- a/.env.example
+++ b/.env.example
@@ -19,6 +19,15 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000
RATE_LIMIT_MAX_REQUESTS=60
RATE_LIMIT_WINDOW_SECONDS=60
+# --- Autenticacion JWT (nivel 10) ---
+# Usuario "demo" para probar /api/auth/login, y la clave con la que se
+# firman los tokens. Cambia JWT_SECRET si vas a exponer esto mas alla de tu
+# maquina local.
+AUTH_DEMO_USERNAME=admin
+AUTH_DEMO_PASSWORD=changeme123
+JWT_SECRET=cambia-esta-clave-en-produccion-es-solo-para-desarrollo-local-1234
+JWT_EXPIRATION_MINUTES=30
+
# --- Frontend (Docker Compose) ---
# URL donde el frontend estático espera encontrar la API.
API_BASE_URL=http://localhost:8080
diff --git a/.github/workflows/nivel-10-autenticacion.yml b/.github/workflows/nivel-10-autenticacion.yml
new file mode 100644
index 0000000..0e3ede5
--- /dev/null
+++ b/.github/workflows/nivel-10-autenticacion.yml
@@ -0,0 +1,32 @@
+name: "Nivel 10 - Autenticacion con JWT"
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test-nivel10:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout codigo
+ uses: actions/checkout@v4
+
+ - name: Configurar Java 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: "temurin"
+ java-version: "17"
+
+ - name: Cache Maven
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - name: Dar permisos de ejecucion a mvnw
+ run: chmod +x mvnw
+
+ - name: Ejecutar la suite completa (nivel 1-10)
+ run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10
diff --git a/.github/workflows/nivel-11-caching.yml b/.github/workflows/nivel-11-caching.yml
new file mode 100644
index 0000000..62553d9
--- /dev/null
+++ b/.github/workflows/nivel-11-caching.yml
@@ -0,0 +1,32 @@
+name: "Nivel 11 - Caching"
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test-nivel11:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout codigo
+ uses: actions/checkout@v4
+
+ - name: Configurar Java 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: "temurin"
+ java-version: "17"
+
+ - name: Cache Maven
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - name: Dar permisos de ejecucion a mvnw
+ run: chmod +x mvnw
+
+ - name: Ejecutar la suite completa (nivel 1-11)
+ run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11
diff --git a/.github/workflows/nivel-12-observabilidad.yml b/.github/workflows/nivel-12-observabilidad.yml
new file mode 100644
index 0000000..3d1d8b8
--- /dev/null
+++ b/.github/workflows/nivel-12-observabilidad.yml
@@ -0,0 +1,42 @@
+name: "Nivel 12 - Observabilidad"
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test-nivel12:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout codigo
+ uses: actions/checkout@v4
+
+ - name: Configurar Java 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: "temurin"
+ java-version: "17"
+
+ - name: Cache Maven
+ uses: actions/cache@v4
+ with:
+ path: ~/.m2/repository
+ key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+ restore-keys: |
+ ${{ runner.os }}-maven-
+
+ - name: Dar permisos de ejecucion a mvnw
+ run: chmod +x mvnw
+
+ - name: Ejecutar la suite completa (nivel 1-12)
+ run: ./mvnw -q test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12
+
+ - name: Verificar cobertura minima del proyecto (JaCoCo)
+ run: ./mvnw -q verify -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12 -Pcoverage-nivel12
+
+ - name: Publicar reporte de cobertura
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: jacoco-report-nivel12
+ path: target/site/jacoco/
diff --git a/docs/curso/05-pruebas-unitarias/README.md b/docs/curso/05-pruebas-unitarias/README.md
index 9bb8936..ad93d93 100644
--- a/docs/curso/05-pruebas-unitarias/README.md
+++ b/docs/curso/05-pruebas-unitarias/README.md
@@ -47,9 +47,11 @@ void testFindBySeason() {
Completa
[`RecipeServiceExtraTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java).
Tiene 3 métodos con `fail("TODO ...")` — bórralo y escribe una prueba real
-para cada uno, probando `countRecipes()` y `getRecipesPage(...)` (los que
-implementaste en los niveles 2 y 7) **a nivel de `RecipeService`**, no de
-`RecipeController`.
+para cada uno, probando `countRecipes()` (el que implementaste en el nivel
+2) y el caso "no encontrado" de `getRecipeById(...)` **a nivel de
+`RecipeService`**, no de `RecipeController` — `RecipeServiceTest.java` ya
+prueba el caso en que la receta sí existe, pero nadie probó todavía qué
+pasa cuando no.
El archivo ya trae la configuración de `@Mock`/`@InjectMocks` lista, y
comentarios con pistas específicas en cada método.
diff --git a/docs/curso/06-seguridad/README.md b/docs/curso/06-seguridad/README.md
index 1dfa26a..dbdc9ab 100644
--- a/docs/curso/06-seguridad/README.md
+++ b/docs/curso/06-seguridad/README.md
@@ -1,10 +1,10 @@
# Nivel 6 — Seguridad básica
-No vas a implementar autenticación/autorización completa en este curso (eso
-da para todo un curso aparte, con su propio dolor de cabeza dedicado), pero
-sí las prácticas de seguridad más básicas que **cualquier** API debería
-tener desde el primer día — el equivalente a cerrar la puerta con llave
-antes de preocuparte por instalar cámaras.
+Todavía no vas a implementar autenticación/autorización (eso llega más
+adelante, en el nivel 10 bonus, una vez tengas encima las prácticas más
+básicas), pero sí las que **cualquier** API debería tener desde el primer
+día — el equivalente a cerrar la puerta con llave antes de preocuparte por
+instalar cámaras.
## Parte A — Validación de entrada (Bean Validation)
diff --git a/docs/curso/09-proyecto-final/README.md b/docs/curso/09-proyecto-final/README.md
index 462f673..7ca6e32 100644
--- a/docs/curso/09-proyecto-final/README.md
+++ b/docs/curso/09-proyecto-final/README.md
@@ -1,7 +1,7 @@
# Nivel 9 — Proyecto final: Comentarios de receta
-Llegaste al último nivel. 🎉 Respira, esto ya es cuesta abajo — no porque
-sea fácil, sino porque ya sabes exactamente cómo se ve el camino: lo
+Llegaste al cierre del curso base. 🎉 Respira, esto ya es cuesta abajo — no
+porque sea fácil, sino porque ya sabes exactamente cómo se ve el camino: lo
recorriste ocho veces con `Recipe`. Aquí no hay un método puntual con un
`TODO` esperando una línea: vas a construir un recurso **completo**, de
punta a punta, replicando por tu cuenta todo lo que aprendiste con `Recipe`
@@ -96,8 +96,21 @@ una cobertura mínima del 60% en todo el proyecto y 70% específicamente en
`CommentService` (perfil Maven `coverage-nivel9`).
Cuando este workflow (`.github/workflows/nivel-09-proyecto-final.yml`) esté
-en verde, completaste el curso — ya construiste, de principio a fin, una
-API REST siguiendo buenas prácticas de arquitectura, pruebas, seguridad,
-paginación y control de tráfico. 🎉 En serio, guarda este repo: dentro de
-un año, cuando alguien te pregunte "¿pero tú sí sabes hacer un backend de
-verdad?", este es tu recibo.
+en verde, completaste el curso base — ya construiste, de principio a fin,
+una API REST siguiendo buenas prácticas de arquitectura, pruebas,
+seguridad, paginación y control de tráfico. 🎉 En serio, guarda este repo:
+dentro de un año, cuando alguien te pregunte "¿pero tú sí sabes hacer un
+backend de verdad?", este es tu recibo.
+
+## ¿Y ahora qué?
+
+Si quieres seguir exprimiendo este proyecto, hay tres niveles bonus que
+llevan la API un poco más allá de lo que la mayoría de cursos cubre:
+
+- [Nivel 10 — Autenticación con JWT](../10-autenticacion/README.md)
+- [Nivel 11 — Caching](../11-caching/README.md)
+- [Nivel 12 — Observabilidad](../12-observabilidad/README.md)
+
+No son obligatorios para decir que "terminaste el curso" — son el
+equivalente a las misiones secundarias de un juego: opcionales, pero ahí es
+donde vive buena parte de lo que se te va a pedir en un trabajo real.
diff --git a/docs/curso/10-autenticacion/README.md b/docs/curso/10-autenticacion/README.md
new file mode 100644
index 0000000..94b248a
--- /dev/null
+++ b/docs/curso/10-autenticacion/README.md
@@ -0,0 +1,117 @@
+# Nivel 10 (bonus) — Autenticación con JWT
+
+Este es el primero de tres niveles **bonus**: no son obligatorios para
+"terminar el curso" (eso ya lo lograste en el nivel 9), pero sí son temas
+que casi cualquier API real termina necesitando tarde o temprano. Aquí
+vamos a cerrar la deuda pendiente del nivel 6: hasta ahora, cualquiera con
+la URL puede crear, editar o borrar recetas y comentarios sin que nadie le
+pregunte quién es.
+
+## El problema
+
+`POST /api/recipes`, `PUT /api/recipes/{name}`, `DELETE /api/recipes/{id}`
+(y sus equivalentes en comentarios) están abiertos al mundo. Eso está bien
+para aprender CRUD, pero ninguna API real se queda así: alguien tiene que
+poder decir "esta petición viene de alguien que ya demostró quién es".
+
+## JWT en dos frases
+
+Un **JWT** (JSON Web Token) es un token firmado digitalmente que un
+servidor emite después de verificar tus credenciales. El cliente lo guarda
+y lo reenvía en cada petición futura (header `Authorization: Bearer
+{token}`); el servidor solo tiene que verificar la firma para confiar en el
+contenido, sin tener que ir a preguntarle a una base de datos "¿esta sesión
+sigue viva?" en cada petición — por eso se dice que es "stateless": toda la
+información que el servidor necesita ya viaja dentro del propio token.
+
+Como el token está firmado (no cifrado), cualquiera puede leer su
+contenido decodificando Base64 — pero solo quien conoce la clave secreta
+pudo haberlo firmado. Por eso nunca metas datos sensibles dentro de un JWT,
+y por eso la clave de firma (`jwt.secret`) nunca debe filtrarse.
+
+## Cómo está armado este nivel
+
+- [`LoginRequest.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java) /
+ [`LoginResponse.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java)
+ — los DTOs de entrada/salida de `POST /api/auth/login`.
+- [`AuthController.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java)
+ — ya completo. Compara las credenciales recibidas contra un único usuario
+ "demo" configurado por variable de entorno (`AUTH_DEMO_USERNAME` /
+ `AUTH_DEMO_PASSWORD`, ver `application.properties`) y, si coinciden, pide
+ un token a `JwtService`. En una API real esto sería una tabla de usuarios
+ con contraseñas hasheadas — simplificamos a un solo usuario para
+ enfocarnos en el mecanismo de JWT en sí, que es idéntico en ambos casos.
+- [`JwtAuthFilter.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java)
+ — ya completo. Lee el header `Authorization`, valida el token con
+ `JwtService` y, si es válido, marca la petición como autenticada.
+- [`SecurityConfig.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java)
+ — ya completo. Define qué rutas son públicas (login, todos los `GET` de
+ recetas/comentarios, Swagger, Actuator) y cuáles exigen un JWT válido
+ (todo lo demás: los `POST`/`PUT`/`DELETE`).
+
+## Tu tarea: `JwtService.java`
+
+Todo el mecanismo criptográfico vive, aislado, en
+[`JwtService.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java).
+Dos métodos:
+
+- **`generateToken(String username)`**: construye un JWT firmado (HS256)
+ con `username` como *subject*, fecha de emisión = ahora, y expiración =
+ ahora + `jwt.expiration-minutes`. La librería es
+ [jjwt](https://github.com/jwtk/jjwt) (`io.jsonwebtoken`), ya agregada al
+ `pom.xml`:
+
+ ```java
+ Jwts.builder()
+ .setSubject(username)
+ .setIssuedAt(new Date())
+ .setExpiration(new Date(System.currentTimeMillis() + expirationMillis))
+ .signWith(signingKey, SignatureAlgorithm.HS256)
+ .compact();
+ ```
+
+- **`validateAndGetUsername(String token)`**: parsea y valida la firma y la
+ expiración del token, devolviendo el username si es válido o `null` si
+ no (corrupto, mal firmado, o expirado — `Jwts.parserBuilder()` lanza
+ `JwtException` en cualquiera de esos casos; captúrala y devuelve `null`).
+
+No necesitas tocar `AuthController`, `JwtAuthFilter` ni `SecurityConfig`:
+una vez que `JwtService` funciona, todo el resto del mecanismo ya está
+conectado.
+
+## Probarlo a mano
+
+```bash
+# Sin token: las rutas GET siguen abiertas
+curl http://localhost:8080/api/recipes
+
+# Login con el usuario demo (ver .env / application.properties)
+curl -X POST http://localhost:8080/api/auth/login \
+ -H "Content-Type: application/json" \
+ -d '{"username":"admin","password":"changeme123"}'
+# -> { "token": "eyJhbGciOi..." }
+
+# Sin token, una escritura debe rechazarse
+curl -i -X DELETE http://localhost:8080/api/recipes/algun-id
+# -> 401/403
+
+# Con token, la misma escritura debe funcionar (si ya implementaste
+# createRecipe/deleteRecipe en niveles anteriores)
+curl -i -X DELETE http://localhost:8080/api/recipes/algun-id \
+ -H "Authorization: Bearer eyJhbGciOi..."
+```
+
+## Verificación de este nivel
+
+```bash
+./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10
+```
+
+El corrector es
+[`JwtServiceTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java)
+y prueba `JwtService` directamente (sin levantar un servidor HTTP ni Spring
+Security completo, igual que `RateLimitFilterTest` en el nivel 8): genera
+tokens, los valida, y verifica que un token corrupto, mal firmado, o
+expirado devuelva `null`.
+
+Sigue con el [nivel 11 (caching)](../11-caching/README.md).
diff --git a/docs/curso/11-caching/README.md b/docs/curso/11-caching/README.md
new file mode 100644
index 0000000..209b0ba
--- /dev/null
+++ b/docs/curso/11-caching/README.md
@@ -0,0 +1,74 @@
+# Nivel 11 (bonus) — Caching
+
+Cada `GET /api/recipes/{id}` que llega hoy va directo a Mongo, aunque sea
+la misma receta que veinte usuarios distintos pidieron en el último minuto.
+Eso funciona, pero desperdicia trabajo: si los datos no cambiaron, ¿por qué
+volver a preguntarle a la base de datos la misma cosa una y otra vez?
+
+## Caching en una frase
+
+Un **caché** guarda el resultado de una operación costosa (aquí, una
+consulta a Mongo) para devolverlo directo la próxima vez que alguien pida
+exactamente lo mismo, sin repetir el trabajo — como un mesero que ya se
+aprendió tu pedido habitual y no necesita ir a preguntarle a cocina otra
+vez. El truco (y el riesgo) está en **cuándo invalidar** esa memoria: si
+actualizas o borras el dato original y el caché no se entera, empiezas a
+servir información vieja.
+
+## Spring Cache
+
+Spring trae un mecanismo de caching declarativo: anotas un método con
+`@Cacheable` y, antes de ejecutarlo de verdad, Spring revisa si ya tiene
+guardado un resultado para esos mismos argumentos — si sí, lo devuelve sin
+tocar tu código. `@CacheEvict` hace lo contrario: vacía el caché cuando los
+datos ya no son válidos (típicamente, en cualquier operación de escritura).
+
+[`CacheConfig.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java)
+ya está completo: activa `@EnableCaching` y define dos cachés en memoria
+(`recipeById`, `recipeCount`) usando `ConcurrentMapCacheManager` — la
+opción más simple para aprender el mecanismo (en producción normalmente se
+usa algo compartido entre instancias, como Redis, pero la forma de usar
+`@Cacheable`/`@CacheEvict` en tu código es exactamente la misma).
+
+## Tu tarea
+
+Todo tu trabajo en este nivel es agregar anotaciones en
+[`RecipeService.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java)
+— no necesitas cambiar ningún cuerpo de método, cada uno ya trae un
+comentario `TODO (nivel 11)` con la anotación exacta que le corresponde
+(y la [`Cacheable`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/Cacheable.html)/[`CacheEvict`](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/cache/annotation/CacheEvict.html)
+que debes importar):
+
+| Método | Anotación | Por qué |
+|---|---|---|
+| `countRecipes()` | `@Cacheable(CacheConfig.RECIPE_COUNT_CACHE)` | Lectura pura, se puede cachear sin más. |
+| `getRecipeById(id)` | `@Cacheable(value = ..., key = "#id")` | Lectura por clave — el `key` distingue una receta de otra. |
+| `saveRecipe(recipe)` | `@CacheEvict(..., allEntries = true)` | Una receta nueva puede cambiar el conteo y (si reemplaza un id) el resultado cacheado de esa receta. |
+| `deleteRecipe(id)` | `@CacheEvict(..., allEntries = true)` | Mismo motivo que `saveRecipe`. |
+| `updateRecipe(name, recipe)` | `@CacheEvict(value = RECIPE_BY_ID_CACHE, allEntries = true)` | El conteo no cambia con un update, solo hay que invalidar la caché de recetas por id. |
+
+Sobre el `key = "#id"` de `getRecipeById`: sin él, Spring cachearía **un
+solo** resultado para cualquier id (el primero que le pidas) — justo el bug
+que no quieres. `"#id"` es [SpEL](https://docs.spring.io/spring-framework/reference/core/expressions.html)
+(Spring Expression Language) y hace referencia al parámetro `id` del propio
+método.
+
+## Verificación de este nivel
+
+```bash
+./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11
+```
+
+El corrector es
+[`RecipeServiceCachingTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java).
+A diferencia de los demás correctores de este curso, este sí levanta un
+mini contexto de Spring (sin base de datos real, sin servidor HTTP) porque
+el caching funciona mediante un proxy que envuelve tu bean — sin ese proxy
+no hay forma de observar el efecto de las anotaciones. La estrategia es
+siempre la misma: llamar dos veces al mismo método con los mismos
+argumentos y verificar, con un repositorio mockeado, cuántas veces la
+llamada llegó realmente hasta ahí. Si tu caché funciona, la segunda llamada
+nunca debería tocar el repositorio — y si guardas/actualizas/borras algo,
+la siguiente lectura sí debería volver a consultarlo.
+
+Sigue con el [nivel 12 (observabilidad)](../12-observabilidad/README.md).
diff --git a/docs/curso/12-observabilidad/README.md b/docs/curso/12-observabilidad/README.md
new file mode 100644
index 0000000..30f90cc
--- /dev/null
+++ b/docs/curso/12-observabilidad/README.md
@@ -0,0 +1,94 @@
+# Nivel 12 (bonus) — Observabilidad
+
+El último nivel del curso. Hasta ahora, si algo falla en producción, tu
+única fuente de información son los logs por defecto de Spring Boot y la
+suerte. **Observabilidad** es la práctica de instrumentar tu aplicación
+para que, cuando algo salga mal a las 3am, puedas responder rápido dos
+preguntas: "¿está viva mi aplicación?" y "¿qué pasó exactamente en *esta*
+petición?".
+
+## Parte A — Health check personalizado
+
+Spring Boot Actuator (ya agregado al `pom.xml`) expone `/actuator/health`
+con el estado genérico de la aplicación. Pero "la aplicación arrancó" y "la
+aplicación puede realmente hablar con Mongo" son cosas distintas — un
+`HealthIndicator` personalizado le agrega a ese endpoint una pieza de
+información específica de tu dominio.
+
+Completa
+[`RecipeDatabaseHealthIndicator.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java),
+específicamente `doHealthCheck(Health.Builder builder)`:
+
+- Llama a `recipeRepository.count()`.
+- Si no lanza excepción, arma la respuesta con
+ `builder.up().withDetail("recipes", count)`.
+- No necesitas try/catch propio: la clase extiende `AbstractHealthIndicator`
+ (en vez de implementar `HealthIndicator` directo), que ya envuelve tu
+ código en un try/catch — si `count()` lanza (Mongo caído, por ejemplo),
+ Actuator lo convierte automáticamente en `Health.down(esaExcepcion)` en
+ vez de tumbar el endpoint completo con un 500.
+
+Cualquier bean que implemente `HealthIndicator` se registra
+automáticamente en `/actuator/health` — no necesitas conectarlo a mano en
+ningún lado.
+
+## Parte B — Request id y logging estructurado
+
+Cuando algo falla, la pregunta casi nunca es "¿qué pasó en el servidor?"
+sino "¿qué pasó con **esta** petición específica?". Sin un identificador
+que amarre todas las líneas de log de una misma petición, reconstruir esa
+historia es como intentar seguir una conversación leyendo los mensajes de
+50 chats mezclados en un solo hilo.
+
+Completa
+[`RequestLoggingFilter.java`](../../../src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java),
+específicamente `resolveRequestId(HttpServletRequest request)`:
+
+- Si el cliente ya mandó un header `X-Request-Id` (no nulo, no vacío),
+ reutilízalo tal cual — típico cuando varios servicios se llaman entre sí
+ y quieres rastrear una petición de punta a punta.
+- Si no, genera uno nuevo con `UUID.randomUUID().toString()`.
+
+El resto del filtro ya está completo: mete el id en el
+[MDC](https://logback.qos.ch/manual/mdc.html) de SLF4J (así aparece
+automáticamente en cada línea de log mientras dura la petición, sin que
+tengas que pasarlo a mano de método en método), lo devuelve en la respuesta
+(header `X-Request-Id`, útil para que el cliente lo reporte si algo sale
+mal), y registra una línea de log al entrar y otra al salir con el método,
+la ruta, el status y la duración.
+
+## Probarlo a mano
+
+```bash
+# Health check (arranca la app con Mongo corriendo)
+curl http://localhost:8080/actuator/health
+
+# Fíjate en el header de respuesta X-Request-Id
+curl -i http://localhost:8080/api/recipes | grep -i x-request-id
+
+# Y en la consola del servidor, deberías ver un par de líneas "-->"/"<--"
+# por cada petición.
+```
+
+## Verificación de este nivel
+
+```bash
+./mvnw test -Dgroups=nivel1,nivel2,nivel3,nivel4,nivel5,nivel6,nivel7,nivel8,nivel9,nivel10,nivel11,nivel12
+```
+
+Los correctores son
+[`RecipeDatabaseHealthIndicatorTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java)
+(prueba `health()` directamente con un repositorio mockeado: `UP` cuando
+responde, `DOWN` cuando lanza una excepción) y
+[`RequestLoggingFilterTest.java`](../../../src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java)
+(prueba `resolveRequestId(...)` directamente, sin servidor HTTP real, con
+un `MockHttpServletRequest`). Este nivel también exige la cobertura mínima
+final del proyecto (perfil Maven `coverage-nivel12`, mismas reglas que el
+nivel 9: 60% del proyecto, 70% en `CommentService`).
+
+Cuando este workflow (`.github/workflows/nivel-12-observabilidad.yml`) esté
+en verde, terminaste los tres niveles bonus: tu API ahora sabe quién puede
+tocarla (nivel 10), no repite trabajo de más (nivel 11), y puede contarte
+qué le pasó a cualquier petición individual cuando algo sale mal (nivel
+12). Eso es, en la práctica, buena parte de lo que separa un proyecto de
+curso de un backend que alguien pondría en producción. 🎉
diff --git a/docs/curso/README.md b/docs/curso/README.md
index f508e97..bc7d1a1 100644
--- a/docs/curso/README.md
+++ b/docs/curso/README.md
@@ -25,6 +25,16 @@ diciéndote "todavía no, pero vas bien".
| 8 | [Rate limiting](08-rate-limiting/README.md) | Filtros HTTP, ventanas de tiempo | 🔴 Alta |
| 9 | [Proyecto final](09-proyecto-final/README.md) | Un recurso completo desde cero | 🔴 Alta |
+Con el nivel 9 completaste el curso base. Los siguientes tres son
+**bonus**: opcionales, pero cubren temas que casi cualquier API real
+termina necesitando.
+
+| # | Nivel | De qué se trata | Dificultad |
+|---|---|---|---|
+| 10 | [Autenticación con JWT](10-autenticacion/README.md) | Spring Security, tokens firmados | 🔴 Alta (bonus) |
+| 11 | [Caching](11-caching/README.md) | `@Cacheable`/`@CacheEvict` | 🟠 Media-alta (bonus) |
+| 12 | [Observabilidad](12-observabilidad/README.md) | Health checks, request id, logging | 🟠 Media-alta (bonus) |
+
## Cómo se evalúa cada nivel
Cada nivel tiene su propio workflow en `.github/workflows/nivel-0N-*.yml`. Se
diff --git a/frontend/app.js b/frontend/app.js
index 5bc75bf..8bc164e 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -72,6 +72,24 @@ const LEVELS = [
description: "GET /api/comments/recipe/{id}",
check: () => pingGet("/api/comments/recipe/panel-de-avance"),
},
+ {
+ badge: "Nivel 10 (bonus)",
+ title: "Autenticación con JWT",
+ description: "POST /api/auth/login con credenciales invalidas debe responder 401.",
+ check: () => checkAuthLogin(),
+ },
+ {
+ badge: "Nivel 11 (bonus)",
+ title: "Caching de lecturas",
+ description: "Sin endpoint dedicado: revisa el resultado del workflow en Actions.",
+ check: null,
+ },
+ {
+ badge: "Nivel 12 (bonus)",
+ title: "Observabilidad: health check + request id",
+ description: "GET /actuator/health",
+ check: () => pingGet("/actuator/health"),
+ },
];
async function pingGet(path) {
@@ -86,6 +104,21 @@ async function pingGet(path) {
}
}
+async function checkAuthLogin() {
+ try {
+ const res = await fetch(API_BASE_URL + "/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: "no-existe", password: "no-existe" }),
+ });
+ // 500 = normalmente UnsupportedOperationException -> el endpoint aún es un TODO.
+ if (res.status >= 500) return "pending";
+ return res.status === 401 ? "ok" : "pending";
+ } catch (err) {
+ return "pending";
+ }
+}
+
async function burstCheckRateLimit(path) {
try {
const requests = Array.from({ length: 12 }, () => fetch(API_BASE_URL + path, { method: "GET" }));
diff --git a/pom.xml b/pom.xml
index 0138f64..0d4bb20 100644
--- a/pom.xml
+++ b/pom.xml
@@ -56,6 +56,42 @@
spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.11.5
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.11.5
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.11.5
+ runtime
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
org.projectlombok
lombok
@@ -120,10 +156,10 @@
@@ -213,6 +249,55 @@
+
+
+ coverage-nivel12
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.10
+
+
+ nivel12-check-project
+ verify
+
+ check
+
+
+
+
+ BUNDLE
+
+
+ LINE
+ COVEREDRATIO
+ 0.60
+
+
+
+
+ CLASS
+
+ *.comment.CommentService
+
+
+
+ LINE
+ COVEREDRATIO
+ 0.70
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java
new file mode 100644
index 0000000..81dd0bc
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/AuthController.java
@@ -0,0 +1,80 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * NIVEL 10 - Login. Ya está completo: no necesitas tocar este archivo.
+ *
+ * Este único usuario "demo" (configurado por ambiente, ver
+ * application.properties) es una simplificación deliberada para este curso.
+ * En una API real esto sería una tabla de usuarios con contraseñas
+ * hasheadas — el mecanismo de JWT en sí (que es lo que vas a implementar en
+ * JwtService) es exactamente el mismo en ambos casos.
+ */
+@RestController
+@RequestMapping("/api/auth")
+@Tag(name = "Autenticación", description = "Login y emisión de tokens JWT")
+public class AuthController {
+
+ private final JwtService jwtService;
+ private final String demoUsername;
+ private final String demoPasswordHash;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthController(
+ JwtService jwtService,
+ PasswordEncoder passwordEncoder,
+ @Value("${auth.demo-username}") String demoUsername,
+ @Value("${auth.demo-password}") String demoPassword) {
+ this.jwtService = jwtService;
+ this.passwordEncoder = passwordEncoder;
+ this.demoUsername = demoUsername;
+ // Hasheamos la contraseña una sola vez, al arrancar, para nunca
+ // comparar texto plano contra texto plano (aunque venga de una
+ // variable de entorno de desarrollo).
+ this.demoPasswordHash = passwordEncoder.encode(demoPassword);
+ }
+
+ /**
+ * POST /api/auth/login
+ *
+ * Devuelve 200 + un JWT si las credenciales coinciden con el usuario
+ * demo, o 401 si no. Mientras JwtService#generateToken no esté
+ * implementado, devuelve 501 (Not Implemented) — igual que
+ * GlobalExceptionHandler en el nivel 6 antes de completarlo.
+ */
+ @Operation(summary = "Login: intercambia usuario/contraseña por un JWT")
+ @ApiResponses({
+ @ApiResponse(responseCode = "200", description = "Login exitoso, token emitido"),
+ @ApiResponse(responseCode = "401", description = "Usuario o contraseña incorrectos")
+ })
+ @PostMapping("/login")
+ public ResponseEntity login(@RequestBody LoginRequest request) {
+ boolean credentialsMatch = request.getUsername() != null
+ && demoUsername.equals(request.getUsername())
+ && request.getPassword() != null
+ && passwordEncoder.matches(request.getPassword(), demoPasswordHash);
+
+ if (!credentialsMatch) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
+ }
+
+ try {
+ String token = jwtService.generateToken(request.getUsername());
+ return ResponseEntity.ok(new LoginResponse(token));
+ } catch (UnsupportedOperationException notImplementedYet) {
+ return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
+ }
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java
new file mode 100644
index 0000000..73a69c7
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtAuthFilter.java
@@ -0,0 +1,66 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.Collections;
+
+/**
+ * NIVEL 10 - Filtro de autenticación JWT. Ya está completo: no necesitas
+ * tocar este archivo, solo entender qué hace.
+ *
+ * A propósito NO está anotado con @Component (a diferencia de
+ * RateLimitFilter en el nivel 8): si lo estuviera, Spring Boot lo
+ * registraría automáticamente como filtro global ADEMÁS de dentro de la
+ * cadena de Spring Security (donde SecurityConfig lo agrega explícitamente
+ * con addFilterBefore), y terminaría ejecutándose dos veces por petición.
+ * SecurityConfig es quien lo instancia como @Bean.
+ *
+ * Por cada petición: si viene un header "Authorization: Bearer {token}" y
+ * el token es válido, marca al usuario como autenticado para el resto de la
+ * cadena (SecurityContextHolder). Si no viene token, o es inválido, o el
+ * nivel aún no está implementado, simplemente deja pasar la petición SIN
+ * autenticar — es SecurityConfig (con authorizeHttpRequests) quien decide
+ * después si esa ruta necesitaba autenticación o no (401/403).
+ */
+public class JwtAuthFilter extends OncePerRequestFilter {
+
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ private final JwtService jwtService;
+
+ public JwtAuthFilter(JwtService jwtService) {
+ this.jwtService = jwtService;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String header = request.getHeader("Authorization");
+
+ if (header != null && header.startsWith(BEARER_PREFIX)) {
+ String token = header.substring(BEARER_PREFIX.length());
+ try {
+ String username = jwtService.validateAndGetUsername(token);
+ if (username != null) {
+ UsernamePasswordAuthenticationToken authentication =
+ new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+ } catch (UnsupportedOperationException notImplementedYet) {
+ // Nivel 10 todavia no implementado: nadie puede autenticarse
+ // todavia (las rutas protegidas seguiran respondiendo
+ // 401/403), pero tampoco tumbamos toda la aplicacion por
+ // esto - mismo espiritu que RateLimitFilter en el nivel 8.
+ }
+ }
+
+ filterChain.doFilter(request, response);
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java
new file mode 100644
index 0000000..cc88c99
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtService.java
@@ -0,0 +1,81 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth;
+
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+/**
+ * NIVEL 10 - Autenticación y autorización con JWT.
+ *
+ * Un JWT (JSON Web Token) es un "carnet" firmado digitalmente: contiene
+ * información (aquí, solo el username) más una firma que prueba que lo
+ * emitió tu servidor y que nadie lo alteró en el camino. Como está firmado
+ * (no cifrado), cualquiera puede leer su contenido — pero solo tu servidor,
+ * que conoce la clave secreta, puede haberlo firmado o verificarlo. Por eso
+ * nunca metas datos sensibles (contraseñas, tarjetas) dentro del token.
+ *
+ * Esta clase concentra TODA la lógica de JWT en dos métodos aislados y
+ * fáciles de probar sin levantar un servidor (igual que
+ * RateLimitFilter#isRequestAllowed en el nivel 8). El resto del mecanismo
+ * (AuthController, JwtAuthFilter, SecurityConfig) ya está completo — tu
+ * única tarea es esta clase.
+ */
+@Component
+public class JwtService {
+
+ private final SecretKey signingKey;
+ private final long expirationMillis;
+
+ public JwtService(
+ @Value("${jwt.secret}") String secret,
+ @Value("${jwt.expiration-minutes}") long expirationMinutes) {
+ this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
+ this.expirationMillis = expirationMinutes * 60_000L;
+ }
+
+ /**
+ * TODO (nivel 10): genera un JWT firmado (HS256) para el usuario dado.
+ *
+ * Pistas:
+ * - Jwts.builder()
+ * .setSubject(username)
+ * .setIssuedAt(new Date())
+ * .setExpiration(new Date(System.currentTimeMillis() + expirationMillis))
+ * .signWith(signingKey, SignatureAlgorithm.HS256)
+ * .compact();
+ * - "compact()" es lo que produce el string final del token
+ * (algo como "eyJhbGciOiJIUzI1NiJ9....").
+ *
+ * @param username identificador del usuario autenticado (el "subject" del token).
+ * @return el JWT compacto, listo para devolver al cliente.
+ */
+ public String generateToken(String username) {
+ throw new UnsupportedOperationException("TODO nivel 10: implementa generateToken");
+ }
+
+ /**
+ * TODO (nivel 10): valida la firma y la expiración de un JWT y devuelve
+ * el username (subject) si es válido.
+ *
+ * Pistas:
+ * - Jwts.parserBuilder().setSigningKey(signingKey).build()
+ * .parseClaimsJws(token).getBody() te da el objeto Claims.
+ * - Claims#getSubject() te da el username.
+ * - Si el token está corrupto, mal firmado, o expiró, el parser lanza
+ * una JwtException (ExpiredJwtException incluida, que es subclase
+ * de JwtException) — captúrala y devuelve null en ese caso, en vez
+ * de dejar que la excepción se propague.
+ *
+ * @param token el JWT recibido en el header Authorization.
+ * @return el username si el token es válido, o null si es inválido/expiró.
+ */
+ public String validateAndGetUsername(String token) {
+ throw new UnsupportedOperationException("TODO nivel 10: implementa validateAndGetUsername");
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java
new file mode 100644
index 0000000..7873af4
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginRequest.java
@@ -0,0 +1,15 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth;
+
+/**
+ * NIVEL 10 - Cuerpo de entrada de POST /api/auth/login.
+ */
+public class LoginRequest {
+ private String username;
+ private String password;
+
+ public String getUsername() { return username; }
+ public void setUsername(String username) { this.username = username; }
+
+ public String getPassword() { return password; }
+ public void setPassword(String password) { this.password = password; }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java
new file mode 100644
index 0000000..bcd4fb7
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/LoginResponse.java
@@ -0,0 +1,19 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth;
+
+/**
+ * NIVEL 10 - Cuerpo de respuesta de POST /api/auth/login: el token que el
+ * cliente debe reenviar en el header "Authorization: Bearer {token}" en
+ * cada petición a una ruta protegida.
+ */
+public class LoginResponse {
+ private String token;
+
+ public LoginResponse() { }
+
+ public LoginResponse(String token) {
+ this.token = token;
+ }
+
+ public String getToken() { return token; }
+ public void setToken(String token) { this.token = token; }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java
new file mode 100644
index 0000000..6487428
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/CacheConfig.java
@@ -0,0 +1,37 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.config;
+
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * NIVEL 11 - Caching. Ya está completo: no necesitas tocar este archivo.
+ *
+ * @EnableCaching activa el mecanismo de caché de Spring: cuando anotas un
+ * método con @Cacheable, Spring envuelve el bean en un proxy que, antes de
+ * ejecutar el método de verdad, revisa si ya tiene el resultado guardado
+ * para esos mismos argumentos — si sí, lo devuelve directo, sin tocar la
+ * base de datos (como un mesero que ya se acuerda de memoria tu pedido
+ * habitual y no necesita ir a preguntarle a cocina otra vez).
+ *
+ * ConcurrentMapCacheManager guarda todo en memoria (un Map por caché,
+ * nombrado). Es la opción más simple para aprender el mecanismo — en
+ * producción normalmente se usa algo compartido entre instancias (Redis,
+ * Caffeine con métricas, etc.), pero la forma de usar @Cacheable/@CacheEvict
+ * en tu código es exactamente la misma sin importar el CacheManager de
+ * fondo.
+ */
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ public static final String RECIPE_BY_ID_CACHE = "recipeById";
+ public static final String RECIPE_COUNT_CACHE = "recipeCount";
+
+ @Bean
+ public CacheManager cacheManager() {
+ return new ConcurrentMapCacheManager(RECIPE_BY_ID_CACHE, RECIPE_COUNT_CACHE);
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java
new file mode 100644
index 0000000..aab13b5
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/SecurityConfig.java
@@ -0,0 +1,68 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.config;
+
+import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth.JwtAuthFilter;
+import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth.JwtService;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+/**
+ * NIVEL 10 - Configuración de Spring Security. Ya está completa: no
+ * necesitas tocar este archivo, solo entender qué hace.
+ *
+ * Reglas de autorización de esta API:
+ * - POST /api/auth/login: público (si no, nadie podría autenticarse).
+ * - GET /api/recipes/** y /api/comments/**: públicos (leer no requiere
+ * login, igual que en los niveles 1-9).
+ * - Swagger UI y Actuator (nivel 12): públicos, para que puedas seguir
+ * explorando/verificando la API sin token.
+ * - Cualquier otra ruta (POST/PUT/DELETE de recetas y comentarios):
+ * requiere un JWT válido en el header Authorization.
+ *
+ * La API es "stateless": no usa sesiones ni cookies, cada petición se
+ * autentica sola con el token que trae (por eso sessionCreationPolicy es
+ * STATELESS y csrf está deshabilitado - CSRF es un riesgo de sesiones
+ * basadas en cookies, no de APIs stateless con Bearer tokens).
+ */
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public JwtAuthFilter jwtAuthFilter(JwtService jwtService) {
+ return new JwtAuthFilter(jwtService);
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
+ http
+ .cors(Customizer.withDefaults())
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .formLogin(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .exceptionHandling(handling -> handling.authenticationEntryPoint(
+ (request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)))
+ .authorizeHttpRequests(auth -> auth
+ .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
+ .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
+ .requestMatchers(HttpMethod.GET, "/api/recipes/**", "/api/comments/**").permitAll()
+ .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/actuator/**").permitAll()
+ .anyRequest().authenticated())
+ .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java
index 1385938..2050c47 100644
--- a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/config/WebConfig.java
@@ -1,10 +1,16 @@
package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.config;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import java.util.Arrays;
+
/**
* NIVEL 1/6 - Configuración de CORS (ya implementada, léela como ejemplo).
*
@@ -48,4 +54,23 @@ public void addCorsMappings(CorsRegistry registry) {
.allowedOrigins(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS");
}
+
+ /**
+ * NIVEL 10 - Spring Security intercepta las peticiones ANTES que el
+ * mapeo de CORS de arriba (addCorsMappings), así que necesita su propia
+ * fuente de configuración de CORS para no bloquear el preflight OPTIONS
+ * del navegador. Reutiliza los mismos orígenes permitidos por variable
+ * de entorno; SecurityConfig la referencia con ".cors(...)".
+ */
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+ configuration.setAllowedOrigins(Arrays.asList(resolveAllowedOrigins()));
+ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
+ configuration.setAllowedHeaders(Arrays.asList("*"));
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/api/**", configuration);
+ return source;
+ }
}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java
new file mode 100644
index 0000000..ff1ea62
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilter.java
@@ -0,0 +1,85 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.UUID;
+
+/**
+ * NIVEL 12 - Observabilidad: request id + logging estructurado.
+ *
+ * Cuando algo falla en producción, la pregunta casi nunca es "¿qué pasó en
+ * el servidor?" sino "¿qué pasó con ESTA petición específica de ESTE
+ * usuario?". Sin un identificador que amarre todas las líneas de log de una
+ * misma petición, buscar la respuesta es como intentar reconstruir una
+ * conversación leyendo los mensajes de 50 chats mezclados en un solo hilo.
+ *
+ * Este filtro le asigna a cada petición un "request id" (lo reutiliza si el
+ * cliente ya mandó uno en el header X-Request-Id — típico cuando varios
+ * servicios se llaman entre sí y quieres rastrear una petición de punta a
+ * punta; si no, genera uno nuevo), lo mete en el MDC de SLF4J (así aparece
+ * automáticamente en cada línea de log mientras dura la petición, sin que
+ * tengas que pasarlo a mano de método en método) y lo devuelve también en
+ * la respuesta, para que el cliente pueda reportar ese id si algo sale mal.
+ *
+ * doFilterInternal ya está completo. Tu única tarea es resolveRequestId(...)
+ * — el resto (MDC, logging, header de respuesta) no necesitas tocarlo.
+ */
+@Component
+public class RequestLoggingFilter extends OncePerRequestFilter {
+
+ private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
+ private static final String REQUEST_ID_HEADER = "X-Request-Id";
+ private static final String MDC_KEY = "requestId";
+
+ /**
+ * TODO (nivel 12): devuelve el request id a usar para esta petición.
+ *
+ * Pistas:
+ * - Si request.getHeader(REQUEST_ID_HEADER) no es null ni está vacío
+ * (después de un trim()), reutilízalo tal cual.
+ * - Si no, genera uno nuevo con UUID.randomUUID().toString().
+ *
+ * @param request la petición HTTP entrante.
+ * @return el request id a usar (nunca null ni vacío).
+ */
+ public String resolveRequestId(HttpServletRequest request) {
+ throw new UnsupportedOperationException("TODO nivel 12: implementa resolveRequestId");
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String requestId;
+ try {
+ requestId = resolveRequestId(request);
+ } catch (UnsupportedOperationException notImplementedYet) {
+ // Nivel 12 todavia no implementado: no rompas peticiones de
+ // niveles anteriores por esto, simplemente no hay request id
+ // todavia (mismo espiritu que RateLimitFilter en el nivel 8).
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ MDC.put(MDC_KEY, requestId);
+ response.setHeader(REQUEST_ID_HEADER, requestId);
+ long start = System.currentTimeMillis();
+ try {
+ log.info("--> {} {}", request.getMethod(), request.getRequestURI());
+ filterChain.doFilter(request, response);
+ } finally {
+ long durationMillis = System.currentTimeMillis() - start;
+ log.info("<-- {} {} {} ({} ms)", request.getMethod(), request.getRequestURI(),
+ response.getStatus(), durationMillis);
+ MDC.remove(MDC_KEY);
+ }
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java
new file mode 100644
index 0000000..c4c468c
--- /dev/null
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicator.java
@@ -0,0 +1,51 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.health;
+
+import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.repository.RecipeRepository;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.actuate.health.AbstractHealthIndicator;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.stereotype.Component;
+
+/**
+ * NIVEL 12 - Observabilidad: health check personalizado.
+ *
+ * Spring Boot Actuator ya expone /actuator/health con el estado genérico de
+ * la aplicación (¿arrancó? ¿responde?), pero eso no te dice si tu API
+ * realmente puede hablar con Mongo — una aplicación puede estar "viva" y
+ * aun así ser incapaz de servir una sola petición real porque la base de
+ * datos está caída. Un HealthIndicator personalizado le agrega a
+ * /actuator/health una pieza de información específica de TU dominio,
+ * exactamente lo que un servicio de monitoreo (o tu equipo de guardia a las
+ * 3am) necesita para saber si "verde" significa "verde de verdad".
+ *
+ * Cualquier bean que implemente HealthIndicator se registra
+ * automáticamente: no necesitas conectarlo a mano en ningún lado.
+ *
+ * Extiende AbstractHealthIndicator (en vez de implementar HealthIndicator
+ * directo) porque ya trae, gratis, un try/catch alrededor de
+ * doHealthCheck(...): si tu código lanza cualquier excepción, Actuator la
+ * convierte automáticamente en Health.down(esaExcepcion) en vez de tumbar
+ * el endpoint completo con un 500.
+ */
+@Component
+public class RecipeDatabaseHealthIndicator extends AbstractHealthIndicator {
+
+ @Autowired
+ private RecipeRepository recipeRepository;
+
+ /**
+ * TODO (nivel 12): implementa el chequeo de salud de la base de datos.
+ *
+ * Pista: llama a recipeRepository.count() y, si no lanza excepción,
+ * arma la respuesta con builder.up().withDetail("recipes", count) — no
+ * necesitas try/catch propio: si count() lanza (Mongo caído, por
+ * ejemplo), AbstractHealthIndicator ya se encarga de convertir esa
+ * excepción en un Health.down(...) por ti.
+ *
+ * @param builder constructor del resultado de salud.
+ */
+ @Override
+ protected void doHealthCheck(Health.Builder builder) throws Exception {
+ throw new UnsupportedOperationException("TODO nivel 12: implementa doHealthCheck");
+ }
+}
diff --git a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java
index 30ce4f1..f761f46 100644
--- a/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java
+++ b/src/main/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeService.java
@@ -27,6 +27,13 @@ public class RecipeService {
* Cuenta el total de recetas almacenadas.
* Spring Data ya trae este conteo gratis en toda MongoRepository — a
* veces la mejor línea de código es la que no tienes que escribir.
+ *
+ * TODO (nivel 11): agrega @Cacheable(CacheConfig.RECIPE_COUNT_CACHE)
+ * justo encima de este método (importa
+ * org.springframework.cache.annotation.Cacheable). No necesitas cambiar
+ * el cuerpo del método — Spring intercepta la llamada ANTES de que
+ * entre aquí si ya tiene el resultado en caché.
+ *
* @return número total de recetas.
*/
public long countRecipes() {
@@ -45,6 +52,15 @@ public Page getRecipesPage(int page, int size) {
/**
* Guarda una receta en la base de datos.
+ *
+ * TODO (nivel 11): agrega
+ * @CacheEvict(value = {CacheConfig.RECIPE_BY_ID_CACHE, CacheConfig.RECIPE_COUNT_CACHE}, allEntries = true)
+ * encima de este método (importa
+ * org.springframework.cache.annotation.CacheEvict). Guardar una receta
+ * nueva puede cambiar el conteo total y, si reemplaza un id existente,
+ * el resultado cacheado de getRecipeById — la única forma segura de no
+ * servir datos viejos es vaciar ambas cachés cuando esto pasa.
+ *
* @param recipe Receta a guardar.
* @return Receta guardada.
*/
@@ -62,6 +78,13 @@ public List getAllRecipes() {
/**
* Busca una receta por su ID.
+ *
+ * TODO (nivel 11): agrega @Cacheable(value = CacheConfig.RECIPE_BY_ID_CACHE, key = "#id")
+ * encima de este método. El "key" es lo que distingue una entrada de
+ * caché de otra — sin él, Spring cachearía UN solo resultado para
+ * cualquier id (el primero que le pidas), que es justo el bug que no
+ * quieres.
+ *
* @param id Identificador de la receta.
* @return Optional con la receta encontrada o vacío si no existe.
*/
@@ -71,6 +94,11 @@ public Optional getRecipeById(String id) {
/**
* Elimina una receta por su ID.
+ *
+ * TODO (nivel 11): agrega
+ * @CacheEvict(value = {CacheConfig.RECIPE_BY_ID_CACHE, CacheConfig.RECIPE_COUNT_CACHE}, allEntries = true)
+ * encima de este método (mismo motivo que en saveRecipe).
+ *
* @param id Identificador de la receta a eliminar.
*/
public void deleteRecipe(String id) {
@@ -142,6 +170,12 @@ public RecipeDTO toDTO(Recipe recipe) {
/**
* Busca una receta por su nombre y la actualiza.
+ *
+ * TODO (nivel 11): agrega @CacheEvict(value = CacheConfig.RECIPE_BY_ID_CACHE, allEntries = true)
+ * encima de este método. (El conteo total no cambia con un update, así
+ * que a diferencia de saveRecipe/deleteRecipe aquí solo hace falta
+ * vaciar la caché de recetas por id, no la de conteo.)
+ *
* @param name Nombre de la receta a buscar.
* @param updatedRecipe Receta actualizada.
* @return Receta actualizada o null si no se encontró.
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index d2d7bfa..de9fb80 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -17,3 +17,19 @@ cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000}
# curioso, no tanto para un script en bucle infinito.
ratelimit.max-requests=${RATE_LIMIT_MAX_REQUESTS:60}
ratelimit.window-seconds=${RATE_LIMIT_WINDOW_SECONDS:60}
+
+# Nivel 10 - Autenticacion: un unico usuario "demo" (en un caso real esto
+# seria una tabla de usuarios con contrasenas hasheadas en la base de datos,
+# aqui simplificamos a un usuario para enfocarnos en el mecanismo de JWT) y
+# la clave con la que se firman los tokens. NUNCA reutilices el valor por
+# defecto de jwt.secret en produccion - cambialo por variable de entorno.
+auth.demo-username=${AUTH_DEMO_USERNAME:admin}
+auth.demo-password=${AUTH_DEMO_PASSWORD:changeme123}
+jwt.secret=${JWT_SECRET:cambia-esta-clave-en-produccion-es-solo-para-desarrollo-local-1234}
+jwt.expiration-minutes=${JWT_EXPIRATION_MINUTES:30}
+
+# Nivel 12 - Observabilidad: endpoints de Actuator expuestos publicamente.
+# Solo health/info/metrics - nunca expongas "env" o "beans" sin proteger,
+# revelan variables de entorno y el arbol de dependencias completo.
+management.endpoints.web.exposure.include=health,info,metrics
+management.endpoint.health.show-details=always
diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java
new file mode 100644
index 0000000..10c160c
--- /dev/null
+++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/auth/JwtServiceTest.java
@@ -0,0 +1,99 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.auth;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * NIVEL 10 - Corrector de autenticación JWT (docs/curso/10-autenticacion).
+ *
+ * No necesitas modificar este archivo. Prueba JwtService directamente (sin
+ * levantar un servidor HTTP ni Spring Security), igual que RateLimitFilter
+ * en el nivel 8.
+ */
+class JwtServiceTest {
+
+ private static final String SECRET = "clave-de-pruebas-que-tiene-mas-de-32-caracteres-para-hs256";
+
+ @Test
+ @Tag("nivel10")
+ @DisplayName("Debe generar un token no nulo con tres partes (header.payload.firma)")
+ void testGenerateToken_producesNonNullToken() {
+ JwtService jwtService = new JwtService(SECRET, 30);
+
+ String token = jwtService.generateToken("ana");
+
+ assertNotNull(token);
+ assertNotEquals("", token.trim());
+ // Un JWT compacto siempre tiene exactamente 3 partes separadas por ".".
+ assertEquals(3, token.split("\\.").length);
+ }
+
+ @Test
+ @Tag("nivel10")
+ @DisplayName("Debe generar tokens distintos para usuarios distintos")
+ void testGenerateToken_differentUsers_produceDifferentTokens() {
+ JwtService jwtService = new JwtService(SECRET, 30);
+
+ String tokenAna = jwtService.generateToken("ana");
+ String tokenLuis = jwtService.generateToken("luis");
+
+ assertNotEquals(tokenAna, tokenLuis);
+ }
+
+ @Test
+ @Tag("nivel10")
+ @DisplayName("Debe validar un token recien generado y devolver el username original")
+ void testValidateAndGetUsername_validToken_returnsUsername() {
+ JwtService jwtService = new JwtService(SECRET, 30);
+
+ String token = jwtService.generateToken("chef123");
+ String username = jwtService.validateAndGetUsername(token);
+
+ assertEquals("chef123", username);
+ }
+
+ @Test
+ @Tag("nivel10")
+ @DisplayName("Debe devolver null para un token invalido o corrupto")
+ void testValidateAndGetUsername_garbageToken_returnsNull() {
+ JwtService jwtService = new JwtService(SECRET, 30);
+
+ String username = jwtService.validateAndGetUsername("esto-no-es-un-jwt-valido");
+
+ assertNull(username);
+ }
+
+ @Test
+ @Tag("nivel10")
+ @DisplayName("Debe devolver null para un token firmado con otra clave")
+ void testValidateAndGetUsername_wrongSignature_returnsNull() {
+ JwtService issuer = new JwtService(SECRET, 30);
+ JwtService verifier = new JwtService("otra-clave-completamente-distinta-de-mas-de-32-caracteres", 30);
+
+ String token = issuer.generateToken("ana");
+ String username = verifier.validateAndGetUsername(token);
+
+ assertNull(username);
+ }
+
+ @Test
+ @Tag("nivel10")
+ @DisplayName("Debe devolver null para un token expirado")
+ void testValidateAndGetUsername_expiredToken_returnsNull() throws InterruptedException {
+ // expiration-minutes = 0: el token nace expirado.
+ JwtService jwtService = new JwtService(SECRET, 0);
+
+ String token = jwtService.generateToken("ana");
+ Thread.sleep(5); // asegura que el reloj avance mas alla de la expiracion
+
+ String username = jwtService.validateAndGetUsername(token);
+
+ assertNull(username);
+ }
+}
diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java
new file mode 100644
index 0000000..65ce711
--- /dev/null
+++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/filter/RequestLoggingFilterTest.java
@@ -0,0 +1,74 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.filter;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * NIVEL 12 - Corrector de request id (docs/curso/12-observabilidad).
+ *
+ * No necesitas modificar este archivo. Prueba
+ * RequestLoggingFilter#resolveRequestId(...) directamente (sin levantar un
+ * servidor HTTP), con un MockHttpServletRequest de Spring Test.
+ */
+class RequestLoggingFilterTest {
+
+ private final RequestLoggingFilter filter = new RequestLoggingFilter();
+
+ @Test
+ @Tag("nivel12")
+ @DisplayName("Debe reutilizar el request id si el cliente ya mando uno")
+ void testResolveRequestId_reusesIncomingHeader() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.addHeader("X-Request-Id", "id-del-cliente-123");
+
+ String requestId = filter.resolveRequestId(request);
+
+ assertEquals("id-del-cliente-123", requestId);
+ }
+
+ @Test
+ @Tag("nivel12")
+ @DisplayName("Debe generar un request id cuando el cliente no manda ninguno")
+ void testResolveRequestId_generatesWhenMissing() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+
+ String requestId = filter.resolveRequestId(request);
+
+ assertNotNull(requestId);
+ assertFalse(requestId.trim().isEmpty());
+ }
+
+ @Test
+ @Tag("nivel12")
+ @DisplayName("Debe generar request ids distintos en llamadas distintas sin header")
+ void testResolveRequestId_generatesUniqueIds() {
+ MockHttpServletRequest requestA = new MockHttpServletRequest();
+ MockHttpServletRequest requestB = new MockHttpServletRequest();
+
+ String idA = filter.resolveRequestId(requestA);
+ String idB = filter.resolveRequestId(requestB);
+
+ assertNotEquals(idA, idB);
+ }
+
+ @Test
+ @Tag("nivel12")
+ @DisplayName("Debe ignorar un header X-Request-Id vacio y generar uno nuevo")
+ void testResolveRequestId_blankHeader_generatesNew() {
+ MockHttpServletRequest request = new MockHttpServletRequest();
+ request.addHeader("X-Request-Id", " ");
+
+ String requestId = filter.resolveRequestId(request);
+
+ assertTrue(requestId != null && !requestId.trim().isEmpty());
+ assertNotEquals(" ", requestId);
+ }
+}
diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java
new file mode 100644
index 0000000..b8ddc50
--- /dev/null
+++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/health/RecipeDatabaseHealthIndicatorTest.java
@@ -0,0 +1,67 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.health;
+
+import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.repository.RecipeRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.Status;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * NIVEL 12 - Corrector del health check personalizado
+ * (docs/curso/12-observabilidad). No necesitas modificar este archivo.
+ *
+ * Llama a health() directamente, sin levantar Actuator ni un servidor HTTP
+ * real — mismo espíritu de aislamiento que los correctores de los niveles
+ * 8 y 10.
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Health check de recetas - Nivel 12")
+class RecipeDatabaseHealthIndicatorTest {
+
+ @Mock
+ private RecipeRepository recipeRepository;
+
+ @InjectMocks
+ private RecipeDatabaseHealthIndicator healthIndicator;
+
+ @Test
+ @Tag("nivel12")
+ @DisplayName("Debe reportar UP y el conteo de recetas cuando el repositorio responde")
+ void testHealth_repositoryResponds_reportsUp() {
+ when(recipeRepository.count()).thenReturn(7L);
+
+ Health health = healthIndicator.health();
+
+ assertEquals(Status.UP, health.getStatus());
+ assertEquals(7L, health.getDetails().get("recipes"));
+ }
+
+ @Test
+ @Tag("nivel12")
+ @DisplayName("Debe reportar DOWN cuando el repositorio lanza una excepcion")
+ void testHealth_repositoryFails_reportsDown() {
+ // lenient(): antes de completar el TODO, doHealthCheck lanza
+ // UnsupportedOperationException sin siquiera llegar a llamar
+ // recipeRepository.count() - con stubbing estricto Mockito
+ // marcaria ese stub como "no usado" y fallaria el test con un
+ // error confuso en vez de la razon real (que count() nunca se
+ // llamo). El verify(...) de abajo es quien realmente exige que tu
+ // implementacion haya intentado consultar el repositorio.
+ lenient().when(recipeRepository.count()).thenThrow(new RuntimeException("mongo no responde"));
+
+ Health health = healthIndicator.health();
+
+ assertEquals(Status.DOWN, health.getStatus());
+ verify(recipeRepository).count();
+ }
+}
diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java
new file mode 100644
index 0000000..0f9fd43
--- /dev/null
+++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceCachingTest.java
@@ -0,0 +1,203 @@
+package edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.service;
+
+import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.model.Recipe;
+import edu.dosw.proyect.API_de_Gestion_de_Recetas_DOSW_Company.repository.RecipeRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * NIVEL 11 - Corrector de caching (docs/curso/11-caching).
+ *
+ * No necesitas modificar este archivo. A diferencia de los demás
+ * correctores de este curso, este SÍ levanta un mini contexto de Spring
+ * (sin base de datos real, sin servidor HTTP) porque el caching de Spring
+ * funciona mediante un proxy que envuelve el bean — sin ese proxy no hay
+ * forma de observar el efecto de @Cacheable/@CacheEvict.
+ *
+ * La estrategia de cada prueba es siempre la misma: llamar dos veces al
+ * mismo método de RecipeService con los mismos argumentos y verificar
+ * cuántas veces llegó realmente hasta RecipeRepository (el mock). Si tu
+ * caché funciona, la segunda llamada nunca debería tocar el repositorio.
+ */
+class RecipeServiceCachingTest {
+
+ /** Contexto minimo: solo lo necesario para que @Cacheable/@CacheEvict funcionen. */
+ @Configuration
+ @EnableCaching
+ static class CachingTestConfig {
+ @Bean
+ CacheManager cacheManager() {
+ return new ConcurrentMapCacheManager("recipeById", "recipeCount");
+ }
+
+ @Bean
+ RecipeRepository recipeRepository() {
+ return mock(RecipeRepository.class);
+ }
+
+ @Bean
+ RecipeService recipeService() {
+ return new RecipeService();
+ }
+ }
+
+ private AnnotationConfigApplicationContext context;
+ private RecipeRepository recipeRepository;
+ private RecipeService recipeService;
+
+ @BeforeEach
+ void setUp() {
+ context = new AnnotationConfigApplicationContext(CachingTestConfig.class);
+ recipeRepository = context.getBean(RecipeRepository.class);
+ recipeService = context.getBean(RecipeService.class);
+ }
+
+ @AfterEach
+ void tearDown() {
+ context.close();
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("getRecipeById no debe volver a consultar el repositorio para el mismo id")
+ void testGetRecipeById_secondCallHitsCache() {
+ Recipe recipe = new Recipe();
+ recipe.setId("id1");
+ when(recipeRepository.findById("id1")).thenReturn(Optional.of(recipe));
+
+ recipeService.getRecipeById("id1");
+ recipeService.getRecipeById("id1");
+ recipeService.getRecipeById("id1");
+
+ verify(recipeRepository, times(1)).findById("id1");
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("getRecipeById debe cachear cada id por separado")
+ void testGetRecipeById_isCachedPerId() {
+ Recipe recipeA = new Recipe();
+ recipeA.setId("a");
+ Recipe recipeB = new Recipe();
+ recipeB.setId("b");
+ when(recipeRepository.findById("a")).thenReturn(Optional.of(recipeA));
+ when(recipeRepository.findById("b")).thenReturn(Optional.of(recipeB));
+
+ recipeService.getRecipeById("a");
+ recipeService.getRecipeById("b");
+ recipeService.getRecipeById("a");
+ recipeService.getRecipeById("b");
+
+ verify(recipeRepository, times(1)).findById("a");
+ verify(recipeRepository, times(1)).findById("b");
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("countRecipes no debe volver a consultar el repositorio")
+ void testCountRecipes_secondCallHitsCache() {
+ when(recipeRepository.count()).thenReturn(5L);
+
+ recipeService.countRecipes();
+ recipeService.countRecipes();
+
+ verify(recipeRepository, times(1)).count();
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("saveRecipe debe invalidar la cache de getRecipeById")
+ void testSaveRecipe_evictsRecipeByIdCache() {
+ Recipe original = new Recipe();
+ original.setId("id1");
+ when(recipeRepository.findById("id1")).thenReturn(Optional.of(original));
+ when(recipeRepository.save(any(Recipe.class))).thenReturn(original);
+
+ recipeService.getRecipeById("id1"); // 1ra consulta real, queda en cache
+ recipeService.getRecipeById("id1"); // deberia venir de cache
+
+ recipeService.saveRecipe(new Recipe()); // deberia vaciar la cache
+
+ recipeService.getRecipeById("id1"); // deberia consultar de nuevo
+
+ verify(recipeRepository, times(2)).findById("id1");
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("deleteRecipe debe invalidar tanto la cache de getRecipeById como la de countRecipes")
+ void testDeleteRecipe_evictsBothCaches() {
+ Recipe original = new Recipe();
+ original.setId("id1");
+ when(recipeRepository.findById("id1")).thenReturn(Optional.of(original));
+ when(recipeRepository.count()).thenReturn(3L);
+
+ recipeService.getRecipeById("id1");
+ recipeService.countRecipes();
+
+ recipeService.deleteRecipe("id1");
+
+ recipeService.getRecipeById("id1");
+ recipeService.countRecipes();
+
+ verify(recipeRepository, times(2)).findById("id1");
+ verify(recipeRepository, times(2)).count();
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("updateRecipe debe invalidar la cache de getRecipeById")
+ void testUpdateRecipe_evictsRecipeByIdCache() {
+ Recipe original = new Recipe();
+ original.setId("id1");
+ original.setTitle("Torta");
+ when(recipeRepository.findById("id1")).thenReturn(Optional.of(original));
+ when(recipeRepository.findByTitle("Torta")).thenReturn(java.util.List.of(original));
+ when(recipeRepository.save(any(Recipe.class))).thenReturn(original);
+
+ recipeService.getRecipeById("id1");
+
+ Recipe updated = new Recipe();
+ updated.setTitle("Torta Actualizada");
+ recipeService.updateRecipe("Torta", updated);
+
+ recipeService.getRecipeById("id1");
+
+ verify(recipeRepository, times(2)).findById("id1");
+ }
+
+ @Test
+ @Tag("nivel11")
+ @DisplayName("Sanity check: el resultado devuelto sigue siendo correcto, cacheado o no")
+ void testGetRecipeById_returnsCorrectValueWhileCached() {
+ Recipe recipe = new Recipe();
+ recipe.setId("id1");
+ recipe.setTitle("Torta de chocolate");
+ when(recipeRepository.findById("id1")).thenReturn(Optional.of(recipe));
+
+ Optional first = recipeService.getRecipeById("id1");
+ Optional second = recipeService.getRecipeById("id1");
+
+ assertTrue(first.isPresent());
+ assertTrue(second.isPresent());
+ assertTrue(first.get().getTitle().equals(second.get().getTitle()));
+ }
+}
diff --git a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java
index dc328e5..8c5e083 100644
--- a/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java
+++ b/src/test/java/edu/dosw/proyect/API_de_Gestion_de_Recetas_DOSW_Company/service/RecipeServiceExtraTest.java
@@ -18,8 +18,10 @@
* NIVEL 5 - Pruebas unitarias propias (docs/curso/05-pruebas-unitarias).
*
* A diferencia de los demás tests del proyecto, ESTE ARCHIVO LO ESCRIBES TÚ.
- * countRecipes() y getRecipesPage(...) (que ya implementaste en los niveles
- * 2 y 7) no tienen ninguna prueba unitaria a nivel de RecipeService todavía.
+ * countRecipes() (que ya implementaste en el nivel 2) y el caso
+ * "no encontrado" de getRecipeById(...) no tienen ninguna prueba unitaria a
+ * nivel de RecipeService todavía — RecipeServiceTest.java solo cubre el
+ * caso en que la receta sí existe.
*
* Sigue el patrón AAA (Arrange-Act-Assert) que ya usa RecipeServiceTest.java
* en este mismo paquete:
@@ -63,16 +65,14 @@ void testCountRecipes_whenEmpty() {
}
/**
- * TODO nivel 5: prueba que getRecipesPage(page, size) delega en
- * recipeRepository.findAll(PageRequest.of(page, size)) y devuelve el
- * Page resultante. Pista: puedes usar
- * new org.springframework.data.domain.PageImpl<>(List.of(new Recipe()))
- * como valor de retorno simulado del repositorio.
+ * TODO nivel 5: prueba que getRecipeById(id) devuelve un Optional vacío
+ * cuando recipeRepository.findById(id) no encuentra nada (usa
+ * when(recipeRepository.findById(...)).thenReturn(Optional.empty())).
*/
@Test
@Tag("nivel5")
- void testGetRecipesPage() {
- fail("TODO nivel 5: implementa testGetRecipesPage");
+ void testGetRecipeById_notFound() {
+ fail("TODO nivel 5: implementa testGetRecipeById_notFound");
}
// Pista de datos de ejemplo por si la necesitas: