Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

# 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:
branches: [main, develop]
pull_request:

permissions:
contents: read

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
with:
distribution: temurin
java-version: '17'
cache: maven

- name: Test con cobertura y umbral (JaCoCo)
run: mvn -B verify

- name: Publicar reportes de pruebas y cobertura
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-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."
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[<uuid>].*`): 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:**
Expand Down Expand Up @@ -106,6 +137,30 @@ 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`. `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`).

---

## 👥 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)
64 changes: 64 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,27 @@

<properties>
<java.version>17</java.version>
<!-- SonarCloud (ADR-0005). El scanner Maven lee estas propiedades; el token
(SONAR_TOKEN) es un secret del repositorio. -->
<sonar.organization>trama-as</sonar.organization>
<sonar.projectKey>Trama-AS_MANI-Java</sonar.projectKey>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.coverage.jacoco.xmlReportPaths>${project.build.directory}/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
<sonar.coverage.exclusions>**/ManiRulesApplication.java</sonar.coverage.exclusions>
<!-- Umbral local de cobertura: `mvn verify` falla por debajo (jacoco:check). -->
<jacoco.minimum.line>0.85</jacoco.minimum.line>
<jacoco.minimum.branch>0.80</jacoco.minimum.branch>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand All @@ -37,6 +51,56 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- Cobertura: genera target/site/jacoco/jacoco.xml, la ruta que lee SonarQube por defecto. -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<excludes>
<exclude>com/trama/mani/rules/ManiRulesApplication.class</exclude>
</excludes>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>${jacoco.minimum.line}</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>${jacoco.minimum.branch}</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -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).
*
* <pre>
* mani.rules.category.defaults.version=category-rules-v1
* mani.rules.category.defaults.name-max-length=60
* mani.rules.category.tenants[&lt;tenant-uuid&gt;].name-max-length=80
* </pre>
*
* 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<String, RuleSet> 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<String> 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> 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<String, RuleSet> getTenants() {
return tenants;
}

public void setTenants(Map<String, RuleSet> 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<String> 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<String> getAllowedFlujos() {
return allowedFlujos;
}

public void setAllowedFlujos(List<String> allowedFlujos) {
this.allowedFlujos = allowedFlujos;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String> allowedFlujos) {
}
Original file line number Diff line number Diff line change
@@ -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 {
}
Loading
Loading