From 1fce5c622d5bf0ae1dc4ed7aac7ddbd58e5c2f46 Mon Sep 17 00:00:00 2001 From: Jose Nicolas Alvarez Mesa Date: Wed, 7 Oct 2026 19:28:19 -0500 Subject: [PATCH 1/4] [SCRUM-1123] Deja el esqueleto de pruebas unitarias del servicio Java - Pruebas de humo con JUnit 5: arranque del contexto de Spring y endpoint de salud (/health y /api/v1/rules/health). - JaCoCo 0.8.12 genera target/site/jacoco/jacoco.xml, la ruta que lee SonarQube por defecto. - Workflow CI: mvn verify en cada PR y push a main; publica el reporte de cobertura como artefacto. - README documenta como ejecutar pruebas y cobertura. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 39 +++++++++++++++++++ README.md | 13 +++++++ pom.xml | 21 ++++++++++ .../mani/rules/ManiRulesApplicationTests.java | 16 ++++++++ .../controller/RulesControllerSmokeTest.java | 38 ++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 src/test/java/com/trama/mani/rules/ManiRulesApplicationTests.java create mode 100644 src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1a3a21f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +# Ejecuta las pruebas unitarias y genera el reporte de cobertura JaCoCo (QA-06 / SCRUM-1123). +# El análisis de SonarQube y su Quality Gate se conectan a este reporte; el proyecto en +# SonarCloud y su token los crea DevOps (no se incluyen aquí). + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Pruebas (Java 17) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Test con cobertura (JaCoCo) + run: mvn -B verify + + - name: Publicar reporte de cobertura + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-jacoco + path: target/site/jacoco/ + if-no-files-found: warn diff --git a/README.md b/README.md index 467415d..9b59e48 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,19 @@ docker run -d -p 8080:8080 --name mani-rules mani-rules-java:local --- +## 🧪 Pruebas y cobertura + +Pruebas con **JUnit 5** (vía `spring-boot-starter-test`) y cobertura con **JaCoCo**. + +```bash +# Ejecutar pruebas y generar el reporte de cobertura +mvn verify +``` + +El reporte queda en `target/site/jacoco/` (HTML) y `target/site/jacoco/jacoco.xml`, la ruta que SonarQube lee por defecto. El workflow `CI` (`.github/workflows/ci.yml`) ejecuta `mvn -B verify` en cada Pull Request y en cada push a `main`, y publica el reporte como artefacto. Conectarlo al Quality Gate requiere el proyecto en SonarCloud y su token, que quedan pendientes de DevOps. + +--- + ## 👥 Equipo y Gobernanza * **Organización:** [TRAMA · Ingeniería de Software](https://github.com/Trama-AS) * **Repositorio Oficial:** [MANI-Java](https://github.com/Trama-AS/MANI-Java) diff --git a/pom.xml b/pom.xml index c13644d..28b327e 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,27 @@ org.springframework.boot spring-boot-maven-plugin + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + diff --git a/src/test/java/com/trama/mani/rules/ManiRulesApplicationTests.java b/src/test/java/com/trama/mani/rules/ManiRulesApplicationTests.java new file mode 100644 index 0000000..1b99939 --- /dev/null +++ b/src/test/java/com/trama/mani/rules/ManiRulesApplicationTests.java @@ -0,0 +1,16 @@ +package com.trama.mani.rules; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +/** + * Prueba de humo (QA-06 / SCRUM-1123): el contexto de Spring arranca completo. + * Si una configuración o un bean futuro rompe el arranque, falla aquí primero. + */ +@SpringBootTest +class ManiRulesApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java b/src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java new file mode 100644 index 0000000..8f69b7e --- /dev/null +++ b/src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java @@ -0,0 +1,38 @@ +package com.trama.mani.rules.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Prueba de humo (QA-06 / SCRUM-1123) de la capa web. Cuando lleguen las reglas reales de + * tarifa y el ranking, sus pruebas se suman en este paquete. + */ +@WebMvcTest(RulesController.class) +class RulesControllerSmokeTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void healthRespondsUpAndEchoesCorrelationId() throws Exception { + mockMvc.perform(get("/health").header("X-Correlation-ID", "corr-123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.service").value("MANI-Rules-Java")) + .andExpect(jsonPath("$.correlationId").value("corr-123")); + } + + @Test + void gatewayHealthPathRespondsUp() throws Exception { + mockMvc.perform(get("/api/v1/rules/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.correlationId").value("none")); + } +} From 2361f566a6e027433056bd2cd8483a281226526d Mon Sep 17 00:00:00 2001 From: Juan Sebastian Alvarez Date: Fri, 16 Oct 2026 12:22:51 -0500 Subject: [PATCH 2/4] [SCRUM-1225] Esqueleto del Rules Service: capas, correlation ID, errores uniformes y rutas del Gateway - CorrelationIdFilter: X-Correlation-ID al MDC (patron de logs ya lo leia) y a la respuesta; "none" si falta, igual que Core. - GlobalExceptionHandler + ApiError: forma {error, code, correlationId} comun con Core para 400/404/405/415/500. - HealthController separado; RulesController responde con y sin el prefijo /api/v1/rules porque el Gateway lo recorta antes de reenviar (antes /api/v1/rules/evaluate-rate llegaba como /evaluate-rate y daba 404). - Quita @CrossOrigin("*"): CORS es responsabilidad unica del Gateway. - CI tambien en push a develop. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- CLAUDE.md | 4 ++ .../rules/controller/HealthController.java | 22 ++++++ .../rules/controller/RulesController.java | 60 +++++++---------- .../com/trama/mani/rules/dto/ApiError.java | 5 ++ .../trama/mani/rules/web/ApiException.java | 24 +++++++ .../mani/rules/web/CorrelationIdFilter.java | 46 +++++++++++++ .../rules/web/GlobalExceptionHandler.java | 64 ++++++++++++++++++ ...st.java => HealthControllerSmokeTest.java} | 4 +- .../trama/mani/rules/web/WebSkeletonTest.java | 67 +++++++++++++++++++ 10 files changed, 259 insertions(+), 39 deletions(-) create mode 100644 src/main/java/com/trama/mani/rules/controller/HealthController.java create mode 100644 src/main/java/com/trama/mani/rules/dto/ApiError.java create mode 100644 src/main/java/com/trama/mani/rules/web/ApiException.java create mode 100644 src/main/java/com/trama/mani/rules/web/CorrelationIdFilter.java create mode 100644 src/main/java/com/trama/mani/rules/web/GlobalExceptionHandler.java rename src/test/java/com/trama/mani/rules/controller/{RulesControllerSmokeTest.java => HealthControllerSmokeTest.java} (95%) create mode 100644 src/test/java/com/trama/mani/rules/web/WebSkeletonTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a3a21f..dd10a6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: permissions: diff --git a/CLAUDE.md b/CLAUDE.md index 8d0c5fc..54aa8ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,5 +16,9 @@ Este documento sintetiza las reglas de arquitectura y patrones de código para t - `com.trama.mani.rules.controller`: Exposición de endpoints REST. - `com.trama.mani.rules.service`: Algoritmos de cálculo de tarifas y evaluación de condiciones. - `com.trama.mani.rules.dto`: Objetos de transferencia de datos de entrada/salida. + - `com.trama.mani.rules.config`: Parámetros de reglas externalizados (`@ConfigurationProperties`), por tenant. + - `com.trama.mani.rules.web`: Transversales HTTP (Correlation ID, contexto de tenant, manejo uniforme de errores `{error, code, correlationId}`). +* **Rutas:** el Gateway recorta `/api/v1/rules` antes de reenviar; todo endpoint responde con y sin ese prefijo (`@RequestMapping({"/api/v1/rules", ""})`). +* **CORS:** lo maneja únicamente `MANI-APIGateway`; no usar `@CrossOrigin`. * **Inmutabilidad y Pureza:** Los métodos de cálculo de reglas deben ser en lo posible funciones deterministas (mismas entradas generan el mismo resultado de cálculo). * **Logging:** Propagar en los logs el encabezado `X-Correlation-ID` recibido. diff --git a/src/main/java/com/trama/mani/rules/controller/HealthController.java b/src/main/java/com/trama/mani/rules/controller/HealthController.java new file mode 100644 index 0000000..3280b24 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/controller/HealthController.java @@ -0,0 +1,22 @@ +package com.trama.mani.rules.controller; + +import com.trama.mani.rules.web.CorrelationIdFilter; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + + @GetMapping({"/health", "/api/v1/rules/health"}) + public Map health() { + Map body = new LinkedHashMap<>(); + body.put("status", "UP"); + body.put("service", "MANI-Rules-Java"); + body.put("timestamp", Instant.now().toString()); + body.put("correlationId", CorrelationIdFilter.current()); + return body; + } +} diff --git a/src/main/java/com/trama/mani/rules/controller/RulesController.java b/src/main/java/com/trama/mani/rules/controller/RulesController.java index e59917e..c0224c6 100644 --- a/src/main/java/com/trama/mani/rules/controller/RulesController.java +++ b/src/main/java/com/trama/mani/rules/controller/RulesController.java @@ -1,33 +1,28 @@ package com.trama.mani.rules.controller; +import com.trama.mani.rules.web.CorrelationIdFilter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.time.Instant; -import java.util.*; - +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; + +/** + * Endpoints de tarifa (RF-16/RF-22) y ranking (RF-13) aún simulados; se implementan en sus + * propias historias. El Gateway recorta "/api/v1/rules" antes de reenviar (nginx.conf), así que + * cada ruta responde con y sin ese prefijo; Core la llama completa por la red interna. + * CORS lo resuelve únicamente el Gateway. + */ @RestController -@CrossOrigin(origins = "*") +@RequestMapping({"/api/v1/rules", ""}) public class RulesController { - // 1. Healthcheck - @GetMapping({"/health", "/api/v1/rules/health"}) - public ResponseEntity> healthCheck( - @RequestHeader(value = "X-Correlation-ID", required = false, defaultValue = "none") String correlationId) { - Map response = new HashMap<>(); - response.put("status", "UP"); - response.put("service", "MANI-Rules-Java"); - response.put("timestamp", Instant.now().toString()); - response.put("correlationId", correlationId); - return ResponseEntity.ok(response); - } - - // 2. Evaluación de tarifas (RF-16 / RF-22) - @PostMapping("/api/v1/rules/evaluate-rate") - public ResponseEntity> evaluateRate( - @RequestBody(required = false) Map request, - @RequestHeader(value = "X-Correlation-ID", required = false, defaultValue = "none") String correlationId) { - + @PostMapping("/evaluate-rate") + public ResponseEntity> evaluateRate(@RequestBody(required = false) Map request) { double basePrice = 35000.0; if (request != null && request.containsKey("basePrice")) { basePrice = Double.parseDouble(request.get("basePrice").toString()); @@ -39,36 +34,29 @@ public ResponseEntity> evaluateRate( } double surcharge = isWeekend ? 5000.0 : 0.0; - double finalPrice = basePrice + surcharge; Map response = new HashMap<>(); response.put("service", "MANI-Rules-Java"); - response.put("correlationId", correlationId); + response.put("correlationId", CorrelationIdFilter.current()); response.put("basePrice", basePrice); response.put("surcharge", surcharge); - response.put("finalPrice", finalPrice); + response.put("finalPrice", basePrice + surcharge); response.put("ruleApplied", isWeekend ? "REGLA-FIN-DE-SEMANA (+5000)" : "TARIFA-ESTANDAR"); response.put("status", "APPLIED"); - return ResponseEntity.ok(response); } - // 3. Ranking de aliados para asignación (RF-13) - @PostMapping("/api/v1/rules/rank-allies") - public ResponseEntity> rankAllies( - @RequestBody(required = false) Map request, - @RequestHeader(value = "X-Correlation-ID", required = false, defaultValue = "none") String correlationId) { - + @PostMapping("/rank-allies") + public ResponseEntity> rankAllies(@RequestBody(required = false) Map request) { List> mockAllies = new ArrayList<>(); mockAllies.add(Map.of("id", "aliado-1", "name", "Carolina Gómez", "rating", 4.9, "score", 98)); mockAllies.add(Map.of("id", "aliado-2", "name", "Paola Morales", "rating", 4.7, "score", 92)); mockAllies.add(Map.of("id", "aliado-3", "name", "Sandra Rivas", "rating", 4.5, "score", 85)); Map response = new HashMap<>(); - response.put("correlationId", correlationId); + response.put("correlationId", CorrelationIdFilter.current()); response.put("rankedAllies", mockAllies); response.put("criteria", "CALIFICACION_Y_EXPERIENCIA"); - return ResponseEntity.ok(response); } } diff --git a/src/main/java/com/trama/mani/rules/dto/ApiError.java b/src/main/java/com/trama/mani/rules/dto/ApiError.java new file mode 100644 index 0000000..adba6bc --- /dev/null +++ b/src/main/java/com/trama/mani/rules/dto/ApiError.java @@ -0,0 +1,5 @@ +package com.trama.mani.rules.dto; + +/** Forma de error común con Core Node: { error, code, correlationId }. */ +public record ApiError(String error, String code, String correlationId) { +} diff --git a/src/main/java/com/trama/mani/rules/web/ApiException.java b/src/main/java/com/trama/mani/rules/web/ApiException.java new file mode 100644 index 0000000..602992e --- /dev/null +++ b/src/main/java/com/trama/mani/rules/web/ApiException.java @@ -0,0 +1,24 @@ +package com.trama.mani.rules.web; + +import org.springframework.http.HttpStatus; + +/** Error de negocio o de entrada con estado HTTP y código estable para los consumidores. */ +public class ApiException extends RuntimeException { + + private final HttpStatus status; + private final String code; + + public ApiException(HttpStatus status, String code, String message) { + super(message); + this.status = status; + this.code = code; + } + + public HttpStatus getStatus() { + return status; + } + + public String getCode() { + return code; + } +} diff --git a/src/main/java/com/trama/mani/rules/web/CorrelationIdFilter.java b/src/main/java/com/trama/mani/rules/web/CorrelationIdFilter.java new file mode 100644 index 0000000..e9c173e --- /dev/null +++ b/src/main/java/com/trama/mani/rules/web/CorrelationIdFilter.java @@ -0,0 +1,46 @@ +package com.trama.mani.rules.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Propaga X-Correlation-ID (lo genera el Gateway o lo reenvía Core) al MDC de los logs y a la + * respuesta. Sin encabezado se usa "none", el mismo valor que Core Node. + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class CorrelationIdFilter extends OncePerRequestFilter { + + public static final String HEADER = "X-Correlation-ID"; + public static final String MDC_KEY = HEADER; + static final String MISSING = "none"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String header = request.getHeader(HEADER); + String correlationId = StringUtils.hasText(header) ? header.trim() : MISSING; + + MDC.put(MDC_KEY, correlationId); + response.setHeader(HEADER, correlationId); + try { + chain.doFilter(request, response); + } finally { + MDC.remove(MDC_KEY); + } + } + + public static String current() { + String value = MDC.get(MDC_KEY); + return value != null ? value : MISSING; + } +} diff --git a/src/main/java/com/trama/mani/rules/web/GlobalExceptionHandler.java b/src/main/java/com/trama/mani/rules/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..a3946a1 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/web/GlobalExceptionHandler.java @@ -0,0 +1,64 @@ +package com.trama.mani.rules.web; + +import com.trama.mani.rules.dto.ApiError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(ApiException.class) + public ResponseEntity handleApi(ApiException ex) { + return build(ex.getStatus(), ex.getCode(), ex.getMessage()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadable(HttpMessageNotReadableException ex) { + return build(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "El cuerpo de la petición no es un JSON válido"); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleInvalid(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(e -> e.getField() + ": " + e.getDefaultMessage()) + .findFirst() + .orElse("Datos de entrada inválidos"); + return build(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", message); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleMediaType(HttpMediaTypeNotSupportedException ex) { + return build(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "VALIDATION_ERROR", "Content-Type debe ser application/json"); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethod(HttpRequestMethodNotSupportedException ex) { + return build(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", ex.getMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNotFound(NoResourceFoundException ex) { + return build(HttpStatus.NOT_FOUND, "NOT_FOUND", "Ruta no encontrada en MANI-Rules-Java"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception ex) { + log.error("Error no controlado en Rules", ex); + return build(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "Error interno del motor de reglas"); + } + + private static ResponseEntity build(HttpStatus status, String code, String message) { + return ResponseEntity.status(status).body(new ApiError(message, code, CorrelationIdFilter.current())); + } +} diff --git a/src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java b/src/test/java/com/trama/mani/rules/controller/HealthControllerSmokeTest.java similarity index 95% rename from src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java rename to src/test/java/com/trama/mani/rules/controller/HealthControllerSmokeTest.java index 8f69b7e..6603682 100644 --- a/src/test/java/com/trama/mani/rules/controller/RulesControllerSmokeTest.java +++ b/src/test/java/com/trama/mani/rules/controller/HealthControllerSmokeTest.java @@ -13,8 +13,8 @@ * Prueba de humo (QA-06 / SCRUM-1123) de la capa web. Cuando lleguen las reglas reales de * tarifa y el ranking, sus pruebas se suman en este paquete. */ -@WebMvcTest(RulesController.class) -class RulesControllerSmokeTest { +@WebMvcTest(HealthController.class) +class HealthControllerSmokeTest { @Autowired private MockMvc mockMvc; diff --git a/src/test/java/com/trama/mani/rules/web/WebSkeletonTest.java b/src/test/java/com/trama/mani/rules/web/WebSkeletonTest.java new file mode 100644 index 0000000..cab0f02 --- /dev/null +++ b/src/test/java/com/trama/mani/rules/web/WebSkeletonTest.java @@ -0,0 +1,67 @@ +package com.trama.mani.rules.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.trama.mani.rules.controller.HealthController; +import com.trama.mani.rules.controller.RulesController; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest({HealthController.class, RulesController.class}) +class WebSkeletonTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void echoesCorrelationIdInResponseHeader() throws Exception { + mockMvc.perform(get("/health").header("X-Correlation-ID", "corr-abc")) + .andExpect(header().string("X-Correlation-ID", "corr-abc")); + } + + @Test + void doesNotAddCorsHeadersBecauseTheGatewayOwnsCors() throws Exception { + mockMvc.perform(get("/health").header("Origin", "http://evil.test")) + .andExpect(status().isOk()) + .andExpect(header().doesNotExist("Access-Control-Allow-Origin")); + } + + @Test + void answersTheSameRouteWithAndWithoutTheGatewayPrefix() throws Exception { + mockMvc.perform(post("/evaluate-rate").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/rules/evaluate-rate").contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(status().isOk()); + } + + @Test + void malformedJsonReturnsUniformValidationError() throws Exception { + mockMvc.perform(post("/evaluate-rate").header("X-Correlation-ID", "corr-json") + .contentType(MediaType.APPLICATION_JSON).content("{no-json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.correlationId").value("corr-json")); + } + + @Test + void unknownRouteReturnsUniformNotFound() throws Exception { + mockMvc.perform(get("/api/v1/rules/no-existe")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")) + .andExpect(jsonPath("$.correlationId").value("none")); + } + + @Test + void wrongMethodReturnsUniformMethodNotAllowed() throws Exception { + mockMvc.perform(get("/evaluate-rate")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.code").value("METHOD_NOT_ALLOWED")); + } +} From 68db99e3eadd2e048cb8f0a92943b93b7ad07f14 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Alvarez Date: Fri, 16 Oct 2026 12:26:16 -0500 Subject: [PATCH 3/4] [SCRUM-1226] Implementa la evaluacion de reglas de categoria en el Rules Service POST /api/v1/rules/categories/evaluate (tambien sin prefijo, por el Gateway) con el contrato declarado por Core en US-03.1.1-M2.2: { allowed, ruleSetVersion, violations[] } + normalized. - CategoryRuleEvaluator: funcion pura con las reglas de crear_categoria_servicio() (migracion 003): nombre normalizado 3-60 con letra (MANI-CAT-422N), flujo operativo permitido (MANI-CAT-422F); UPDATE parcial; OPERATE exige categoria activa (MANI-CAT-422C). - Tenant solo desde X-Tenant-Id (claim del Gateway/Core); tenantId del cuerpo distinto => 403 TENANT_MISMATCH. CREATE/UPDATE exigen rol ADMIN. - CategoryRulesProperties: parametros en mani.rules.category.* con override por tenant y version trazable; configuracion incoherente falla el arranque. - El 409 por nombre duplicado sigue en el indice unico (requiere estado). Co-Authored-By: Claude Opus 5.5 --- README.md | 31 ++++ pom.xml | 4 + .../rules/config/CategoryRulesProperties.java | 138 ++++++++++++++++++ .../rules/config/EffectiveCategoryRules.java | 12 ++ .../mani/rules/config/RulesConfiguration.java | 9 ++ .../controller/CategoryRulesController.java | 89 +++++++++++ .../rules/dto/CategoryEvaluationRequest.java | 29 ++++ .../rules/dto/CategoryEvaluationResponse.java | 22 +++ .../trama/mani/rules/dto/RuleViolation.java | 8 + .../mani/rules/service/CategoryOperation.java | 29 ++++ .../rules/service/CategoryRuleEvaluator.java | 97 ++++++++++++ src/main/resources/application.properties | 13 ++ 12 files changed, 481 insertions(+) create mode 100644 src/main/java/com/trama/mani/rules/config/CategoryRulesProperties.java create mode 100644 src/main/java/com/trama/mani/rules/config/EffectiveCategoryRules.java create mode 100644 src/main/java/com/trama/mani/rules/config/RulesConfiguration.java create mode 100644 src/main/java/com/trama/mani/rules/controller/CategoryRulesController.java create mode 100644 src/main/java/com/trama/mani/rules/dto/CategoryEvaluationRequest.java create mode 100644 src/main/java/com/trama/mani/rules/dto/CategoryEvaluationResponse.java create mode 100644 src/main/java/com/trama/mani/rules/dto/RuleViolation.java create mode 100644 src/main/java/com/trama/mani/rules/service/CategoryOperation.java create mode 100644 src/main/java/com/trama/mani/rules/service/CategoryRuleEvaluator.java diff --git a/README.md b/README.md index 9b59e48..2870d06 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,37 @@ flowchart LR | `GET` | `/api/v1/rules/health` | Verificación de estado del motor de reglas. | No | | `POST` | `/api/v1/rules/evaluate-rate` | Cálculo de precio final con desglose de recargos. | Sí (JSON) | | `POST` | `/api/v1/rules/rank-allies` | Ordenamiento y ponderación de aliados para servicio. | Opcional | +| `POST` | `/api/v1/rules/categories/evaluate` | Reglas operativas de la categoría de servicio (US-03.1.1-M6). | Sí (JSON) | + +> El Gateway recorta `/api/v1/rules` antes de reenviar: cada ruta también responde sin el prefijo. Core la invoca completa por la red interna (`RULES_SERVICE_URL`, p. ej. `http://rules-service:8080`). + +### Reglas de categoría (`POST /api/v1/rules/categories/evaluate`) + +Reemplaza las validaciones que vivían en `crear_categoria_servicio()` (migración 003, SCRUM-857) y en Core Node. Contrato publicado en `MANI-APIGateway/docs/openapi/rules.yaml`. + +**Encabezados** (los pone el Gateway tras validar el JWT, o Core al llamar internamente): `X-Tenant-Id` (obligatorio; el tenant **nunca** se toma del cuerpo), `X-User-Role` (obligatorio; `CREATE`/`UPDATE` exigen `ADMIN`), `X-Correlation-ID`. + +```json +{ "categoryId": null, "context": { "operation": "CREATE", "name": " Plomería Express ", "flujoOperativo": "tarifa_estandar" } } +``` + +**Respuesta `200`** (la regla incumplida es `allowed=false`, no un error HTTP): +```json +{ "allowed": true, "ruleSetVersion": "category-rules-v1", "violations": [], + "normalized": { "name": "Plomería Express", "flujoOperativo": "TARIFA_ESTANDAR" }, + "tenantId": "…", "categoryId": null, "operation": "CREATE", "correlationId": "…" } +``` + +| Operación | Reglas | Código | +| :--- | :--- | :--- | +| `CREATE` | nombre normalizado de 3 a 60 caracteres con al menos una letra | `MANI-CAT-422N` | +| `CREATE` | flujo operativo en `COTIZACION_PREVIA` / `TARIFA_ESTANDAR` | `MANI-CAT-422F` | +| `UPDATE` | las mismas, solo sobre los campos enviados | `422N` / `422F` | +| `OPERATE` | la categoría debe estar activa | `MANI-CAT-422C` | + +Errores: `400 VALIDATION_ERROR | TENANT_REQUIRED`, `401 UNAUTHORIZED`, `403 FORBIDDEN | TENANT_MISMATCH`. El nombre único por tenant (`409`) sigue en el índice `ux_categoria_tenant_nombre`, porque necesita estado. + +Los parámetros viven en `application.properties` (`mani.rules.category.*`) con override por tenant (`mani.rules.category.tenants[].*`): cambiarlos solo exige redesplegar Rules. Un flujo nuevo además requiere ampliar el `CHECK ck_categoria_flujo` en BD. ### Ejemplo: Evaluación de Tarifa (`POST /api/v1/rules/evaluate-rate`) **Petición:** diff --git a/pom.xml b/pom.xml index 28b327e..6980bf8 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/trama/mani/rules/config/CategoryRulesProperties.java b/src/main/java/com/trama/mani/rules/config/CategoryRulesProperties.java new file mode 100644 index 0000000..6767cb3 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/config/CategoryRulesProperties.java @@ -0,0 +1,138 @@ +package com.trama.mani.rules.config; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Parámetros de las reglas de categoría (US-03.1.1 / SCRUM-857), externalizados para cambiarlos + * redesplegando o reconfigurando solo el Rules Service, nunca Core (RF-02). + * + *
+ * mani.rules.category.defaults.version=category-rules-v1
+ * mani.rules.category.defaults.name-max-length=60
+ * mani.rules.category.tenants[<tenant-uuid>].name-max-length=80
+ * 
+ * + * Un tenant solo sobrescribe los campos que declara; el resto hereda de {@code defaults}. + */ +@ConfigurationProperties(prefix = "mani.rules.category") +public class CategoryRulesProperties implements InitializingBean { + + private RuleSet defaults = new RuleSet(); + private Map tenants = new HashMap<>(); + + public EffectiveCategoryRules forTenant(String tenantId) { + RuleSet override = tenants.get(tenantId); + if (override == null) { + return toEffective(defaults, defaults.getVersion()); + } + String version = override.getVersion() != null ? override.getVersion() : defaults.getVersion() + "/" + tenantId; + RuleSet merged = new RuleSet(); + merged.setNameMinLength(pick(override.getNameMinLength(), defaults.getNameMinLength())); + merged.setNameMaxLength(pick(override.getNameMaxLength(), defaults.getNameMaxLength())); + merged.setNameRequiresLetter(pick(override.getNameRequiresLetter(), defaults.getNameRequiresLetter())); + merged.setAllowedFlujos(override.getAllowedFlujos() != null ? override.getAllowedFlujos() : defaults.getAllowedFlujos()); + return toEffective(merged, version); + } + + /** Falla el arranque ante una configuración incoherente en vez de evaluar con ella. */ + @Override + public void afterPropertiesSet() { + if (defaults.getVersion() == null || defaults.getVersion().isBlank()) { + throw new IllegalStateException("mani.rules.category.defaults.version es obligatorio"); + } + forTenant(null); + tenants.keySet().forEach(this::forTenant); + } + + private static EffectiveCategoryRules toEffective(RuleSet set, String version) { + Integer min = set.getNameMinLength(); + Integer max = set.getNameMaxLength(); + if (min == null || max == null || min < 1 || max < min) { + throw new IllegalStateException( + "Longitudes de nombre inválidas en reglas de categoría (" + version + "): min=" + min + ", max=" + max); + } + List flujos = set.getAllowedFlujos() == null ? List.of() : set.getAllowedFlujos().stream() + .map(f -> f.trim().toUpperCase(Locale.ROOT)) + .filter(f -> !f.isEmpty()) + .toList(); + if (flujos.isEmpty()) { + throw new IllegalStateException("allowed-flujos no puede estar vacío en reglas de categoría (" + version + ")"); + } + boolean requiresLetter = set.getNameRequiresLetter() == null || set.getNameRequiresLetter(); + return new EffectiveCategoryRules(version, min, max, requiresLetter, flujos); + } + + private static T pick(T override, T fallback) { + return override != null ? override : fallback; + } + + public RuleSet getDefaults() { + return defaults; + } + + public void setDefaults(RuleSet defaults) { + this.defaults = defaults; + } + + public Map getTenants() { + return tenants; + } + + public void setTenants(Map tenants) { + this.tenants = tenants; + } + + public static class RuleSet { + private String version; + private Integer nameMinLength; + private Integer nameMaxLength; + private Boolean nameRequiresLetter; + // null (no lista vacía) para que un tenant que no lo declara herede el de defaults. + private List allowedFlujos; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public Integer getNameMinLength() { + return nameMinLength; + } + + public void setNameMinLength(Integer nameMinLength) { + this.nameMinLength = nameMinLength; + } + + public Integer getNameMaxLength() { + return nameMaxLength; + } + + public void setNameMaxLength(Integer nameMaxLength) { + this.nameMaxLength = nameMaxLength; + } + + public Boolean getNameRequiresLetter() { + return nameRequiresLetter; + } + + public void setNameRequiresLetter(Boolean nameRequiresLetter) { + this.nameRequiresLetter = nameRequiresLetter; + } + + public List getAllowedFlujos() { + return allowedFlujos; + } + + public void setAllowedFlujos(List allowedFlujos) { + this.allowedFlujos = allowedFlujos; + } + } +} diff --git a/src/main/java/com/trama/mani/rules/config/EffectiveCategoryRules.java b/src/main/java/com/trama/mani/rules/config/EffectiveCategoryRules.java new file mode 100644 index 0000000..e99226c --- /dev/null +++ b/src/main/java/com/trama/mani/rules/config/EffectiveCategoryRules.java @@ -0,0 +1,12 @@ +package com.trama.mani.rules.config; + +import java.util.List; + +/** Reglas de categoría ya resueltas para un tenant (defaults + su override). */ +public record EffectiveCategoryRules( + String version, + int nameMinLength, + int nameMaxLength, + boolean nameRequiresLetter, + List allowedFlujos) { +} diff --git a/src/main/java/com/trama/mani/rules/config/RulesConfiguration.java b/src/main/java/com/trama/mani/rules/config/RulesConfiguration.java new file mode 100644 index 0000000..b3e5a34 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/config/RulesConfiguration.java @@ -0,0 +1,9 @@ +package com.trama.mani.rules.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(CategoryRulesProperties.class) +public class RulesConfiguration { +} diff --git a/src/main/java/com/trama/mani/rules/controller/CategoryRulesController.java b/src/main/java/com/trama/mani/rules/controller/CategoryRulesController.java new file mode 100644 index 0000000..b9dbab5 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/controller/CategoryRulesController.java @@ -0,0 +1,89 @@ +package com.trama.mani.rules.controller; + +import com.trama.mani.rules.config.CategoryRulesProperties; +import com.trama.mani.rules.config.EffectiveCategoryRules; +import com.trama.mani.rules.dto.CategoryEvaluationRequest; +import com.trama.mani.rules.dto.CategoryEvaluationResponse; +import com.trama.mani.rules.dto.RuleViolation; +import com.trama.mani.rules.service.CategoryOperation; +import com.trama.mani.rules.service.CategoryRuleEvaluator; +import com.trama.mani.rules.web.ApiException; +import com.trama.mani.rules.web.CorrelationIdFilter; +import jakarta.validation.Valid; +import java.util.Locale; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Evaluación de reglas de categoría (US-03.1.1-M6 / SCRUM-1226). + * + *

Tenant y rol llegan como encabezados que pone quien ya validó el JWT: el Gateway + * (auth_request, nginx.conf) o Core al llamar por la red interna. Rules no es alcanzable desde + * fuera sin pasar por el Gateway. Una evaluación que no se cumple es un 200 con allowed=false: + * el error es de la categoría, no de la petición. + */ +@RestController +@RequestMapping({"/api/v1/rules", ""}) +public class CategoryRulesController { + + private static final Logger log = LoggerFactory.getLogger(CategoryRulesController.class); + private static final Set ADMIN_ROLES = Set.of("ADMIN", "ADMIN_TENANT"); + + private final CategoryRulesProperties properties; + private final CategoryRuleEvaluator evaluator; + + public CategoryRulesController(CategoryRulesProperties properties, CategoryRuleEvaluator evaluator) { + this.properties = properties; + this.evaluator = evaluator; + } + + @PostMapping("/categories/evaluate") + public CategoryEvaluationResponse evaluate( + @RequestHeader(value = "X-Tenant-Id", required = false) String tenantHeader, + @RequestHeader(value = "X-User-Role", required = false) String roleHeader, + @Valid @RequestBody CategoryEvaluationRequest request) { + + if (!StringUtils.hasText(roleHeader)) { + throw new ApiException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "Encabezado X-User-Role requerido"); + } + if (!StringUtils.hasText(tenantHeader)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "TENANT_REQUIRED", "Encabezado X-Tenant-Id requerido"); + } + String tenantId = tenantHeader.trim(); + if (request.tenantId() != null && !request.tenantId().trim().equals(tenantId)) { + throw new ApiException(HttpStatus.FORBIDDEN, "TENANT_MISMATCH", + "El tenantId del cuerpo no coincide con el tenant de la sesión"); + } + + CategoryOperation operation = CategoryOperation.parse(request.context().operation()) + .orElseThrow(() -> new ApiException(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", + "context.operation debe ser CREATE, UPDATE u OPERATE")); + if (operation == CategoryOperation.OPERATE && request.context().active() == null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", + "context.active es obligatorio para OPERATE"); + } + if (operation.requiresAdmin() && !ADMIN_ROLES.contains(roleHeader.trim().toUpperCase(Locale.ROOT))) { + throw new ApiException(HttpStatus.FORBIDDEN, "FORBIDDEN", + "Solo el administrador del tenant gestiona categorías"); + } + + EffectiveCategoryRules rules = properties.forTenant(tenantId); + CategoryRuleEvaluator.Result result = evaluator.evaluate(rules, operation, request.context().name(), + request.context().flujoOperativo(), request.context().active()); + + log.info("Reglas de categoría tenant={} categoria={} operacion={} version={} allowed={} violaciones={}", + tenantId, request.categoryId(), operation, rules.version(), result.allowed(), + result.violations().stream().map(RuleViolation::code).toList()); + + return new CategoryEvaluationResponse(result.allowed(), rules.version(), result.violations(), + result.normalized(), tenantId, request.categoryId(), operation.name(), CorrelationIdFilter.current()); + } +} diff --git a/src/main/java/com/trama/mani/rules/dto/CategoryEvaluationRequest.java b/src/main/java/com/trama/mani/rules/dto/CategoryEvaluationRequest.java new file mode 100644 index 0000000..fc421b1 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/dto/CategoryEvaluationRequest.java @@ -0,0 +1,29 @@ +package com.trama.mani.rules.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +/** + * Cuerpo de POST /api/v1/rules/categories/evaluate. Refleja el puerto que Core declaró en + * US-03.1.1-M2.2: evaluateCategoryRules({ tenantId, categoryId, context }). El tenant NO se toma + * de aquí sino de X-Tenant-Id (claim validado por el Gateway o reenviado por Core); si el cuerpo + * trae tenantId y no coincide, la petición se rechaza. + */ +public record CategoryEvaluationRequest( + String tenantId, + String categoryId, + @NotNull(message = "es obligatorio") @Valid Context context) { + + /** + * @param operation CREATE | UPDATE | OPERATE + * @param name nombre propuesto (CREATE obligatorio; UPDATE solo si cambia) + * @param flujoOperativo flujo propuesto (CREATE obligatorio; UPDATE solo si cambia) + * @param active estado actual de la categoría (obligatorio en OPERATE) + */ + public record Context( + @NotNull(message = "es obligatorio") String operation, + String name, + String flujoOperativo, + Boolean active) { + } +} diff --git a/src/main/java/com/trama/mani/rules/dto/CategoryEvaluationResponse.java b/src/main/java/com/trama/mani/rules/dto/CategoryEvaluationResponse.java new file mode 100644 index 0000000..11a9cff --- /dev/null +++ b/src/main/java/com/trama/mani/rules/dto/CategoryEvaluationResponse.java @@ -0,0 +1,22 @@ +package com.trama.mani.rules.dto; + +import java.util.List; + +/** + * allowed/ruleSetVersion/violations es exactamente la forma de RuleEvaluationResult en Core + * (US-03.1.1-M2.2). normalized trae los valores tal como deben persistirse (nombre sin espacios + * repetidos, flujo en mayúsculas), para que Core no reimplemente la normalización. + */ +public record CategoryEvaluationResponse( + boolean allowed, + String ruleSetVersion, + List violations, + Normalized normalized, + String tenantId, + String categoryId, + String operation, + String correlationId) { + + public record Normalized(String name, String flujoOperativo) { + } +} diff --git a/src/main/java/com/trama/mani/rules/dto/RuleViolation.java b/src/main/java/com/trama/mani/rules/dto/RuleViolation.java new file mode 100644 index 0000000..230c077 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/dto/RuleViolation.java @@ -0,0 +1,8 @@ +package com.trama.mani.rules.dto; + +/** + * Regla incumplida. Los códigos son los mismos que emitía crear_categoria_servicio() + * (migración 003): MANI-CAT-422N nombre, MANI-CAT-422F flujo operativo, MANI-CAT-422C no disponible. + */ +public record RuleViolation(String code, String field, String message) { +} diff --git a/src/main/java/com/trama/mani/rules/service/CategoryOperation.java b/src/main/java/com/trama/mani/rules/service/CategoryOperation.java new file mode 100644 index 0000000..840ac3d --- /dev/null +++ b/src/main/java/com/trama/mani/rules/service/CategoryOperation.java @@ -0,0 +1,29 @@ +package com.trama.mani.rules.service; + +import java.util.Locale; +import java.util.Optional; + +/** Momento del ciclo de vida de la categoría que se evalúa. */ +public enum CategoryOperation { + /** Backoffice crea la categoría: nombre y flujo obligatorios. */ + CREATE, + /** Backoffice edita: solo se evalúan los campos enviados (actualización parcial). */ + UPDATE, + /** La categoría se usa en la operación (p. ej. un aliado la declara): debe estar activa. */ + OPERATE; + + public boolean requiresAdmin() { + return this != OPERATE; + } + + public static Optional parse(String raw) { + if (raw == null) { + return Optional.empty(); + } + try { + return Optional.of(valueOf(raw.trim().toUpperCase(Locale.ROOT))); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/trama/mani/rules/service/CategoryRuleEvaluator.java b/src/main/java/com/trama/mani/rules/service/CategoryRuleEvaluator.java new file mode 100644 index 0000000..005bc01 --- /dev/null +++ b/src/main/java/com/trama/mani/rules/service/CategoryRuleEvaluator.java @@ -0,0 +1,97 @@ +package com.trama.mani.rules.service; + +import com.trama.mani.rules.config.EffectiveCategoryRules; +import com.trama.mani.rules.dto.CategoryEvaluationResponse.Normalized; +import com.trama.mani.rules.dto.RuleViolation; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +/** + * Reglas operativas de la categoría de servicio, migradas de crear_categoria_servicio() + * (MANI-APIGateway/database/migrations/003_categorias_servicio.sql, SCRUM-857) y de las + * validaciones equivalentes que tenía Core Node. Función pura: mismas entradas y mismas reglas + * producen siempre el mismo resultado; no consulta BD. + * + *

Fuera de alcance a propósito: el nombre único por tenant (MANI-CAT-409) necesita estado y + * lo sigue decidiendo el índice ux_categoria_tenant_nombre. + */ +@Service +public class CategoryRuleEvaluator { + + public static final String NAME_INVALID = "MANI-CAT-422N"; + public static final String FLUJO_INVALID = "MANI-CAT-422F"; + public static final String NOT_AVAILABLE = "MANI-CAT-422C"; + + private static final Pattern WHITESPACE = Pattern.compile("\\s+"); + private static final Pattern HAS_LETTER = Pattern.compile("\\p{L}"); + + public Result evaluate(EffectiveCategoryRules rules, CategoryOperation operation, String name, String flujo, + Boolean active) { + List violations = new ArrayList<>(); + String normalizedName = normalizeName(name); + String normalizedFlujo = normalizeFlujo(flujo); + + switch (operation) { + case CREATE -> { + checkName(rules, normalizedName, violations); + checkFlujo(rules, normalizedFlujo, violations); + } + case UPDATE -> { + if (name != null) { + checkName(rules, normalizedName, violations); + } + if (flujo != null) { + checkFlujo(rules, normalizedFlujo, violations); + } + } + case OPERATE -> { + if (!Boolean.TRUE.equals(active)) { + violations.add(new RuleViolation(NOT_AVAILABLE, "active", "la categoría está inactiva")); + } + } + } + + return new Result(violations.isEmpty(), List.copyOf(violations), new Normalized(normalizedName, normalizedFlujo)); + } + + private static void checkName(EffectiveCategoryRules rules, String name, List violations) { + if (name == null || name.isEmpty()) { + violations.add(new RuleViolation(NAME_INVALID, "name", "el nombre es obligatorio")); + return; + } + // char_length() de Postgres cuenta caracteres, no unidades UTF-16. + int length = name.codePointCount(0, name.length()); + if (length < rules.nameMinLength() || length > rules.nameMaxLength()) { + violations.add(new RuleViolation(NAME_INVALID, "name", "el nombre debe tener entre " + + rules.nameMinLength() + " y " + rules.nameMaxLength() + " caracteres")); + } + if (rules.nameRequiresLetter() && !HAS_LETTER.matcher(name).find()) { + violations.add(new RuleViolation(NAME_INVALID, "name", "el nombre debe contener al menos una letra")); + } + } + + private static void checkFlujo(EffectiveCategoryRules rules, String flujo, List violations) { + if (flujo == null || flujo.isEmpty()) { + violations.add(new RuleViolation(FLUJO_INVALID, "flujoOperativo", "el flujo operativo es obligatorio")); + return; + } + if (!rules.allowedFlujos().contains(flujo)) { + violations.add(new RuleViolation(FLUJO_INVALID, "flujoOperativo", "flujo operativo inválido (" + flujo + + "); permitidos: " + String.join(", ", rules.allowedFlujos()))); + } + } + + static String normalizeName(String name) { + return name == null ? null : WHITESPACE.matcher(name.strip()).replaceAll(" "); + } + + static String normalizeFlujo(String flujo) { + return flujo == null ? null : flujo.strip().toUpperCase(Locale.ROOT); + } + + public record Result(boolean allowed, List violations, Normalized normalized) { + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 366b1b6..e87ff69 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,3 +3,16 @@ spring.application.name=mani-rules-java # Formato de logging estructurado logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - [Correlation: %X{X-Correlation-ID}] %msg%n + +# Reglas de categoria de servicio (US-03.1.1-M6). Mismos valores que crear_categoria_servicio() +# (migracion 003). Cambiarlas aqui (o por SPRING_APPLICATION_JSON / config montada) solo exige +# redesplegar Rules; sube la version para que cada decision quede trazable. +mani.rules.category.defaults.version=category-rules-v1 +mani.rules.category.defaults.name-min-length=3 +mani.rules.category.defaults.name-max-length=60 +mani.rules.category.defaults.name-requires-letter=true +mani.rules.category.defaults.allowed-flujos=COTIZACION_PREVIA,TARIFA_ESTANDAR +# Override por tenant (hereda lo no declarado). Ejemplo: +# mani.rules.category.tenants[10000000-0000-4000-8000-000000000011].version=category-rules-v1-t11 +# mani.rules.category.tenants[10000000-0000-4000-8000-000000000011].name-max-length=80 +# Ojo: un flujo NUEVO tambien exige ampliar el CHECK ck_categoria_flujo de la BD. From eab145759209ddbdeca2b421a63403ead4ad8b57 Mon Sep 17 00:00:00 2001 From: Juan Sebastian Alvarez Date: Fri, 16 Oct 2026 12:44:23 -0500 Subject: [PATCH 4/4] [SCRUM-1227] Pruebas JUnit 5 de reglas de categoria y gate de cobertura/Quality Gate - CategoryRuleEvaluatorTest: limites 2/3/60/61, conteo por caracteres como char_length (emoji), letras Unicode, normalizacion, flujos, acumulacion de violaciones, UPDATE parcial, OPERATE, reglas configurables, determinismo. - CategoryRulesPropertiesTest: binding real con override por tenant (UUID), version trazable y configuracion incoherente que impide el arranque. - CategoryRulesControllerTest: contrato HTTP completo, prefijo del Gateway, roles ADMIN/admin_tenant vs ALLY, TENANT_REQUIRED/TENANT_MISMATCH, 400/401/403/415; UnexpectedErrorTest: 500 uniforme sin filtrar detalle. - jacoco:check en verify (lineas >= 85 %, ramas >= 80 %); hoy 93.6 % / 87.8 %. - CI: SonarCloud con sonar.qualitygate.wait=true cuando existe SONAR_TOKEN; publica reportes de Surefire. 85 pruebas, mvn verify en verde. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 29 ++- README.md | 13 +- pom.xml | 39 +++ .../config/CategoryRulesPropertiesTest.java | 114 +++++++++ .../CategoryRulesControllerTest.java | 185 ++++++++++++++ .../service/CategoryRuleEvaluatorTest.java | 235 ++++++++++++++++++ .../mani/rules/web/UnexpectedErrorTest.java | 46 ++++ 7 files changed, 654 insertions(+), 7 deletions(-) create mode 100644 src/test/java/com/trama/mani/rules/config/CategoryRulesPropertiesTest.java create mode 100644 src/test/java/com/trama/mani/rules/controller/CategoryRulesControllerTest.java create mode 100644 src/test/java/com/trama/mani/rules/service/CategoryRuleEvaluatorTest.java create mode 100644 src/test/java/com/trama/mani/rules/web/UnexpectedErrorTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd10a6d..3d1743d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,10 @@ name: CI -# Ejecuta las pruebas unitarias y genera el reporte de cobertura JaCoCo (QA-06 / SCRUM-1123). -# El análisis de SonarQube y su Quality Gate se conectan a este reporte; el proyecto en -# SonarCloud y su token los crea DevOps (no se incluyen aquí). +# Pruebas JUnit 5 + cobertura JaCoCo (QA-06 / SCRUM-1123) con umbral bloqueante +# (jacoco:check en `mvn verify`, ver pom.xml) y análisis SonarCloud con espera del +# Quality Gate (US-03.1.1-M6.3 / SCRUM-1227). El paso de Sonar corre solo si el +# secret SONAR_TOKEN existe: así un PR no falla mientras DevOps crea el proyecto +# Trama-AS_MANI-Java en SonarCloud. on: push: @@ -16,9 +18,14 @@ jobs: test: name: Pruebas (Java 17) runs-on: ubuntu-latest + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - name: Checkout code uses: actions/checkout@v4 + with: + # Sonar necesita el historial para atribuir código nuevo en PRs. + fetch-depth: 0 - name: Set up JDK 17 uses: actions/setup-java@v4 @@ -27,13 +34,23 @@ jobs: java-version: '17' cache: maven - - name: Test con cobertura (JaCoCo) + - name: Test con cobertura y umbral (JaCoCo) run: mvn -B verify - - name: Publicar reporte de cobertura + - name: Publicar reportes de pruebas y cobertura if: always() uses: actions/upload-artifact@v4 with: name: coverage-jacoco - path: target/site/jacoco/ + path: | + target/site/jacoco/ + target/surefire-reports/ if-no-files-found: warn + + - name: SonarCloud + Quality Gate + if: env.SONAR_TOKEN != '' + run: mvn -B org.sonarsource.scanner.maven:sonar-maven-plugin:4.0.0.4121:sonar -Dsonar.qualitygate.wait=true + + - name: Aviso si Sonar no está configurado + if: env.SONAR_TOKEN == '' + run: echo "::warning::SONAR_TOKEN no configurado; se omitió el análisis de SonarCloud y su Quality Gate." diff --git a/README.md b/README.md index 2870d06..3561590 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,18 @@ Pruebas con **JUnit 5** (vía `spring-boot-starter-test`) y cobertura con **JaCo mvn verify ``` -El reporte queda en `target/site/jacoco/` (HTML) y `target/site/jacoco/jacoco.xml`, la ruta que SonarQube lee por defecto. El workflow `CI` (`.github/workflows/ci.yml`) ejecuta `mvn -B verify` en cada Pull Request y en cada push a `main`, y publica el reporte como artefacto. Conectarlo al Quality Gate requiere el proyecto en SonarCloud y su token, que quedan pendientes de DevOps. +El reporte queda en `target/site/jacoco/` (HTML) y `target/site/jacoco/jacoco.xml`. `mvn verify` **falla** si la cobertura baja de 85 % de líneas u 80 % de ramas (`jacoco:check`, umbrales en `pom.xml`). + +| Suite | Qué cubre | +| :--- | :--- | +| `CategoryRuleEvaluatorTest` | Reglas puras: límites 2/3/60/61, conteo por caracteres (emoji), letras Unicode, normalización, flujos, UPDATE parcial, OPERATE, reglas configurables, determinismo | +| `CategoryRulesPropertiesTest` | Binding de `mani.rules.category.*`, override por tenant, versión trazable, configuración incoherente que impide el arranque | +| `CategoryRulesControllerTest` | Contrato HTTP: forma de respuesta, prefijo del Gateway, roles, tenant (`TENANT_REQUIRED`, `TENANT_MISMATCH`), 400/401/403/415 | +| `UnexpectedErrorTest`, `WebSkeletonTest`, `HealthControllerSmokeTest` | 500 uniforme sin filtrar detalle, correlation ID, CORS, 404/405 | + +El workflow `CI` ejecuta `mvn -B verify` en cada Pull Request y push a `main`/`develop`, publica cobertura y reportes de Surefire, y corre **SonarCloud con `sonar.qualitygate.wait=true`** (el PR falla si no pasa el Quality Gate). Ese paso se activa cuando el repositorio tiene el secret `SONAR_TOKEN` y el proyecto `Trama-AS_MANI-Java` existe en SonarCloud (DevOps); mientras tanto CI emite una advertencia en vez de fallar. + +Las pruebas de contrato extremo a extremo (Gateway → Rules) viven en `MANI-APIGateway` (`postman/MANI-Rules.postman_collection.json`, job `contract-gate-rules`). --- diff --git a/pom.xml b/pom.xml index 6980bf8..2934eb1 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,16 @@ 17 + + trama-as + Trama-AS_MANI-Java + https://sonarcloud.io + ${project.build.directory}/site/jacoco/jacoco.xml + **/ManiRulesApplication.java + + 0.85 + 0.80 @@ -60,6 +70,35 @@ report + + check + verify + + check + + + + com/trama/mani/rules/ManiRulesApplication.class + + + + BUNDLE + + + LINE + COVEREDRATIO + ${jacoco.minimum.line} + + + BRANCH + COVEREDRATIO + ${jacoco.minimum.branch} + + + + + + diff --git a/src/test/java/com/trama/mani/rules/config/CategoryRulesPropertiesTest.java b/src/test/java/com/trama/mani/rules/config/CategoryRulesPropertiesTest.java new file mode 100644 index 0000000..ef3ffe6 --- /dev/null +++ b/src/test/java/com/trama/mani/rules/config/CategoryRulesPropertiesTest.java @@ -0,0 +1,114 @@ +package com.trama.mani.rules.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +/** + * Las reglas se cambian por configuración de Rules (no se despliega Core) y una configuración + * incoherente debe impedir el arranque en vez de evaluar con ella. + */ +class CategoryRulesPropertiesTest { + + private static final String TENANT = "10000000-0000-4000-8000-000000000011"; + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(RulesConfiguration.class) + .withPropertyValues( + "mani.rules.category.defaults.version=category-rules-v1", + "mani.rules.category.defaults.name-min-length=3", + "mani.rules.category.defaults.name-max-length=60", + "mani.rules.category.defaults.name-requires-letter=true", + "mani.rules.category.defaults.allowed-flujos=COTIZACION_PREVIA,TARIFA_ESTANDAR"); + + @Test + void sinOverrideUsaLosDefaults() { + runner.run(context -> { + EffectiveCategoryRules rules = context.getBean(CategoryRulesProperties.class).forTenant("cualquiera"); + + assertThat(rules).isEqualTo(new EffectiveCategoryRules( + "category-rules-v1", 3, 60, true, List.of("COTIZACION_PREVIA", "TARIFA_ESTANDAR"))); + }); + } + + @Test + void unTenantSobrescribeSoloLoQueDeclaraYHeredaElResto() { + runner.withPropertyValues( + "mani.rules.category.tenants[" + TENANT + "].name-max-length=80", + "mani.rules.category.tenants[" + TENANT + "].version=category-rules-v2-t11") + .run(context -> { + CategoryRulesProperties properties = context.getBean(CategoryRulesProperties.class); + EffectiveCategoryRules tenant = properties.forTenant(TENANT); + + assertThat(tenant.version()).isEqualTo("category-rules-v2-t11"); + assertThat(tenant.nameMaxLength()).isEqualTo(80); + assertThat(tenant.nameMinLength()).isEqualTo(3); + assertThat(tenant.allowedFlujos()).containsExactly("COTIZACION_PREVIA", "TARIFA_ESTANDAR"); + assertThat(properties.forTenant("otro-tenant").nameMaxLength()).isEqualTo(60); + }); + } + + @Test + void unOverrideSinVersionPropiaQuedaTrazableConElTenant() { + runner.withPropertyValues("mani.rules.category.tenants[" + TENANT + "].name-min-length=2").run(context -> { + assertThat(context.getBean(CategoryRulesProperties.class).forTenant(TENANT).version()) + .isEqualTo("category-rules-v1/" + TENANT); + }); + } + + @Test + void normalizaLosFlujosConfiguradosYPermiteDesactivarLaLetra() { + runner.withPropertyValues( + "mani.rules.category.tenants[" + TENANT + "].allowed-flujos= visita_tecnica , ,TARIFA_ESTANDAR", + "mani.rules.category.tenants[" + TENANT + "].name-requires-letter=false") + .run(context -> { + EffectiveCategoryRules tenant = context.getBean(CategoryRulesProperties.class).forTenant(TENANT); + + assertThat(tenant.allowedFlujos()).containsExactly("VISITA_TECNICA", "TARIFA_ESTANDAR"); + assertThat(tenant.nameRequiresLetter()).isFalse(); + }); + } + + @Test + void noArrancaSiElMinimoSuperaAlMaximo() { + runner.withPropertyValues("mani.rules.category.defaults.name-min-length=70").run(context -> + assertThat(context).hasFailed().getFailure().hasRootCauseMessage( + "Longitudes de nombre inválidas en reglas de categoría (category-rules-v1): min=70, max=60")); + } + + @Test + void noArrancaSiElMinimoEsMenorQueUno() { + runner.withPropertyValues("mani.rules.category.defaults.name-min-length=0") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void noArrancaSiNoHayFlujosPermitidos() { + runner.withPropertyValues("mani.rules.category.defaults.allowed-flujos=") + .run(context -> assertThat(context).hasFailed().getFailure() + .hasRootCauseMessage("allowed-flujos no puede estar vacío en reglas de categoría (category-rules-v1)")); + } + + @Test + void noArrancaSinVersion() { + runner.withPropertyValues("mani.rules.category.defaults.version=") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void noArrancaSiElOverrideDeUnTenantEsIncoherente() { + runner.withPropertyValues("mani.rules.category.tenants[" + TENANT + "].name-max-length=2") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void sinLongitudesConfiguradasNoArranca() { + new ApplicationContextRunner() + .withUserConfiguration(RulesConfiguration.class) + .withPropertyValues("mani.rules.category.defaults.version=v1", + "mani.rules.category.defaults.allowed-flujos=TARIFA_ESTANDAR") + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/src/test/java/com/trama/mani/rules/controller/CategoryRulesControllerTest.java b/src/test/java/com/trama/mani/rules/controller/CategoryRulesControllerTest.java new file mode 100644 index 0000000..83e463a --- /dev/null +++ b/src/test/java/com/trama/mani/rules/controller/CategoryRulesControllerTest.java @@ -0,0 +1,185 @@ +package com.trama.mani.rules.controller; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.trama.mani.rules.config.RulesConfiguration; +import com.trama.mani.rules.service.CategoryRuleEvaluator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +/** Contrato HTTP de POST /api/v1/rules/categories/evaluate (docs/openapi/rules.yaml del Gateway). */ +@WebMvcTest(CategoryRulesController.class) +@Import({RulesConfiguration.class, CategoryRuleEvaluator.class}) +@TestPropertySource(properties = { + "mani.rules.category.tenants[tenant-override].name-min-length=2", + "mani.rules.category.tenants[tenant-override].version=category-rules-v2-override" +}) +class CategoryRulesControllerTest { + + private static final String URL = "/api/v1/rules/categories/evaluate"; + private static final String VALID_CREATE = + "{\"context\":{\"operation\":\"CREATE\",\"name\":\" Plomería Express \",\"flujoOperativo\":\"tarifa_estandar\"}}"; + + @Autowired + private MockMvc mockMvc; + + private ResultActions evaluate(String url, String tenant, String role, String body) throws Exception { + var request = post(url).contentType(MediaType.APPLICATION_JSON).content(body) + .header("X-Correlation-ID", "corr-test"); + if (tenant != null) { + request.header("X-Tenant-Id", tenant); + } + if (role != null) { + request.header("X-User-Role", role); + } + return mockMvc.perform(request); + } + + @Test + void createValidaRespondeElContratoCompleto() throws Exception { + evaluate(URL, "tenant-a", "ADMIN", VALID_CREATE) + .andExpect(status().isOk()) + .andExpect(header().string("X-Correlation-ID", "corr-test")) + .andExpect(jsonPath("$.allowed").value(true)) + .andExpect(jsonPath("$.ruleSetVersion").value("category-rules-v1")) + .andExpect(jsonPath("$.violations", hasSize(0))) + .andExpect(jsonPath("$.normalized.name").value("Plomería Express")) + .andExpect(jsonPath("$.normalized.flujoOperativo").value("TARIFA_ESTANDAR")) + .andExpect(jsonPath("$.tenantId").value("tenant-a")) + .andExpect(jsonPath("$.categoryId").doesNotExist()) + .andExpect(jsonPath("$.operation").value("CREATE")) + .andExpect(jsonPath("$.correlationId").value("corr-test")); + } + + @Test + void respondeTambienSinElPrefijoQueRecortaElGateway() throws Exception { + evaluate("/categories/evaluate", "tenant-a", "ADMIN", VALID_CREATE) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.allowed").value(true)); + } + + @Test + void unaReglaIncumplidaEsUn200ConAllowedFalseYSusViolaciones() throws Exception { + evaluate(URL, "tenant-a", "ADMIN", "{\"context\":{\"operation\":\"CREATE\",\"name\":\"12\",\"flujoOperativo\":\"OTRO\"}}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.allowed").value(false)) + .andExpect(jsonPath("$.violations", hasSize(3))) + .andExpect(jsonPath("$.violations[0].code").value("MANI-CAT-422N")) + .andExpect(jsonPath("$.violations[0].field").value("name")) + .andExpect(jsonPath("$.violations[2].code").value("MANI-CAT-422F")) + .andExpect(jsonPath("$.violations[2].field").value("flujoOperativo")); + } + + @Test + void operateConCategoriaInactivaDevuelve422C() throws Exception { + evaluate(URL, "tenant-a", "aliado", "{\"categoryId\":\"c-1\",\"context\":{\"operation\":\"OPERATE\",\"active\":false}}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.allowed").value(false)) + .andExpect(jsonPath("$.categoryId").value("c-1")) + .andExpect(jsonPath("$.violations[0].code").value("MANI-CAT-422C")); + } + + @Test + void aplicaElOverrideDelTenantYReportaSuVersion() throws Exception { + String twoChars = "{\"context\":{\"operation\":\"CREATE\",\"name\":\"AB\",\"flujoOperativo\":\"TARIFA_ESTANDAR\"}}"; + + evaluate(URL, "tenant-override", "ADMIN", twoChars) + .andExpect(jsonPath("$.allowed").value(true)) + .andExpect(jsonPath("$.ruleSetVersion").value("category-rules-v2-override")); + evaluate(URL, "tenant-normal", "ADMIN", twoChars) + .andExpect(jsonPath("$.allowed").value(false)) + .andExpect(jsonPath("$.ruleSetVersion").value("category-rules-v1")); + } + + @ParameterizedTest + @ValueSource(strings = {"ADMIN", "admin", "ADMIN_TENANT", "admin_tenant"}) + void aceptaLosRolesDeAdministradorQueEmitenCoreYElGateway(String role) throws Exception { + evaluate(URL, "tenant-a", role, VALID_CREATE).andExpect(status().isOk()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALLY", "aliado", "CLIENT", "authenticated"}) + void createYUpdateExigenAdministrador(String role) throws Exception { + evaluate(URL, "tenant-a", role, VALID_CREATE) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("FORBIDDEN")) + .andExpect(jsonPath("$.correlationId").value("corr-test")); + evaluate(URL, "tenant-a", role, "{\"context\":{\"operation\":\"UPDATE\",\"name\":\"Nuevo\"}}") + .andExpect(status().isForbidden()); + } + + @Test + void operateLoPuedeEvaluarCualquierRolAutenticado() throws Exception { + evaluate(URL, "tenant-a", "aliado", "{\"context\":{\"operation\":\"OPERATE\",\"active\":true}}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.allowed").value(true)); + } + + @Test + void sinTenantRespondeTenantRequired() throws Exception { + evaluate(URL, null, "ADMIN", VALID_CREATE) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("TENANT_REQUIRED")); + evaluate(URL, " ", "ADMIN", VALID_CREATE) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("TENANT_REQUIRED")); + } + + @Test + void sinRolRespondeUnauthorized() throws Exception { + evaluate(URL, "tenant-a", null, VALID_CREATE) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); + } + + @Test + void unTenantAjenoEnElCuerpoSeRechaza() throws Exception { + evaluate(URL, "tenant-a", "ADMIN", + "{\"tenantId\":\"tenant-b\",\"context\":{\"operation\":\"CREATE\",\"name\":\"Plomería\",\"flujoOperativo\":\"TARIFA_ESTANDAR\"}}") + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("TENANT_MISMATCH")); + } + + @Test + void elTenantDelCuerpoIgualAlDeLaSesionSeAcepta() throws Exception { + evaluate(URL, "tenant-a", "ADMIN", + "{\"tenantId\":\"tenant-a\",\"context\":{\"operation\":\"CREATE\",\"name\":\"Plomería\",\"flujoOperativo\":\"TARIFA_ESTANDAR\"}}") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.tenantId").value("tenant-a")); + } + + @ParameterizedTest + @ValueSource(strings = { + "{}", + "{\"context\":{}}", + "{\"context\":{\"operation\":\"DELETE\"}}", + "{\"context\":{\"operation\":\"OPERATE\"}}", + "{no-json" + }) + void peticionesMalFormadasRespondenValidationError(String body) throws Exception { + evaluate(URL, "tenant-a", "ADMIN", body) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.correlationId").value("corr-test")); + } + + @Test + void contentTypeDistintoDeJsonResponde415() throws Exception { + mockMvc.perform(post(URL).contentType(MediaType.TEXT_PLAIN).content("x") + .header("X-Tenant-Id", "tenant-a").header("X-User-Role", "ADMIN")) + .andExpect(status().isUnsupportedMediaType()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")); + } +} diff --git a/src/test/java/com/trama/mani/rules/service/CategoryRuleEvaluatorTest.java b/src/test/java/com/trama/mani/rules/service/CategoryRuleEvaluatorTest.java new file mode 100644 index 0000000..2b4354a --- /dev/null +++ b/src/test/java/com/trama/mani/rules/service/CategoryRuleEvaluatorTest.java @@ -0,0 +1,235 @@ +package com.trama.mani.rules.service; + +import static com.trama.mani.rules.service.CategoryRuleEvaluator.FLUJO_INVALID; +import static com.trama.mani.rules.service.CategoryRuleEvaluator.NAME_INVALID; +import static com.trama.mani.rules.service.CategoryRuleEvaluator.NOT_AVAILABLE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.trama.mani.rules.config.EffectiveCategoryRules; +import com.trama.mani.rules.dto.RuleViolation; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +/** Reglas de crear_categoria_servicio() (migración 003, SCRUM-857) portadas a Rules. */ +class CategoryRuleEvaluatorTest { + + private static final EffectiveCategoryRules DEFAULTS = + new EffectiveCategoryRules("v1", 3, 60, true, List.of("COTIZACION_PREVIA", "TARIFA_ESTANDAR")); + + private final CategoryRuleEvaluator evaluator = new CategoryRuleEvaluator(); + + private CategoryRuleEvaluator.Result create(String name, String flujo) { + return evaluator.evaluate(DEFAULTS, CategoryOperation.CREATE, name, flujo, null); + } + + private static List codes(CategoryRuleEvaluator.Result result) { + return result.violations().stream().map(RuleViolation::code).toList(); + } + + @Nested + @DisplayName("CREATE") + class Create { + + @Test + void aceptaUnaCategoriaValidaYNormalizaNombreYFlujo() { + CategoryRuleEvaluator.Result result = create(" Plomería Express \t", " tarifa_estandar "); + + assertThat(result.allowed()).isTrue(); + assertThat(result.violations()).isEmpty(); + assertThat(result.normalized().name()).isEqualTo("Plomería Express"); + assertThat(result.normalized().flujoOperativo()).isEqualTo("TARIFA_ESTANDAR"); + } + + @ParameterizedTest(name = "longitud {0} => allowed={1}") + @CsvSource({"2, false", "3, true", "60, true", "61, false"}) + void respetaLosLimitesDeLongitudDelNombre(int length, boolean allowed) { + CategoryRuleEvaluator.Result result = create("a".repeat(length), "TARIFA_ESTANDAR"); + + assertThat(result.allowed()).isEqualTo(allowed); + if (!allowed) { + assertThat(result.violations()).singleElement() + .satisfies(v -> { + assertThat(v.code()).isEqualTo(NAME_INVALID); + assertThat(v.field()).isEqualTo("name"); + assertThat(v.message()).contains("entre 3 y 60"); + }); + } + } + + @Test + void cuentaCaracteresComoPostgresYNoUnidadesUtf16() { + // 59 letras + un emoji: 60 caracteres (char_length) aunque sean 61 unidades UTF-16. + String name = "a".repeat(59) + "😀"; + assertThat(name.length()).isEqualTo(61); + + assertThat(create(name, "TARIFA_ESTANDAR").allowed()).isTrue(); + } + + @Test + void colapsaEspaciosAntesDeMedirLaLongitud() { + assertThat(create("a b", "TARIFA_ESTANDAR").allowed()).isTrue(); + assertThat(create(" a ", "TARIFA_ESTANDAR").allowed()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"123", "!!!-", "2026 99"}) + void exigeAlMenosUnaLetra(String name) { + CategoryRuleEvaluator.Result result = create(name, "TARIFA_ESTANDAR"); + + assertThat(result.allowed()).isFalse(); + assertThat(result.violations()).extracting(RuleViolation::message) + .contains("el nombre debe contener al menos una letra"); + } + + @ParameterizedTest + @ValueSource(strings = {"Ñu 24h", "Uñas 3D", "电工服务", "Αβγ"}) + void aceptaLetrasDeCualquierIdioma(String name) { + assertThat(create(name, "TARIFA_ESTANDAR").allowed()).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t\n"}) + void exigeElNombre(String name) { + CategoryRuleEvaluator.Result result = create(name, "TARIFA_ESTANDAR"); + + assertThat(codes(result)).containsExactly(NAME_INVALID); + assertThat(result.violations().get(0).message()).isEqualTo("el nombre es obligatorio"); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void exigeElFlujoOperativo(String flujo) { + CategoryRuleEvaluator.Result result = create("Plomería", flujo); + + assertThat(codes(result)).containsExactly(FLUJO_INVALID); + assertThat(result.violations().get(0).message()).isEqualTo("el flujo operativo es obligatorio"); + } + + @ParameterizedTest + @ValueSource(strings = {"OTRO", "COTIZACION", "TARIFA ESTANDAR"}) + void rechazaFlujosNoPermitidos(String flujo) { + CategoryRuleEvaluator.Result result = create("Plomería", flujo); + + assertThat(result.violations()).singleElement().satisfies(v -> { + assertThat(v.code()).isEqualTo(FLUJO_INVALID); + assertThat(v.field()).isEqualTo("flujoOperativo"); + assertThat(v.message()).contains("COTIZACION_PREVIA, TARIFA_ESTANDAR"); + }); + } + + @ParameterizedTest + @ValueSource(strings = {"cotizacion_previa", "Tarifa_Estandar", " COTIZACION_PREVIA "}) + void aceptaElFlujoSinDistinguirMayusculasNiEspaciosExtremos(String flujo) { + assertThat(create("Plomería", flujo).allowed()).isTrue(); + } + + @Test + void acumulaTodasLasViolacionesEnUnaSolaEvaluacion() { + assertThat(codes(create("12", "OTRO"))).containsExactly(NAME_INVALID, NAME_INVALID, FLUJO_INVALID); + } + } + + @Nested + @DisplayName("UPDATE") + class Update { + + @Test + void soloEvaluaElNombreSiEsLoUnicoQueCambia() { + CategoryRuleEvaluator.Result result = + evaluator.evaluate(DEFAULTS, CategoryOperation.UPDATE, " Plomería Pro ", null, null); + + assertThat(result.allowed()).isTrue(); + assertThat(result.normalized().name()).isEqualTo("Plomería Pro"); + assertThat(result.normalized().flujoOperativo()).isNull(); + } + + @Test + void soloEvaluaElFlujoSiEsLoUnicoQueCambia() { + assertThat(codes(evaluator.evaluate(DEFAULTS, CategoryOperation.UPDATE, null, "OTRO", null))) + .containsExactly(FLUJO_INVALID); + } + + @Test + void sinCamposDeReglaNoHayNadaQueRechazar() { + assertThat(evaluator.evaluate(DEFAULTS, CategoryOperation.UPDATE, null, null, null).allowed()).isTrue(); + } + + @Test + void unNombreEnviadoEnBlancoSeRechaza() { + assertThat(codes(evaluator.evaluate(DEFAULTS, CategoryOperation.UPDATE, " ", null, null))) + .containsExactly(NAME_INVALID); + } + } + + @Nested + @DisplayName("OPERATE") + class Operate { + + @Test + void unaCategoriaActivaSePuedeOperar() { + assertThat(evaluator.evaluate(DEFAULTS, CategoryOperation.OPERATE, null, null, true).allowed()).isTrue(); + } + + @Test + void unaCategoriaInactivaNoEstaDisponible() { + CategoryRuleEvaluator.Result result = evaluator.evaluate(DEFAULTS, CategoryOperation.OPERATE, null, null, false); + + assertThat(result.allowed()).isFalse(); + assertThat(result.violations()).singleElement() + .isEqualTo(new RuleViolation(NOT_AVAILABLE, "active", "la categoría está inactiva")); + } + + @Test + void noEvaluaNombreNiFlujoAlOperar() { + // Filas históricas pueden tener flujo_operativo NULL: operar no las vuelve a juzgar. + assertThat(evaluator.evaluate(DEFAULTS, CategoryOperation.OPERATE, "x", null, true).allowed()).isTrue(); + } + } + + @Nested + @DisplayName("Reglas configurables") + class Configurable { + + @Test + void usaLosLimitesYFlujosDelTenant() { + EffectiveCategoryRules custom = new EffectiveCategoryRules("t-v2", 2, 80, true, List.of("VISITA_TECNICA")); + + assertThat(evaluator.evaluate(custom, CategoryOperation.CREATE, "AB", "visita_tecnica", null).allowed()).isTrue(); + assertThat(evaluator.evaluate(custom, CategoryOperation.CREATE, "a".repeat(80), "VISITA_TECNICA", null).allowed()) + .isTrue(); + assertThat(codes(evaluator.evaluate(custom, CategoryOperation.CREATE, "AB", "TARIFA_ESTANDAR", null))) + .containsExactly(FLUJO_INVALID); + } + + @Test + void puedeDesactivarLaExigenciaDeLetra() { + EffectiveCategoryRules noLetter = new EffectiveCategoryRules("v", 3, 60, false, List.of("TARIFA_ESTANDAR")); + + assertThat(evaluator.evaluate(noLetter, CategoryOperation.CREATE, "123", "TARIFA_ESTANDAR", null).allowed()) + .isTrue(); + } + + @Test + void esDeterministaParaLaMismaEntrada() { + assertThat(create("12", "OTRO")).isEqualTo(create("12", "OTRO")); + } + } + + @Test + void parseDeOperacionIgnoraMayusculasYRechazaDesconocidas() { + assertThat(CategoryOperation.parse(" create ")).contains(CategoryOperation.CREATE); + assertThat(CategoryOperation.parse("DELETE")).isEmpty(); + assertThat(CategoryOperation.parse(null)).isEmpty(); + assertThat(CategoryOperation.CREATE.requiresAdmin()).isTrue(); + assertThat(CategoryOperation.UPDATE.requiresAdmin()).isTrue(); + assertThat(CategoryOperation.OPERATE.requiresAdmin()).isFalse(); + } +} diff --git a/src/test/java/com/trama/mani/rules/web/UnexpectedErrorTest.java b/src/test/java/com/trama/mani/rules/web/UnexpectedErrorTest.java new file mode 100644 index 0000000..71c8538 --- /dev/null +++ b/src/test/java/com/trama/mani/rules/web/UnexpectedErrorTest.java @@ -0,0 +1,46 @@ +package com.trama.mani.rules.web; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.trama.mani.rules.config.RulesConfiguration; +import com.trama.mani.rules.controller.CategoryRulesController; +import com.trama.mani.rules.service.CategoryRuleEvaluator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** Una falla interna de Rules es un 500 uniforme que no filtra el detalle (Core lo traduce a 503). */ +@WebMvcTest(CategoryRulesController.class) +@Import(RulesConfiguration.class) +class UnexpectedErrorTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private CategoryRuleEvaluator evaluator; + + @Test + void unaExcepcionNoControladaRespondeInternalErrorSinDetalle() throws Exception { + given(evaluator.evaluate(any(), any(), any(), any(), any())).willThrow(new IllegalStateException("secreto interno")); + + mockMvc.perform(post("/api/v1/rules/categories/evaluate") + .contentType(MediaType.APPLICATION_JSON) + .header("X-Tenant-Id", "tenant-a") + .header("X-User-Role", "ADMIN") + .header("X-Correlation-ID", "corr-500") + .content("{\"context\":{\"operation\":\"OPERATE\",\"active\":true}}")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("INTERNAL_ERROR")) + .andExpect(jsonPath("$.error").value("Error interno del motor de reglas")) + .andExpect(jsonPath("$.correlationId").value("corr-500")); + } +}