TsArticulos — Bugs detectados, fixes aplicados y testing agregado

Auditoría post-incidente Dino (19–20/09/2026) — bugs, fixes por tanda, testing con SQL real, mutation testing y revisión adversarial

Estado del trabajo

Todo el trabajo está en la rama local fix/duplicate-tiebreak-sql-it, SIN commit. main no fue modificado. Números finales verificados el 22/09/2026: 196 pruebas unitarias, 67 pruebas de integración con SQL real (14 clases), PIT 309/315 mutantes detectados (98,1 %). Diff: 22 archivos versionados modificados: +2474/−173; además 28 archivos nuevos sin versionar bajo src/ (≈3.976 líneas: pruebas de integración, pruebas unitarias nuevas, DDL de pruebas, GlobalRestExceptionHandler). La credencial de pruebas (SQL Server local) no aparece en ningún archivo: 0 coincidencias. Nota: la credencial de desarrollo preexistente en src/main/resources/application-dev.yml sigue versionada (B20 / A11, solo propuesto: rotar y pasar a variables de entorno). Reporte del incidente original: incidente-2026-09-19-precio-cache.html.

1. Resumen ejecutivo

Incidente Dino 19–20/09/2026 (precio de autocobro 1.05 en lugar de 8490.00). Causa raíz: el camino on-miss no aplicaba el contrato de dm_artic — tabla multi-versión por diseño de SrvPOS, donde la fila vigente es la de mayor iVersion y, en empate, mayor iid (NULL se trata como el valor menor). A partir de ahí se hizo una auditoría completa de bugs, se agregaron pruebas con SQL real y mutation testing (PIT), y se aplicaron fixes en dos tandas con revisión adversarial (judgment-day: dos jueces ciegos en paralelo, se corrige solo lo confirmado por ambos, y se vuelve a juzgar).

Bugs

24
16 aplicados · 3 propuestos · 1 caracterizado (sin fix) · 1 informativo · 3 descartados tras verificación

Pruebas unitarias

63 → 196
antes → ahora

Integración SQL real

0 → 67
14 clases IT nuevas

Mutation testing (PIT)

166/248 → 309/315
67 % → 98,1 %

2. Bugs detectados

Ordenados por id de descubrimiento. Estado por ítem: Aplicado en rama Solo propuesto Caracterizado (sin fix) Informativo.

ID Bug Severidad Cómo se detectó Estado
B1 On-miss (findByEanSuc) sin ORDER BY → fila arbitraria entre versiones (causa del incidente). Alta Logs + código Aplicado (A1/A4)
B2 Preload elegía mayor iid solo por coincidencia con el orden de inserción de SrvPOS. Media Análisis fuente SrvPOS Aplicado (A2)
B3 Envase no determinista (batch sin ORDER BY + putIfAbsent). Media Auditoría Aplicado (A3)
B4 NULL en columnas anulables rompía on-miss (AopInvocationException, getters primitivos de la proyección JPA) mientras el preload convertía NULL→0. Alta IT sobre el DDL real Aplicado (A4: único mapeo JDBC)
B5 Refresh colgado dejaba refreshRunning=true para siempre (sin query timeout). Alta Auditoría Aplicado (A5)
B6 refreshCacheBlocking sin stack trace en el log. Baja Auditoría Aplicado (A8)
B7 Escritura on-miss incondicional podía pisar un valor más nuevo tras un swap. Media Judgment-day T1 R1 (ambos jueces) Aplicado
B8 Carrera entre dos on-miss concurrentes de la misma clave (check-then-act no atómico). Media (real) Judgment-day T1 R2 (ambos) Aplicado (asMap().merge) + prueba de concurrencia real (2 hilos, falla contra el código anterior en la iteración 1)
B9 WARN por cada duplicado en preload (ruido: multi-versión es por diseño). Baja Judgment-day T1 R1 Aplicado (DEBUG por fila + un INFO por carga)
B10 REST searchBy/findArticulos/countSearchBy: versiones superadas y duplicados, JOIN de envase multiplica filas, count ≠ filas, hasNext incorrecto, LIKE sin escape de % _ [, mismo bug NULL de B4. Alta Auditoría + IT + juez B Aplicado (A9)
B11 POST /cache/refresh devolvía 202 aunque el refresh se omitiera o no arrancara. Media Análisis Aplicado (A6: 202/409/503; SrvPOS solo registra status+body)
B12 Expiración fija de 4 h (con scheduler diario en Dino, todo el tráfico caía a on-miss). Media Análisis Aplicado (A7) + mitigación operativa: cron cada 30 min
B13 spring-websocket/spring-messaging fijados en 6.2.0 fuera del BOM. Media Auditoría Aplicado (A12 → 6.2.18)
B14 iVersion BIGINT mapeado a Integer (wrap silencioso sobre 2^31−1). Baja/latente IT Aplicado (A14a: Long de punta a punta, regresión sobre 2147483648)
B15 Sin @ControllerAdvice/@MessageExceptionHandler: ante falla de BD el POS recibía otro formato. Media Revisión OCR Aplicado (A14b)
B16 EnvaseDto sin trim de descripción. Baja Revisión OCR Aplicado (A14c)
B17 Total "Articulos" contaba filas leídas, no claves distintas. Baja Revisión Aplicado (A14d)
B18 Hallado por la matriz COLLATE: cEnganche con espacios alrededor → el JOIN SQL encuentra el envase pero la clave Java (buildEnvaseLookupKey con cEnganche sin trim) no coincide con la del mapa (iarticulo sin padding) → el envase se descarta en silencio en preload, on-miss y búsqueda por igual. Judgment-day tanda 2 (R1/R2) confirmó además que la clave del lado ENVASE (iArticulo + inrosuc) también quedaba sin normalizar. Media (money path, depende de datos) IT CollationAndPaddingMatrixIT.cEngangeWithSurroundingWhitespaceSilentlyDropsTheEnvaseOnEveryPath Aplicado en rama — fix: trim() en ambos lados de la clave (artículo cEnganche/inrosuc y envase iArticulo/inrosuc); 4 ITs de regresión en CollationAndPaddingMatrixIT
B19 Seguridad: /cache/refresh y /ws-articulos/** en permitAll; security.enabled=false en todos los perfiles; orígenes *. Media Auditoría Solo propuesto (A10)
B20 Credencial de BD en application-dev.yml versionado. Media Auditoría Solo propuesto (A11, rotar)
B21 Neto redondeado a decimales antes del IVA (posible 1 centavo); iTax desconocido → 21 % sin log. Baja/Media Auditoría Solo propuesto (A13, validación fiscal)
B22 Mayúsculas/minúsculas: SQL Server compara cEnganche/iArticulo sin distinguir mayúsculas (colación CI) pero la clave Java es sensible a mayúsculas — mismo mecanismo que B18. Prueba de caracterización CollationAndPaddingMatrixIT.cEngancheAndIarticuloDifferingOnlyByCaseCurrentlyDropTheEnvaseOnEveryPath: con cEnganche='env9060' e iArticulo='ENV9060' el IN de SQL (colación CI) encuentra el envase pero la clave Java (sensible a mayúsculas) no coincide → envase omitido en preload, on-miss y búsqueda. Comportamiento actual documentado; contra datos reales no hay casos (0 de 769). Baja/teórica Judgment-day tanda 2 (R3) + IT de caracterización Caracterizado (sin fix) — fix propuesto: normalizar mayúsculas en ambos lados de la clave de envase
B23 Helpers de normalización duplicados con semántica distinta para valor nulo: normalize devuelve null y normalizeCacheKeyPart devuelve "" para sucursal nula → claves "null|x" vs "|x". Info Judgment-day tanda 2 (R3, ambos jueces) Informativo — sin impacto real: una sucursal nula nunca entra en el IN (:sucursales) de la consulta, así que ningún envase llega al lookup. Deuda anotada: unificar ambos helpers en uno solo
B24 WITH(NOLOCK) durante la recarga nocturna: SrvPOS hace DELETE + INSERT del bloque de la sucursal dentro de una transacción (SrvPOS_Artic_Novedades.cpp 959–1090); un preload disparado por el cron en ese intervalo puede cargar un bloque incompleto y activarlo hasta el siguiente refresh; un miss puede ver transitoriamente "no encontrado". Riesgo preexistente, no introducido por la rama. Fix (A16): ninguna de las 4 consultas de ArticulosCacheLoadRepositoryImpl usa WITH(NOLOCK) — en las dos consultas preexistentes (lote base y envases) el hint fue eliminado; las otras dos (on-miss TOP (1) y CTE de búsqueda) son consultas nuevas de esta rama, creadas inicialmente con el hint heredado y corregidas en A16. Las 7 consultas JPA originales con NOLOCK de ArticulosRepository fueron eliminadas o migradas (A4/A9). Las lecturas pasan a READ COMMITTED (default de la BD, RCSI desactivado). Durante la recarga nocturna la consulta espera al commit; si supera cache-query-timeout-seconds (60 s) el refresh queda FAILED, refreshRunning se libera y la caché anterior sigue activa. Un miss en esa ventana recibe 503 (sobre ResponseMessage) en lugar de un "no encontrado" falso. Costo: lecturas bloqueadas unos segundos durante el completo, una vez por noche. Media (ventana de carrera con la recarga de SrvPOS) Revisión adversarial independiente (Codex), contra 860.328 filas reales Aplicado en rama — ver A16 (READ COMMITTED, decisión del owner)

Descartados tras verificación

3. Fixes aplicados (por tanda, con archivos)

Tanda 1 A1–A5, A8 + B7/B8/B9

repository/ArticulosCacheLoadRepository(Impl).javafindArticuloForCacheMiss: SELECT TOP (1) … WHERE dm.inrosuc = :nrosuc AND dm.cean = :ean ORDER BY dm.iVersion DESC, dm.iid DESC; envase ORDER BY env.iVersion DESC, env.iid DESC. repository/ArticulosRepository.java (se eliminó findByEanSuc). service/cache/ArticuloCacheService.java (putIfHigherPriority, putOnCacheMissIfHigherPriority):

Snippet — merge atómico (B8)
ArticuloProjection winner = activeCache.asMap().merge(cacheKey, candidate,
        (existing, incoming) -> isHigherPriority(incoming, existing) ? incoming : existing);

config/CacheConfig.java (bean articuloCacheLoadJdbcTemplate con setQueryTimeout), config/AppProperties.java, application.yml, template-application.yml.

Tanda 2 A6, A7, A9, A12, A14

A9 — reescritura de searchBy/findArticulos/countSearchBy con ventana:

Snippet — desempate por ventana (A9)
ROW_NUMBER() OVER (PARTITION BY dm.cean, dm.inrosuc ORDER BY dm.iVersion DESC, dm.iid DESC)
  -> rn = 1 -> filtros -> ORDER BY iid OFFSET/FETCH

LIKE con ESCAPE '\'; count sobre el mismo conjunto filtrado; un envase por artículo. Rendimiento medido: 80.000 filas en una sucursal → searchBy 175 ms, countSearchBy 67 ms.

  • A6 — controller/ArticuloCacheAdminController.java (202/409/503).
  • A7 — CacheConfig/AppProperties (expiración configurable).
  • A12 — pom.xml (pins de WebSocket/Messaging a 6.2.18).
  • A14 — proyecciones/rows/DTOs/RowMapper (getLong+wasNull); controller/GlobalRestExceptionHandler.java (DataAccessException→503, otras→500, sobre ResponseMessage, sin SQL ni stack trace al cliente); @MessageExceptionHandler en ArticuloWebSocketController; EnvaseDto trim; RefreshStatusSnapshot.loadedEntries = claves distintas.

A16 Tanda 2, post-revisión Codex (B24)

Ninguna de las 4 consultas de ArticulosCacheLoadRepositoryImpl usa WITH(NOLOCK): en las dos consultas preexistentes (lote base y envases) el hint fue eliminado; las otras dos (on-miss TOP (1) y CTE de búsqueda) son consultas nuevas de esta rama, creadas inicialmente con el hint heredado y corregidas en A16. Las 7 consultas JPA originales con NOLOCK de ArticulosRepository fueron eliminadas o migradas (A4/A9). Las lecturas pasan a READ COMMITTED (default de la BD, RCSI desactivado). Durante la recarga nocturna la consulta espera al commit; si supera cache-query-timeout-seconds (60 s) el refresh queda FAILED, refreshRunning se libera y la caché anterior sigue activa. Un miss en esa ventana recibe 503 (sobre ResponseMessage) en lugar de un "no encontrado" falso. Costo: lecturas bloqueadas unos segundos durante el completo, una vez por noche.

Archivo: ArticulosCacheLoadRepositoryImpl.java, líneas ~58, ~76, ~104, ~154.

Nuevas propiedades (defaults = comportamiento actual)

app.articulo.cache-query-timeout-seconds Default 60 s; ≤0 desactiva el timeout.
app.articulo.cache-expire-after-write-minutes Default 240 min; ≤0 desactiva la expiración.

4. Testing agregado

Antes → ahora

Métrica Antes Ahora
Pruebas unitarias 63 196
Integración con SQL real 0 67 (14 clases)
PIT (mutation testing) 166/248 (67 %) 309/315 (98,1 %)

PIT por clase

Clase Antes Ahora
ArticuloDto 36/52 52/52
EnvaseDto 4/9 11/11
ArticuloProjection 5/8 6/6
ArticuloProjectionRow 2/2 (nuevo)
ArticuloService 19/32 31/32
ArticuloCacheService 96/147 158/163
GlobalRestExceptionHandler 3/3 (nuevo)
ArticulosCacheLoadRepositoryImpl 0/46 (sin cobertura) 46/46 (100 %)

Perfil pit con mutadores STRONGER. El SQL en sí no es mutable por PIT (cadenas constantes) y sigue cubierto por las 66 pruebas con SQL real; la lógica Java de la clase (mapeo, normalización, escape de LIKE, resolución de envases, binding de parámetros) queda al 100 %.

Cobertura nueva vía ArticulosCacheLoadRepositoryImplTest (44 pruebas, sin BD): RowMapper con ResultSet mockeado, incluyendo coerciones NULL→0/false y iVersion > Integer.MAX_VALUE como Long; normalize; simetría de buildEnvaseLookupKey; escape de toLikePattern; putIfAbsent primero-gana en findEnvasesBatchForCacheLoad; binding de parámetros capturado para findArticuloForCacheMiss; lote base; searchBy/countSearchBy/findArticulos.

6 mutantes vivos (preexistentes, ninguno de las tandas)

Mutantes vivos: por qué no es 100 %

Conclusión: los 6 son equivalentes o inalcanzables; ninguno afecta precios.

Infraestructura de las IT

Perfil Maven integration-sql (maven-failsafe, *IT.java), SQL Server local, base descartable TsArticulosIT, DDL copiado de la estructura real de TipreRetail.dbo.dm_artic (solo lectura de metadatos), cada sentencia destructiva verifica DB_NAME()='TsArticulosIT'. Conexión por variables de entorno TSART_IT_DB_URL (default jdbc:sqlserver://localhost:1433;encrypt=false;trustServerCertificate=true), TSART_IT_DB_USER (default sa) y TSART_IT_DB_PASSWORD (sin default). La omisión (JUnit Assumptions) depende únicamente de que TSART_IT_DB_PASSWORD no esté definida.

Nota

Las ITs viven en src/test/java y compilan siempre con mvn test-compile; Surefire las excluye (*IT) y el perfil integration-sql habilita su ejecución con Failsafe.

Clases IT nuevas

Prueba unitaria de regresión (A16)

ArticulosCacheLoadRepositoryImplNoLockRegressionTest — verifica que ninguna constante SQL de la clase contiene NOLOCK.

Comandos

Verificación completa (unitarias)
mvn -o verify
Integración con SQL real
TSART_IT_DB_USER=<usuario> TSART_IT_DB_PASSWORD=<clave> mvn -o -Pintegration-sql verify
Mutation testing (PIT)
mvn -o test-compile org.pitest:pitest-maven:mutationCoverage -Ppit
(reportes en target/pit-reports/)
Atención

mvnw está roto (falta .mvn/wrapper/maven-wrapper.properties): usar el Maven del sistema.

5. Revisión adversarial (judgment-day)

Dos jueces ciegos en paralelo revisan el mismo diff sin verse entre sí; solo se corrige lo que ambos confirman de forma independiente; luego se vuelve a juzgar sobre el resultado.

Tanda 1

Tanda 2

Veredicto final

JUDGMENT: APPROVED — tanda 1 (3 rondas) y tanda 2 (3 rondas).

6. Revisión adversarial independiente (Codex, 22/09/2026)

Se pidió a un segundo modelo (OpenAI Codex), sin acceso a nuestras conclusiones (prompt en prompt-codex-adversarial-review.md), una revisión adversarial de la rama. Veredicto de Codex: REJECT (3 hallazgos). Cada hallazgo fue contrastado contra el código y contra 860.328 filas de una copia local de TipreRetail (consultas de solo lectura).

Segunda pasada de Codex (documentación)

Seis imprecisiones señaladas en este mismo reporte (diff acumulado, alcance de la búsqueda de credencial, compilación de las ITs, gating por variables de entorno, estado de B22 y redacción de A16 sobre WITH(NOLOCK)); todas verificadas y corregidas en esta versión.

Hallazgo Severidad Codex Evidencia Veredicto final
Clave de caché ean|sucursal sensible a mayúsculas; "puede devolver la versión vieja". CRITICAL EANs solo dígitos (0 filas con letras); un miss por diferencia de mayúsculas iría a la base y devolvería la fila vigente (TOP 1 ORDER BY iVersion DESC, iid DESC); a lo sumo una entrada duplicada en caché. Teórico e inofensivo; la consecuencia descrita no puede ocurrir.
Clave de envase: cEnganche vs iArticulo con distinta capitalización coinciden en SQL (colación CI) pero no en la clave Java. WARNING (real) 769 artículos con enganche, 769 resuelven por join; diferencias de mayúsculas: 0; de espacios: 0 (cubiertas por A15). Confirmado contra 769 casos reales (= B22, caracterizado en la sección 2); 0 de 769 tiene la variación de mayúsculas hoy. Comportamiento actual documentado; fix propuesto.
Miss con referencia a la caché vieja + swap → "responde el valor viejo". WARNING (real) La caché activa se resuelve después de leer la base; peor caso: escritura perdida en la caché que se invalida y relectura en el siguiente lookup; el valor devuelto es la fila recién leída o una cacheada con mayor (iVersion, iid), nunca más vieja. Inofensivo; ya evaluado en judgment-day tanda 1.

Sospechas de Codex con valor real

B24 Aplicado en rama (ver A16)

WITH(NOLOCK) durante la recarga nocturna: SrvPOS hace DELETE + INSERT del bloque de la sucursal dentro de una transacción (SrvPOS_Artic_Novedades.cpp 959–1090); un preload disparado por el cron en ese intervalo puede cargar un bloque incompleto y activarlo hasta el siguiente refresh; un miss puede ver transitoriamente "no encontrado". Riesgo preexistente, no introducido por la rama.

Fix aplicada en rama (ver A16): preload y miss con READ COMMITTED (el timeout de 60 s mantiene la caché anterior si bloquea).

B25 Solo propuesto Baja

Entidad JPA Articulo con iVersion Integer (model/Articulo.java:42); la entidad no tiene uso funcional tras A9; unificar a Long o eliminarla.

7. Cambios visibles para el despliegue

Recomendación operativa inmediata en Dino

app.articulo.cache-refresh-cron cada 30 minutos.

8. Pendientes / decisiones