From be3f94eab5c7b47d84d3f4805fa8b173183842fa Mon Sep 17 00:00:00 2001 From: Eduardo Fonseca Ribeiro Date: Wed, 23 Sep 2026 11:11:40 -0300 Subject: [PATCH 1/3] feat(api): adiciona reprocessamento de jobs arquivados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cria novo job a partir da última regra de um job arquivado - preserva representação, vínculos de origem e versionamento da regra - permite herdar ou ajustar competências e orçamento - publica regra-submetida com origem reprocessamento pelo outbox - adapta consulta e confirmação para jobs originados de reprocessamento - adiciona cobertura de persistência, atomicidade e preservação de especificações --- .../synapse/api/job/BuscarJobService.java | 17 +- .../api/job/ConfirmarParametrosService.java | 52 +- .../java/synapse/api/job/CriarJobService.java | 2 +- .../java/synapse/api/job/HashDaRegra.java | 2 + .../java/synapse/api/job/JobCriadoDto.java | 10 +- .../synapse/api/job/ReprocessarJobAdvice.java | 34 ++ .../api/job/ReprocessarJobController.java | 30 ++ .../api/job/ReprocessarJobException.java | 39 ++ .../api/job/ReprocessarJobRequisicao.java | 75 +++ .../api/job/ReprocessarJobService.java | 102 ++++ .../main/java/synapse/api/job/TiposDeJob.java | 12 +- .../ConfirmarParametrosControllerTests.java | 13 +- .../api/job/CriarJobControllerTests.java | 2 +- .../api/job/ExecutarAcaoControllerTests.java | 2 +- .../synapse/api/job/HashDaRegraTests.java | 38 ++ .../job/ReprocessarJobControllerTests.java | 131 +++++ .../job/ReprocessarJobPersistenciaTests.java | 458 ++++++++++++++++++ 17 files changed, 977 insertions(+), 42 deletions(-) create mode 100644 api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java create mode 100644 api/src/main/java/synapse/api/job/ReprocessarJobController.java create mode 100644 api/src/main/java/synapse/api/job/ReprocessarJobException.java create mode 100644 api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java create mode 100644 api/src/main/java/synapse/api/job/ReprocessarJobService.java create mode 100644 api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java create mode 100644 api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java diff --git a/api/src/main/java/synapse/api/job/BuscarJobService.java b/api/src/main/java/synapse/api/job/BuscarJobService.java index 40dcf40..cb892ca 100644 --- a/api/src/main/java/synapse/api/job/BuscarJobService.java +++ b/api/src/main/java/synapse/api/job/BuscarJobService.java @@ -7,6 +7,7 @@ import java.util.UUID; import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; @@ -19,7 +20,9 @@ class BuscarJobService { private final JdbcTemplate jdbc; - private final JsonMapper json = new JsonMapper(); + private final JsonMapper json = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); BuscarJobService(JdbcTemplate jdbc) { this.jdbc = jdbc; @@ -31,13 +34,14 @@ JobDetalhadoDto buscar(UUID jobId) { SELECT j.id, j.status, - s.tipo AS origem, + CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem, j.competencias, j.orcamento, j.criado_em, j.iniciado_em, j.finalizado_em, j.submissao_id, + j.job_origem_id, regras.regras, sim.id AS simulacao_id, @@ -51,7 +55,7 @@ JobDetalhadoDto buscar(UUID jobId) { rs.decomposicao AS simulacao_decomposicao FROM jobs j - JOIN submissoes s ON s.id = j.submissao_id + LEFT JOIN submissoes s ON s.id = j.submissao_id JOIN LATERAL ( SELECT jsonb_agg( jsonb_build_object( @@ -87,6 +91,7 @@ LEFT JOIN LATERAL ( Instant finalizadoEm = rs.getTimestamp("finalizado_em") != null ? rs.getTimestamp("finalizado_em").toInstant() : null; UUID submissaoId = rs.getObject("submissao_id", UUID.class); + UUID jobOrigemId = rs.getObject("job_origem_id", UUID.class); List regras = regras(rs.getString("regras")); @@ -125,7 +130,7 @@ LEFT JOIN LATERAL ( } return new JobDetalhadoDto(id, status, origem, competencias, orcamento, criadoEm, iniciadoEm, - finalizadoEm, submissaoId, regras, simulacao); + finalizadoEm, submissaoId, jobOrigemId, regras, simulacao); }, jobId); } catch (EmptyResultDataAccessException ex) { @@ -138,9 +143,7 @@ private List regras(String regrasJson) { List regras = new ArrayList<>(); for (JsonNode regra : raiz) { NucleoRegraDto nucleo = this.json.readValue(regra.path("nucleo").toString(), NucleoRegraDto.class); - List especificacoes = this.json.readValue(regra.path("especificacoes").toString(), - new TypeReference>() { - }); + List especificacoes = regra.path("especificacoes").valueStream().toList(); RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(nucleo, especificacoes); regras.add(new RegraCriadaDto(UUID.fromString(regra.path("id").asString()), regra.path("versao").asInt(), regra.path("origem").asString(), representacao, Instant.parse(regra.path("criada_em").asString()))); diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java index 85322d1..73fefd3 100644 --- a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java +++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java @@ -13,6 +13,8 @@ import java.util.UUID; import org.jspecify.annotations.Nullable; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; import org.springframework.dao.EmptyResultDataAccessException; @@ -40,7 +42,9 @@ class ConfirmarParametrosService { private final Outbox outbox; - private final JsonMapper json = new JsonMapper(); + private final JsonMapper json = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); ConfirmarParametrosService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) { this.jdbc = jdbc; @@ -54,7 +58,8 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) { this.maquina.transicionar(jobId, JobStatus.GERANDO_REGRA, "usuario", null); VersaoAnterior anterior = ultimaVersao(jobId); - RepresentacaoRegraDto representacao = requisicao.representacao(); + RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(requisicao.representacao().nucleo(), + anterior != null ? anterior.especificacoes() : List.of()); String hash = HashDaRegra.calcular(representacao); boolean editado = anterior != null && !hash.equals(anterior.hash()); @@ -69,23 +74,24 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) { new ParametrosConfirmadosDto(jobId, versao.id())); registrarTrilha(jobId, versao.id(), editado, representacao.nucleo(), anterior, timestamp); - RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), "confirmacao_usuario", representacao, + RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), versao.origem(), representacao, versao.criadaEm()); return new JobCriadoDto(jobId, JobStatus.GERANDO_REGRA.paraColuna(), dados.origem(), competencias, orcamento, - dados.criadoEm(), dados.submissaoId(), regra); + dados.criadoEm(), dados.submissaoId(), dados.jobOrigemId(), regra); } private DadosDoJob carregarJob(UUID jobId) { try { return Objects.requireNonNull(this.jdbc.queryForObject(""" - SELECT s.tipo AS origem, j.orcamento, j.criado_em, j.submissao_id - FROM jobs j JOIN submissoes s ON s.id = j.submissao_id + SELECT CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem, + j.orcamento, j.criado_em, j.submissao_id, j.job_origem_id + FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id WHERE j.id = ? """, (rs, linha) -> new DadosDoJob(Objects.requireNonNull(rs.getString("origem")), Objects.requireNonNull(rs.getBigDecimal("orcamento")), Objects.requireNonNull(rs.getTimestamp("criado_em")).toInstant(), - Objects.requireNonNull(rs.getObject("submissao_id", UUID.class))), + rs.getObject("submissao_id", UUID.class), rs.getObject("job_origem_id", UUID.class)), jobId)); } catch (EmptyResultDataAccessException ex) { @@ -94,12 +100,16 @@ private DadosDoJob carregarJob(UUID jobId) { } private @Nullable VersaoAnterior ultimaVersao(UUID jobId) { - List versoes = this.jdbc.query(""" - SELECT id, versao, hash, nucleo FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1 - """, + List versoes = this.jdbc.query( + """ + SELECT id, versao, hash, nucleo, especificacoes FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1 + """, (rs, linha) -> new VersaoAnterior(Objects.requireNonNull(rs.getObject("id", UUID.class)), rs.getInt("versao"), Objects.requireNonNull(rs.getString("hash")), - this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class)), + this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class), + this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes"))) + .valueStream() + .toList()), jobId); return versoes.isEmpty() ? null : versoes.getFirst(); } @@ -107,10 +117,11 @@ private DadosDoJob carregarJob(UUID jobId) { private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representacao, String hash, @Nullable VersaoAnterior anterior, Timestamp timestamp, Instant agora) { List existentes = this.jdbc.query(""" - SELECT id, versao, criada_em FROM regras WHERE job_id = ? AND hash = ? + SELECT id, versao, origem, criada_em FROM regras WHERE job_id = ? AND hash = ? """, (rs, linha) -> new VersaoRegra(Objects.requireNonNull(rs.getObject("id", UUID.class)), - rs.getInt("versao"), Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()), + rs.getInt("versao"), Objects.requireNonNull(rs.getString("origem")), + Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()), jobId, hash); if (!existentes.isEmpty()) { return existentes.getFirst(); @@ -119,10 +130,11 @@ private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representac UUID origemId = (anterior != null) ? anterior.id() : null; UUID id = Objects.requireNonNull(this.jdbc.queryForObject(""" INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) - VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, '[]'::jsonb, ?, ?) RETURNING id + VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, + COALESCE((SELECT especificacoes FROM regras WHERE id = ?), '[]'::jsonb), ?, ?) RETURNING id """, UUID.class, jobId, novaVersao, origemId, this.json.writeValueAsString(representacao.nucleo()), - hash, timestamp)); - return new VersaoRegra(id, novaVersao, agora); + origemId, hash, timestamp)); + return new VersaoRegra(id, novaVersao, "confirmacao_usuario", agora); } private BigDecimal resolverOrcamento(UUID jobId, ConfirmarParametrosRequisicao requisicao, BigDecimal atual) { @@ -189,13 +201,15 @@ private static boolean percentualDiferente(@Nullable BigDecimal atual, @Nullable return atual.compareTo(anterior) != 0; } - private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, UUID submissaoId) { + private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, @Nullable UUID submissaoId, + @Nullable UUID jobOrigemId) { } - private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo) { + private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo, + List especificacoes) { } - private record VersaoRegra(UUID id, int versao, Instant criadaEm) { + private record VersaoRegra(UUID id, int versao, String origem, Instant criadaEm) { } } diff --git a/api/src/main/java/synapse/api/job/CriarJobService.java b/api/src/main/java/synapse/api/job/CriarJobService.java index 26541bc..3047768 100644 --- a/api/src/main/java/synapse/api/job/CriarJobService.java +++ b/api/src/main/java/synapse/api/job/CriarJobService.java @@ -67,7 +67,7 @@ INSERT INTO regras (job_id, versao, origem, nucleo, especificacoes, hash, criada this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA, new RegraSubmetidaDto(jobId, requisicao.origem(), requisicao.competencias(), submissaoId, regraId)); return new JobCriadoDto(jobId, status, requisicao.origem(), requisicao.competencias(), requisicao.orcamento(), - agora, submissaoId, regra); + agora, submissaoId, null, regra); } } diff --git a/api/src/main/java/synapse/api/job/HashDaRegra.java b/api/src/main/java/synapse/api/job/HashDaRegra.java index 70534dc..5d3845b 100644 --- a/api/src/main/java/synapse/api/job/HashDaRegra.java +++ b/api/src/main/java/synapse/api/job/HashDaRegra.java @@ -8,6 +8,7 @@ import tools.jackson.core.StreamWriteFeature; import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.cfg.JsonNodeFeature; import tools.jackson.databind.json.JsonMapper; final class HashDaRegra { @@ -16,6 +17,7 @@ final class HashDaRegra { .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) .disable(MapperFeature.SORT_CREATOR_PROPERTIES_FIRST) .enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN) + .enable(JsonNodeFeature.WRITE_PROPERTIES_SORTED) .build(); private HashDaRegra() { diff --git a/api/src/main/java/synapse/api/job/JobCriadoDto.java b/api/src/main/java/synapse/api/job/JobCriadoDto.java index 1b17e58..39c25fc 100644 --- a/api/src/main/java/synapse/api/job/JobCriadoDto.java +++ b/api/src/main/java/synapse/api/job/JobCriadoDto.java @@ -4,10 +4,12 @@ import java.time.Instant; import java.util.List; import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonInclude; import org.jspecify.annotations.Nullable; record JobCriadoDto(UUID id, String status, String origem, List competencias, BigDecimal orcamento, - Instant criado_em, UUID submissao_id, RegraCriadaDto regra) { + Instant criado_em, @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id, + @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, RegraCriadaDto regra) { } record RegraCriadaDto(UUID id, int versao, String origem, RepresentacaoRegraDto representacao, Instant criada_em) { @@ -18,6 +20,8 @@ record SimulacaoDto(UUID id, Instant criado_em, @Nullable String status, @Nullab } record JobDetalhadoDto(UUID id, String status, String origem, List competencias, BigDecimal orcamento, - Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em, UUID submissao_id, - List regras, @Nullable SimulacaoDto simulacao) { + Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em, + @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id, + @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, List regras, + @Nullable SimulacaoDto simulacao) { } diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java b/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java new file mode 100644 index 0000000..83cfebc --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java @@ -0,0 +1,34 @@ +package synapse.api.job; + +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ReprocessarJobController.class) +class ReprocessarJobAdvice { + + @ExceptionHandler(ReprocessarJobException.class) + ResponseEntity tratar(ReprocessarJobException ex) { + return ResponseEntity.status(ex.status()).body(ex.erro()); + } + + @ExceptionHandler(JobNaoEncontradoException.class) + ResponseEntity jobNaoEncontrado() { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErroDto("job_nao_encontrado", "Job não encontrado.")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + ResponseEntity corpoInvalido() { + return tratar(ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido.")); + } + + @ExceptionHandler(DataAccessException.class) + ResponseEntity falhaDePersistencia() { + return ResponseEntity.internalServerError().build(); + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobController.java b/api/src/main/java/synapse/api/job/ReprocessarJobController.java new file mode 100644 index 0000000..9c59521 --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobController.java @@ -0,0 +1,30 @@ +package synapse.api.job; + +import java.net.URI; +import java.util.UUID; + +import org.jspecify.annotations.Nullable; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class ReprocessarJobController { + + private final ReprocessarJobService service; + + ReprocessarJobController(ReprocessarJobService service) { + this.service = service; + } + + @PostMapping(path = "/jobs/{id}/reprocessar", produces = "application/json") + ResponseEntity reprocessar(@PathVariable("id") UUID id, + @RequestBody(required = false) @Nullable String corpo) { + JobCriadoDto job = this.service.reprocessar(id, ReprocessarJobRequisicao.deJson(corpo)); + return ResponseEntity.created(URI.create("/api/jobs/" + job.id())).body(job); + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobException.java b/api/src/main/java/synapse/api/job/ReprocessarJobException.java new file mode 100644 index 0000000..a6d6d9c --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobException.java @@ -0,0 +1,39 @@ +package synapse.api.job; + +import org.springframework.http.HttpStatus; + +class ReprocessarJobException extends RuntimeException { + + private final HttpStatus status; + + private final ErroDto erro; + + private ReprocessarJobException(HttpStatus status, String codigo, String mensagem) { + super(mensagem); + this.status = status; + this.erro = new ErroDto(codigo, mensagem); + } + + static ReprocessarJobException requisicao(String mensagem) { + return new ReprocessarJobException(HttpStatus.BAD_REQUEST, "requisicao_invalida", mensagem); + } + + static ReprocessarJobException estadoInvalido() { + return new ReprocessarJobException(HttpStatus.CONFLICT, "estado_invalido", + "Somente um job arquivado pode ser reprocessado."); + } + + static ReprocessarJobException semRegra() { + return new ReprocessarJobException(HttpStatus.CONFLICT, "estado_invalido", + "O job arquivado não possui uma regra formada para reprocessamento."); + } + + HttpStatus status() { + return this.status; + } + + ErroDto erro() { + return this.erro; + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java b/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java new file mode 100644 index 0000000..da95b91 --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobRequisicao.java @@ -0,0 +1,75 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +record ReprocessarJobRequisicao(@Nullable BigDecimal orcamento, @Nullable List competencias) { + + private static final List MESES = List.of("2025-07", "2025-08", "2025-09", "2025-10", "2025-11", "2025-12"); + + private static final JsonMapper JSON = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + + static ReprocessarJobRequisicao deJson(@Nullable String corpo) { + if (corpo == null) { + return new ReprocessarJobRequisicao(null, null); + } + JsonNode raiz; + try { + raiz = JSON.readTree(corpo); + } + catch (JacksonException ex) { + throw ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido."); + } + if (raiz == null || !raiz.isObject()) { + throw ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido."); + } + return new ReprocessarJobRequisicao(orcamento(raiz.path("orcamento")), competencias(raiz)); + } + + private static @Nullable BigDecimal orcamento(JsonNode orcamento) { + if (orcamento.isMissingNode() || orcamento.isNull()) { + return null; + } + if (!orcamento.isNumber()) { + throw ReprocessarJobException.requisicao("O campo orcamento precisa ser um número."); + } + return orcamento.decimalValue(); + } + + private static @Nullable List competencias(JsonNode raiz) { + if (!raiz.has("competencias") || raiz.path("competencias").isNull()) { + return null; + } + JsonNode meses = raiz.path("competencias"); + if (!meses.isArray()) { + throw ReprocessarJobException.requisicao("O campo competencias precisa ser uma lista de meses."); + } + List competencias = new ArrayList<>(); + for (JsonNode mes : meses) { + if (!mes.isString()) { + throw ReprocessarJobException + .requisicao("Cada competência precisa ser um mês entre 2025-07 e 2025-12."); + } + competencias.add(mes.asString()); + } + if (competencias.isEmpty() || !MESES.containsAll(competencias)) { + throw ReprocessarJobException + .requisicao("Informe competências entre 2025-07 e 2025-12, em uma lista não vazia."); + } + if (new HashSet<>(competencias).size() != competencias.size()) { + throw ReprocessarJobException.requisicao("O campo competencias não permite meses repetidos."); + } + return competencias.stream().sorted().toList(); + } + +} diff --git a/api/src/main/java/synapse/api/job/ReprocessarJobService.java b/api/src/main/java/synapse/api/job/ReprocessarJobService.java new file mode 100644 index 0000000..79af417 --- /dev/null +++ b/api/src/main/java/synapse/api/job/ReprocessarJobService.java @@ -0,0 +1,102 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.SqlArrayValue; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import synapse.api.core.outbox.EventoOutbox; +import synapse.api.core.outbox.Outbox; + +@Service +class ReprocessarJobService { + + private final JdbcTemplate jdbc; + + private final MaquinaDeEstadosDoJob maquina; + + private final Outbox outbox; + + private final JsonMapper json = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); + + ReprocessarJobService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) { + this.jdbc = jdbc; + this.maquina = maquina; + this.outbox = outbox; + } + + @Transactional + JobCriadoDto reprocessar(UUID origemId, ReprocessarJobRequisicao requisicao) { + JobDeOrigem origem = carregarOrigem(origemId); + if (origem.status() != JobStatus.ARQUIVADO) { + throw ReprocessarJobException.estadoInvalido(); + } + List regras = this.jdbc.queryForList(""" + SELECT id FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1 + """, UUID.class, origemId); + if (regras.isEmpty()) { + throw ReprocessarJobException.semRegra(); + } + List competencias = requisicao.competencias() != null ? requisicao.competencias() + : origem.competencias(); + BigDecimal orcamento = requisicao.orcamento() != null ? requisicao.orcamento() : origem.orcamento(); + Instant agora = Instant.now().truncatedTo(ChronoUnit.MICROS); + Timestamp timestamp = Timestamp.from(agora); + JobStatus status = JobStatus.AGUARDANDO_CONFIRMACAO_PARAMETROS; + UUID jobId = Objects.requireNonNull(this.jdbc.queryForObject(""" + INSERT INTO jobs (status, usuario_id, job_origem_id, competencias, orcamento, criado_em) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id + """, UUID.class, status.paraColuna(), origem.usuarioId(), origemId, + new SqlArrayValue("text", competencias.toArray()), orcamento, timestamp)); + this.maquina.registrarCriacao(jobId, status, "usuario"); + RegraCriadaDto regra = Objects.requireNonNull(this.jdbc.queryForObject(""" + INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) + SELECT ?, 1, 'reprocessamento', id, nucleo, especificacoes, hash, ? + FROM regras WHERE id = ? + RETURNING id, nucleo, especificacoes + """, (rs, linha) -> new RegraCriadaDto(Objects.requireNonNull(rs.getObject("id", UUID.class)), 1, + "reprocessamento", + new RepresentacaoRegraDto( + this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class), + this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes"))) + .valueStream() + .toList()), + agora), jobId, timestamp, regras.getFirst())); + this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA, + new RegraSubmetidaDto(jobId, "reprocessamento", competencias, null, regra.id())); + return new JobCriadoDto(jobId, status.paraColuna(), "reprocessamento", competencias, orcamento, agora, null, + origemId, regra); + } + + private JobDeOrigem carregarOrigem(UUID jobId) { + List jobs = this.jdbc.query(""" + SELECT status, usuario_id, competencias, orcamento FROM jobs WHERE id = ? + """, + (rs, linha) -> new JobDeOrigem(JobStatus.deColuna(Objects.requireNonNull(rs.getString("status"))), + Objects.requireNonNull(rs.getObject("usuario_id", UUID.class)), + List.of((String[]) rs.getArray("competencias").getArray()), + Objects.requireNonNull(rs.getBigDecimal("orcamento"))), + jobId); + if (jobs.isEmpty()) { + throw new JobNaoEncontradoException(jobId); + } + return jobs.getFirst(); + } + + private record JobDeOrigem(JobStatus status, UUID usuarioId, List competencias, BigDecimal orcamento) { + } + +} diff --git a/api/src/main/java/synapse/api/job/TiposDeJob.java b/api/src/main/java/synapse/api/job/TiposDeJob.java index 8fa0e24..7d58ae5 100644 --- a/api/src/main/java/synapse/api/job/TiposDeJob.java +++ b/api/src/main/java/synapse/api/job/TiposDeJob.java @@ -5,6 +5,7 @@ import java.util.Map; import org.jspecify.annotations.Nullable; +import tools.jackson.databind.JsonNode; record VigenciaDto( @@ -46,18 +47,11 @@ record NucleoRegraDto( @Nullable BigDecimal percentual) { } -record EspecificacaoRegraDto( - - String ref, - - String construto) { -} - record RepresentacaoRegraDto( NucleoRegraDto nucleo, - List especificacoes) { + List especificacoes) { } record TotaisSimulacaoDto( @@ -102,4 +96,4 @@ record ResultadoSimulacaoDto( List assercoes, @Nullable DecomposicaoResultadoDto decomposicao) { -} \ No newline at end of file +} diff --git a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java index b84f100..a0665c1 100644 --- a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java +++ b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java @@ -46,7 +46,7 @@ void preparar() { void confirmaResponde202ComRegraNaVersaoNova() throws Exception { when(this.service.confirmar(any(), any())).thenReturn(new JobCriadoDto(JOB, "gerando_regra", "formulario", List.of("2025-11"), new BigDecimal("485000.0"), Instant.parse("2026-09-20T10:00:00Z"), - UUID.randomUUID(), + UUID.randomUUID(), null, new RegraCriadaDto(UUID.randomUUID(), 2, "confirmacao_usuario", new RepresentacaoRegraDto(new NucleoRegraDto(new VigenciaDto("2025-11", "2025-11"), List.of("13"), List.of("10", "20"), List.of("100", "300"), new BigDecimal("0.03")), @@ -61,6 +61,17 @@ void confirmaResponde202ComRegraNaVersaoNova() throws Exception { .andExpect(jsonPath("$.regra.representacao.nucleo.percentual").value(0.03)); } + @Test + void naoPermiteEditarEspecificacoes() throws Exception { + this.mvc + .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) + .content(CONFIRMAR.replace("\"especificacoes\":[]", + "\"especificacoes\":[{\"ref\":\"elem.1\",\"construto\":\"generico\"}]"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.codigo").value("requisicao_invalida")); + verifyNoInteractions(this.service); + } + @Test void corpoInvalidoResponde400() throws Exception { this.mvc.perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON).content("{")) diff --git a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java index 2b3e167..c7998bb 100644 --- a/api/src/test/java/synapse/api/job/CriarJobControllerTests.java +++ b/api/src/test/java/synapse/api/job/CriarJobControllerTests.java @@ -54,7 +54,7 @@ void semPrincipalResponde201ComLocationEExatamenteOsCamposIniciais() throws Exce when(this.service.criar(any())).thenAnswer(invocacao -> { CriarJobRequisicao requisicao = invocacao.getArgument(0); return new JobCriadoDto(jobId, "gerando_regra", "formulario", requisicao.competencias(), - requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(), + requisicao.orcamento(), Instant.parse("2026-09-16T15:00:00Z"), UUID.randomUUID(), null, new RegraCriadaDto(UUID.randomUUID(), 1, "confirmacao_usuario", requisicao.representacao(), Instant.parse("2026-09-16T15:00:00Z"))); }); diff --git a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java index 6bf6c5c..7cca189 100644 --- a/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java +++ b/api/src/test/java/synapse/api/job/ExecutarAcaoControllerTests.java @@ -56,7 +56,7 @@ private static JobDetalhadoDto jobComStatus(String status) { new RepresentacaoRegraDto(nucleo, List.of()), Instant.parse("2026-09-16T15:00:00Z")); return new JobDetalhadoDto(JOB_ID, status, "formulario", List.of("2025-11"), new BigDecimal("485000"), Instant.parse("2026-09-16T15:00:00Z"), null, Instant.parse("2026-09-18T10:00:00Z"), UUID.randomUUID(), - List.of(regra), null); + null, List.of(regra), null); } @ParameterizedTest diff --git a/api/src/test/java/synapse/api/job/HashDaRegraTests.java b/api/src/test/java/synapse/api/job/HashDaRegraTests.java index 904d853..2aa1bc3 100644 --- a/api/src/test/java/synapse/api/job/HashDaRegraTests.java +++ b/api/src/test/java/synapse/api/job/HashDaRegraTests.java @@ -1,11 +1,49 @@ package synapse.api.job; import org.junit.jupiter.api.Test; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; import static org.assertj.core.api.Assertions.assertThat; class HashDaRegraTests { + private static final JsonMapper JSON = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); + + @Test + void hashIncluiCamposExtensiveisSemPerderPrecisao() { + String especificacoes = """ + [{"ref":"elem.1","construto":"faixa_valor","efeito":{"tipo":"bonus_fixo", + "valor":3500.1234567890123456789},"extensao":{"ativo":true}}] + """; + String hash = HashDaRegra.calcular(comEspecificacoes(especificacoes)); + assertThat(hash).isNotEqualTo(HashDaRegra.calcular(comEspecificacoes(especificacoes.replace("true", "false")))) + .isNotEqualTo(HashDaRegra.calcular(comEspecificacoes(especificacoes.replace("6789", "6788")))); + } + + @Test + void ordemDeCamposExtensiveisNaoCriaUmaRepresentacaoDiferente() { + String primeira = """ + [{"ref":"elem.1","construto":"faixa_valor","efeito":{"tipo":"bonus_fixo","valor":3500.125}, + "extensao":{"criterios":["a","b"],"ativo":true}}] + """; + String equivalente = """ + [{"extensao":{"ativo":true,"criterios":["a","b"]},"efeito":{"valor":3500.125,"tipo":"bonus_fixo"}, + "construto":"faixa_valor","ref":"elem.1"}] + """; + assertThat(HashDaRegra.calcular(comEspecificacoes(primeira))) + .isEqualTo(HashDaRegra.calcular(comEspecificacoes(equivalente))); + } + + private static RepresentacaoRegraDto comEspecificacoes(String especificacoes) { + var nucleo = CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao().nucleo(); + return JSON.readValue( + "{\"nucleo\":" + JSON.writeValueAsString(nucleo) + ",\"especificacoes\":" + especificacoes + "}", + RepresentacaoRegraDto.class); + } + @Test void ignoraOrdemDasPropriedadesEscalaDecimalEConteudoForaDaRegra() { String original = CriarJobControllerTests.FORMULARIO; diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java new file mode 100644 index 0000000..3370d5b --- /dev/null +++ b/api/src/test/java/synapse/api/job/ReprocessarJobControllerTests.java @@ -0,0 +1,131 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +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; + +class ReprocessarJobControllerTests { + + private static final UUID ORIGEM = UUID.randomUUID(); + + private static final UUID NOVO = UUID.randomUUID(); + + private final ReprocessarJobService service = mock(ReprocessarJobService.class); + + private MockMvc mvc; + + @BeforeEach + void preparar() { + this.mvc = MockMvcBuilders.standaloneSetup(new ReprocessarJobController(this.service)) + .setControllerAdvice(new ReprocessarJobAdvice()) + .build(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "{}", "{\"orcamento\":null,\"competencias\":null}" }) + void corpoOpcionalCriaJobComProcedenciaERegra(String corpo) throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenReturn(novoJob()); + var pedido = post("/jobs/{id}/reprocessar", ORIGEM); + if (!corpo.isEmpty()) { + pedido.contentType(MediaType.APPLICATION_JSON).content(corpo); + } + String resposta = this.mvc.perform(pedido) + .andExpect(status().isCreated()) + .andExpect(header().string("Location", "/api/jobs/" + NOVO)) + .andExpect(jsonPath("$.id").value(NOVO.toString())) + .andExpect(jsonPath("$.status").value("aguardando_confirmacao_parametros")) + .andExpect(jsonPath("$.origem").value("reprocessamento")) + .andExpect(jsonPath("$.job_origem_id").value(ORIGEM.toString())) + .andExpect(jsonPath("$.regra.versao").value(1)) + .andReturn() + .getResponse() + .getContentAsString(); + assertThat(new JsonMapper().readTree(resposta).propertyNames()).containsExactlyInAnyOrder("id", "status", + "origem", "competencias", "orcamento", "criado_em", "job_origem_id", "regra"); + verify(this.service).reprocessar(eq(ORIGEM), argThat(r -> r.orcamento() == null && r.competencias() == null)); + } + + @Test + void overridesPreservamPrecisaoEOrdenamCompetencias() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenReturn(novoJob()); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM).contentType(MediaType.APPLICATION_JSON).content(""" + {"orcamento":-0.1234567890123456789,"competencias":["2025-12","2025-07","2025-09"]} + """)).andExpect(status().isCreated()); + verify(this.service).reprocessar(eq(ORIGEM), + argThat(r -> r.orcamento() != null + && new BigDecimal("-0.1234567890123456789").compareTo(r.orcamento()) == 0 + && List.of("2025-07", "2025-09", "2025-12").equals(r.competencias()))); + } + + @ParameterizedTest + @ValueSource(strings = { "{", "null", "[]", "42", "{} {}", "{\"orcamento\":\"10\"}", "{\"competencias\":[]}", + "{\"competencias\":\"2025-11\"}", "{\"competencias\":[null]}", "{\"competencias\":[202511]}", + "{\"competencias\":[\"2025-06\"]}", "{\"competencias\":[\"2026-01\"]}", "{\"competencias\":[\"2025-7\"]}", + "{\"competencias\":[\"2025-11\",\"2025-11\"]}" }) + void entradaInvalidaNaoChamaServico(String corpo) throws Exception { + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM).contentType(MediaType.APPLICATION_JSON).content(corpo)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.codigo").value("requisicao_invalida")); + verifyNoInteractions(this.service); + } + + @Test + void inexistenteRetorna404() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(new JobNaoEncontradoException(ORIGEM)); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.codigo").value("job_nao_encontrado")); + } + + @Test + void estadoInvalidoRetorna409Especifico() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(ReprocessarJobException.estadoInvalido()); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.codigo").value("estado_invalido")) + .andExpect(jsonPath("$.mensagem").value("Somente um job arquivado pode ser reprocessado.")); + } + + @Test + void naoExpoeErroDoJdbc() throws Exception { + when(this.service.reprocessar(eq(ORIGEM), any())).thenThrow(new DataIntegrityViolationException("detalhe SQL")); + this.mvc.perform(post("/jobs/{id}/reprocessar", ORIGEM)) + .andExpect(status().isInternalServerError()) + .andExpect(content().string("")); + } + + private static JobCriadoDto novoJob() { + var representacao = CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO).representacao(); + Instant agora = Instant.parse("2026-09-23T10:00:00Z"); + return new JobCriadoDto(NOVO, "aguardando_confirmacao_parametros", "reprocessamento", List.of("2025-11"), + new BigDecimal("485000"), agora, null, ORIGEM, + new RegraCriadaDto(UUID.randomUUID(), 1, "reprocessamento", representacao, agora)); + } + +} diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java new file mode 100644 index 0000000..7528077 --- /dev/null +++ b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java @@ -0,0 +1,458 @@ +package synapse.api.job; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import javax.sql.DataSource; + +import liquibase.Contexts; +import liquibase.Liquibase; +import liquibase.database.DatabaseFactory; +import liquibase.database.jvm.JdbcConnection; +import liquibase.resource.ClassLoaderResourceAccessor; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.postgresql.PostgreSQLContainer; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataAccessException; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import synapse.api.core.outbox.Outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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; + +@EnabledIf("dockerIsAvailable") +class ReprocessarJobPersistenciaTests { + + private static final UUID USUARIO = CriarJobControllerTests.USUARIO; + + private static final UUID DONO_ORIGINAL = UUID.fromString("99999999-9999-4999-8999-999999999999"); + + private static final JsonMapper JSON = JsonMapper.builder() + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .build(); + + private static final String ESPECIFICACOES = """ + [{"ref":"elem.1","construto":"faixa_valor","limite_inferior":40000.123456789, + "limite_superior":50000,"efeito":{"tipo":"bonus_fixo","valor":3500.125}, + "extensao":{"criterios":["a","b"],"ativo":true,"fator":0.123456789012345678901}}] + """; + + private static PostgreSQLContainer postgres; + + private static AnnotationConfigApplicationContext contexto; + + private static JdbcTemplate jdbc; + + private static JdbcTemplate dono; + + private static ReprocessarJobService service; + + private static MockMvc mvc; + + static boolean dockerIsAvailable() { + return DockerClientFactory.instance().isDockerAvailable(); + } + + @BeforeAll + static void preparar() throws Exception { + postgres = new PostgreSQLContainer("postgres:18-alpine"); + postgres.start(); + try (Connection conexao = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), + postgres.getPassword()); + Liquibase liquibase = new Liquibase("db/changelog/changelog.yaml", new ClassLoaderResourceAccessor(), + DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conexao)))) { + liquibase.getChangeLogParameters().set("usuario_api", "synapse_api"); + liquibase.getChangeLogParameters().set("usuario_codegen", "synapse_codegen"); + liquibase.getChangeLogParameters().set("usuario_worker", "synapse_worker"); + liquibase.update(new Contexts()); + } + dono = new JdbcTemplate( + new DriverManagerDataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())); + dono.execute("ALTER ROLE synapse_api WITH PASSWORD 'senha-de-teste'"); + DataSource dataSource = new DriverManagerDataSource(postgres.getJdbcUrl(), "synapse_api", "senha-de-teste"); + jdbc = new JdbcTemplate(dataSource); + jdbc.update(""" + INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) + VALUES (?, 'rh-t079', 'x', 'RH', 'profissional_rh', '2026-01-02T00:00:00Z') + """, USUARIO); + contexto = new AnnotationConfigApplicationContext(); + contexto.registerBean(DataSource.class, () -> dataSource); + contexto.registerBean(JdbcTemplate.class, () -> jdbc); + contexto.registerBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(dataSource)); + contexto.register(Config.class); + contexto.refresh(); + service = contexto.getBean(ReprocessarJobService.class); + mvc = MockMvcBuilders + .standaloneSetup(contexto.getBean(ReprocessarJobController.class), + contexto.getBean(BuscarJobController.class), contexto.getBean(ConfirmarParametrosController.class)) + .setControllerAdvice(new ReprocessarJobAdvice(), new ConfirmarParametrosAdvice()) + .build(); + } + + @AfterAll + static void encerrar() { + if (contexto != null) { + contexto.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + @TestConfiguration(proxyBeanMethods = false) + @EnableTransactionManagement + @Import({ CriarJobService.class, ReprocessarJobService.class, BuscarJobService.class, + ConfirmarParametrosService.class, ExecutarAcaoService.class, MaquinaDeEstadosDoJob.class, Outbox.class, + ReprocessarJobController.class, BuscarJobController.class, ConfirmarParametrosController.class }) + static class Config { + + } + + @BeforeEach + void limpar() { + dono.execute("TRUNCATE submissoes CASCADE"); + jdbc.update(""" + INSERT INTO usuarios (id, login, senha_hash, nome, papel, ativo, criado_em) + VALUES (?, 'dono-t079', 'x', 'Dono original', 'profissional_rh', false, '2026-02-01T00:00:00Z') + ON CONFLICT (id) DO NOTHING + """, DONO_ORIGINAL); + } + + @ParameterizedTest + @ValueSource(strings = { "", "{}" }) + void criaNovoJobHerdandoParametrosEUltimaRegraSemAlterarOriginal(String corpo) throws Exception { + UUID origem = criarOrigem(true); + String antes = retrato(origem); + JsonNode novo = reprocessar(origem, corpo); + UUID novoId = UUID.fromString(novo.path("id").asString()); + assertThat(novoId).isNotEqualTo(origem); + assertThat(novo.propertyNames()).containsExactlyInAnyOrder("id", "status", "origem", "competencias", + "orcamento", "criado_em", "job_origem_id", "regra"); + assertThat(novo.path("status").asString()).isEqualTo("aguardando_confirmacao_parametros"); + assertThat(novo.path("origem").asString()).isEqualTo("reprocessamento"); + assertThat(novo.path("job_origem_id").asString()).isEqualTo(origem.toString()); + assertThat(novo.path("orcamento").decimalValue()).isEqualByComparingTo("485000.1234567890123456789"); + assertThat(novo.path("competencias")).isEqualTo(JSON.readTree("[\"2025-11\"]")); + Map job = jdbc.queryForMap("SELECT * FROM jobs WHERE id = ?", novoId); + assertThat(job).containsEntry("usuario_id", DONO_ORIGINAL) + .containsEntry("submissao_id", null) + .containsEntry("job_origem_id", origem) + .containsEntry("status", "aguardando_confirmacao_parametros") + .containsEntry("tentativas", 0) + .containsEntry("iniciado_em", null) + .containsEntry("finalizado_em", null); + assertThat(job.get("orcamento")).isEqualTo(new BigDecimal("485000.1234567890123456789")); + assertThat(competencias(novoId)).containsExactlyElementsOf(competencias(origem)); + assertThat(jdbc.queryForObject("SELECT count(*) FROM job_transicoes WHERE job_id = ?", Integer.class, novoId)) + .isEqualTo(1); + assertThat(jdbc.queryForMap("SELECT status_anterior, status_novo, ator FROM job_transicoes WHERE job_id = ?", + novoId)) + .containsEntry("status_anterior", null) + .containsEntry("status_novo", "aguardando_confirmacao_parametros") + .containsEntry("ator", "usuario"); + Map anterior = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 7", origem); + Map regra = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId); + assertThat(regra).containsEntry("versao", 1) + .containsEntry("origem", "reprocessamento") + .containsEntry("regra_origem_id", anterior.get("id")) + .containsEntry("nucleo", anterior.get("nucleo")) + .containsEntry("especificacoes", anterior.get("especificacoes")) + .containsEntry("hash", anterior.get("hash")) + .containsEntry("criada_em", job.get("criado_em")); + assertThat(regra.get("id")).isNotEqualTo(anterior.get("id")); + assertThat(novo.path("regra").path("id").asString()) + .isEqualTo(Objects.requireNonNull(regra.get("id")).toString()); + assertThat(novo.path("regra").path("versao").asInt()).isEqualTo(1); + assertThat(novo.path("regra").path("representacao").path("especificacoes")) + .isEqualTo(JSON.readTree(ESPECIFICACOES)); + assertThat(JSON.readTree(Objects.requireNonNull(regra.get("especificacoes")).toString())) + .isEqualTo(JSON.readTree(ESPECIFICACOES)); + verificarEvento(novoId, UUID.fromString(novo.path("regra").path("id").asString()), List.of("2025-11")); + assertThat(retrato(origem)).isEqualTo(antes); + assertThat(jdbc.queryForObject("SELECT count(*) FROM submissoes", Integer.class)).isEqualTo(1); + } + + @Test + void overridesMudamSomenteONovoJobESeuEvento() throws Exception { + UUID origem = criarOrigem(true); + String antes = retrato(origem); + JsonNode novo = reprocessar(origem, """ + {"orcamento":600000.1234567890123456789,"competencias":["2025-12","2025-07","2025-09"]} + """); + UUID novoId = UUID.fromString(novo.path("id").asString()); + assertThat(novo.path("orcamento").decimalValue()).isEqualByComparingTo("600000.1234567890123456789"); + assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId)) + .isEqualByComparingTo("600000.1234567890123456789"); + assertThat(competencias(novoId)).containsExactly("2025-07", "2025-09", "2025-12"); + verificarEvento(novoId, UUID.fromString(novo.path("regra").path("id").asString()), competencias(novoId)); + assertThat(retrato(origem)).isEqualTo(antes); + } + + @ParameterizedTest + @ValueSource(strings = { "{\"orcamento\":-1.125}", "{\"competencias\":[\"2025-07\"]}" }) + void overrideIsoladoPreservaOutroParametro(String corpo) throws Exception { + UUID origem = criarOrigem(false); + JsonNode novo = reprocessar(origem, corpo); + boolean mudaOrcamento = corpo.contains("orcamento"); + assertThat(novo.path("orcamento").decimalValue()) + .isEqualByComparingTo(mudaOrcamento ? "-1.125" : "485000.1234567890123456789"); + assertThat(novo.path("competencias")) + .isEqualTo(JSON.readTree(mudaOrcamento ? "[\"2025-11\"]" : "[\"2025-07\"]")); + } + + @ParameterizedTest + @EnumSource(value = JobStatus.class, names = "ARQUIVADO", mode = EnumSource.Mode.EXCLUDE) + void recusaTodosOsDemaisEstadosSemEfeitos(JobStatus estado) throws Exception { + UUID origem = criarOrigem(false); + jdbc.update("UPDATE jobs SET status = ? WHERE id = ?", estado.paraColuna(), origem); + Map antes = contagens(); + String original = retrato(origem); + mvc.perform(post("/jobs/{id}/reprocessar", origem)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.codigo").value("estado_invalido")) + .andExpect(jsonPath("$.mensagem").value("Somente um job arquivado pode ser reprocessado.")); + assertThat(contagens()).isEqualTo(antes); + assertThat(retrato(origem)).isEqualTo(original); + } + + @Test + void inexistenteNaoTemEfeitosColaterais() throws Exception { + Map antes = contagens(); + mvc.perform(post("/jobs/{id}/reprocessar", UUID.randomUUID())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.codigo").value("job_nao_encontrado")); + assertThat(contagens()).isEqualTo(antes); + } + + @Test + void arquivadoSemRegraNaoCriaJobParcial() throws Exception { + UUID origem = criarOrigem(false); + dono.update("DELETE FROM trilhas_auditoria WHERE job_id = ?", origem); + dono.update("DELETE FROM regras WHERE job_id = ?", origem); + Map antes = contagens(); + mvc.perform(post("/jobs/{id}/reprocessar", origem)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.codigo").value("estado_invalido")) + .andExpect( + jsonPath("$.mensagem").value("O job arquivado não possui uma regra formada para reprocessamento.")); + assertThat(contagens()).isEqualTo(antes); + } + + @Test + void getPosteriorEncontraJobSemSubmissaoEComRegraSemeada() throws Exception { + UUID origem = criarOrigem(true); + JsonNode novo = reprocessar(origem, ""); + String resposta = assertDoesNotThrow(() -> mvc.perform(get("/jobs/{id}", novo.path("id").asString()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.origem").value("reprocessamento")) + .andExpect(jsonPath("$.job_origem_id").value(origem.toString())) + .andExpect(jsonPath("$.status").value("aguardando_confirmacao_parametros")) + .andExpect(jsonPath("$.regras.length()").value(1)) + .andExpect(jsonPath("$.regras[0].versao").value(1)) + .andExpect(jsonPath("$.regras[0].id").value(novo.path("regra").path("id").asString())) + .andReturn() + .getResponse() + .getContentAsString(); + assertThat(JSON.readTree(resposta).has("submissao_id")).isFalse(); + assertThat(JSON.readTree(resposta).path("regras").path(0).path("representacao").path("especificacoes")) + .isEqualTo(JSON.readTree(ESPECIFICACOES)); + mvc.perform(get("/jobs/{id}", origem)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.origem").value("formulario")) + .andExpect(jsonPath("$.submissao_id").isNotEmpty()) + .andExpect(jsonPath("$.job_origem_id").doesNotExist()); + } + + @ParameterizedTest + @CsvSource({ "true,false", "false,false", "true,true", "false,true" }) + void confirmacaoPosteriorEditaParametrosPreservandoVersaoSemeada(boolean editarRegra, boolean extensoes) + throws Exception { + UUID origem = criarOrigem(extensoes); + String original = retrato(origem); + JsonNode novo = reprocessar(origem, ""); + UUID novoId = UUID.fromString(novo.path("id").asString()); + Map semeada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId); + String corpo = ConfirmarParametrosControllerTests.CONFIRMAR; + if (editarRegra) { + corpo = corpo.replace("0.03", "0.04"); + } + corpo = corpo.replace("{\"regra\"", + "{\"orcamento\":700000,\"competencias\":[\"2025-10\",\"2025-08\"],\"regra\""); + String resposta = mvc + .perform(post("/jobs/{id}/parameters", novoId).contentType(MediaType.APPLICATION_JSON).content(corpo)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("gerando_regra")) + .andExpect(jsonPath("$.origem").value("reprocessamento")) + .andExpect(jsonPath("$.job_origem_id").value(origem.toString())) + .andExpect(jsonPath("$.regra.versao").value(editarRegra ? 2 : 1)) + .andExpect(jsonPath("$.regra.origem").value(editarRegra ? "confirmacao_usuario" : "reprocessamento")) + .andReturn() + .getResponse() + .getContentAsString(); + assertThat(JSON.readTree(resposta).has("submissao_id")).isFalse(); + assertThat(jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 1", novoId)).isEqualTo(semeada); + assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, novoId)) + .isEqualTo("gerando_regra"); + assertThat(jdbc.queryForObject("SELECT job_origem_id FROM jobs WHERE id = ?", UUID.class, novoId)) + .isEqualTo(origem); + assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId)) + .isEqualByComparingTo("700000"); + assertThat(competencias(novoId)).containsExactly("2025-08", "2025-10"); + assertThat(jdbc.queryForObject("SELECT count(*) FROM regras WHERE job_id = ?", Integer.class, novoId)) + .isEqualTo(editarRegra ? 2 : 1); + if (editarRegra) { + assertThat(jdbc.queryForObject("SELECT regra_origem_id FROM regras WHERE job_id = ? AND versao = 2", + UUID.class, novoId)) + .isEqualTo(semeada.get("id")); + } + JsonNode especificacoes = JSON.readTree(extensoes ? ESPECIFICACOES : "[]"); + String persistidas = Objects.requireNonNull( + jdbc.queryForObject("SELECT especificacoes::text FROM regras WHERE job_id = ? AND versao = ?", + String.class, novoId, editarRegra ? 2 : 1)); + assertThat(JSON.readTree(persistidas)).isEqualTo(especificacoes) + .isEqualTo(JSON.readTree(Objects.requireNonNull(semeada.get("especificacoes")).toString())); + assertThat(JSON.readTree(resposta).path("regra").path("representacao").path("especificacoes")) + .isEqualTo(especificacoes); + String consulta = mvc.perform(get("/jobs/{id}", novoId)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + JsonNode regras = JSON.readTree(consulta).path("regras"); + assertThat(regras.size()).isEqualTo(editarRegra ? 2 : 1); + for (JsonNode regra : regras) { + assertThat(regra.path("representacao").path("especificacoes")).isEqualTo(especificacoes); + } + String payload = Objects.requireNonNull(jdbc.queryForObject( + "SELECT payload::text FROM outbox_events WHERE job_id = ? AND tipo = 'parametros-confirmados'", + String.class, novoId)); + ContratoDeEvento.validar("parametros-confirmados", payload); + assertThat(JSON.readTree(payload).path("regra_id")).isEqualTo(JSON.readTree(resposta).path("regra").path("id")); + assertThat(retrato(origem)).isEqualTo(original); + } + + @Test + void falhaNoOutboxReverteJobRegraETransicao() { + UUID origem = criarOrigem(true); + Map antes = contagens(); + String original = retrato(origem); + dono.execute("REVOKE INSERT ON outbox_events FROM synapse_api"); + try { + assertThatThrownBy(() -> service.reprocessar(origem, ReprocessarJobRequisicao.deJson(null))) + .isInstanceOf(DataAccessException.class) + .hasMessageContaining("INSERT INTO outbox_events"); + } + finally { + dono.execute("GRANT INSERT ON outbox_events TO synapse_api"); + } + assertThat(contagens()).isEqualTo(antes); + assertThat(retrato(origem)).isEqualTo(original); + } + + private static UUID criarOrigem(boolean extensoes) { + JobCriadoDto job = contexto.getBean(CriarJobService.class) + .criar(CriarJobRequisicao.deJson(CriarJobControllerTests.FORMULARIO)); + var representacao = ConfirmarParametrosRequisicao.deJson(ConfirmarParametrosControllerTests.CONFIRMAR) + .representacao(); + var completa = JSON.readValue("{\"nucleo\":" + JSON.writeValueAsString(representacao.nucleo()) + + ",\"especificacoes\":" + (extensoes ? ESPECIFICACOES : "[]") + "}", RepresentacaoRegraDto.class); + UUID regraId = Objects.requireNonNull(jdbc.queryForObject(""" + INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) + VALUES (?, 7, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, now()) RETURNING id + """, UUID.class, job.id(), job.regra().id(), JSON.writeValueAsString(representacao.nucleo()), + extensoes ? ESPECIFICACOES : "[]", HashDaRegra.calcular(completa))); + var maquina = contexto.getBean(MaquinaDeEstadosDoJob.class); + maquina.transicionar(job.id(), JobStatus.SIMULANDO, "evento", null); + maquina.transicionar(job.id(), JobStatus.AGUARDANDO_DECISAO_USUARIO, "evento", null); + contexto.getBean(ExecutarAcaoService.class).aplicar(job.id(), AcaoJob.ARQUIVAR); + jdbc.update("UPDATE jobs SET usuario_id = ?, tentativas = 3, iniciado_em = criado_em WHERE id = ?", + DONO_ORIGINAL, job.id()); + jdbc.update(""" + INSERT INTO trilhas_auditoria (evento_id, job_id, no, concluido_em, regra_id, conclusao) + VALUES (?, ?, 'confirmacao', now(), ?, '{"resumo":"confirmação anterior"}'::jsonb) + """, UUID.randomUUID(), job.id(), regraId); + return job.id(); + } + + private static JsonNode reprocessar(UUID origem, String corpo) throws Exception { + var pedido = post("/jobs/{id}/reprocessar", origem); + if (!corpo.isEmpty()) { + pedido.contentType(MediaType.APPLICATION_JSON).content(corpo); + } + var resposta = mvc.perform(pedido).andExpect(status().isCreated()).andReturn().getResponse(); + JsonNode novo = JSON.readTree(resposta.getContentAsString()); + assertThat(resposta.getHeader("Location")).isEqualTo("/api/jobs/" + novo.path("id").asString()); + return novo; + } + + private static void verificarEvento(UUID jobId, UUID regraId, List competencias) throws Exception { + Map evento = jdbc + .queryForMap("SELECT tipo, payload::text AS payload FROM outbox_events WHERE job_id = ?", jobId); + assertThat(evento).containsEntry("tipo", "regra-submetida"); + String payload = Objects.requireNonNull((String) evento.get("payload")); + ContratoDeEvento.validar("regra-submetida", payload); + assertThat(JSON.readTree(payload)).isEqualTo(JSON.valueToTree(Map.of("job_id", jobId.toString(), "origem", + "reprocessamento", "competencias", competencias, "regra_id", regraId.toString()))); + } + + private static List competencias(UUID jobId) { + return jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId); + } + + private static Map contagens() { + return jdbc.queryForMap(""" + SELECT (SELECT count(*) FROM jobs) AS jobs, (SELECT count(*) FROM regras) AS regras, + (SELECT count(*) FROM submissoes) AS submissoes, (SELECT count(*) FROM outbox_events) AS eventos, + (SELECT count(*) FROM job_transicoes) AS transicoes, (SELECT count(*) FROM job_acoes) AS acoes, + (SELECT count(*) FROM trilhas_auditoria) AS trilhas + """); + } + + private static String retrato(UUID jobId) { + return Objects.requireNonNull(jdbc.queryForObject( + """ + SELECT jsonb_build_object('job', to_jsonb(j), 'submissao', to_jsonb(s), + 'regras', (SELECT jsonb_agg(to_jsonb(r) ORDER BY r.id) FROM regras r WHERE r.job_id = j.id), + 'transicoes', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM job_transicoes t WHERE t.job_id = j.id), + 'acoes', (SELECT jsonb_agg(to_jsonb(a) ORDER BY a.id) FROM job_acoes a WHERE a.job_id = j.id), + 'trilhas', (SELECT jsonb_agg(to_jsonb(t) ORDER BY t.id) FROM trilhas_auditoria t WHERE t.job_id = j.id), + 'eventos', (SELECT jsonb_agg(to_jsonb(e) ORDER BY e.id) FROM outbox_events e WHERE e.job_id = j.id), + 'simulacoes', (SELECT jsonb_agg(to_jsonb(s) ORDER BY s.id) FROM simulacoes s WHERE s.job_id = j.id))::text + FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id WHERE j.id = ? + """, + String.class, jobId)); + } + +} From 13d84c74c288e37f575fb70b1f31247adfc7b487 Mon Sep 17 00:00:00 2001 From: Eduardo Fonseca Ribeiro Date: Wed, 23 Sep 2026 21:19:01 -0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(api):=20permite=20editar=20representa?= =?UTF-8?q?=C3=A7=C3=A3o=20completa=20no=20reprocessamento?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aceita especificações não vazias na confirmação de parâmetros - valida especificações pelo schema canônico da representação - considera núcleo e especificações no versionamento da regra - persiste integralmente a representação confirmada em novas versões - mantém versões anteriores e o job arquivado imutáveis - registra alterações de especificações pelos refs dos elementos - amplia testes de round-trip, versionamento, hash e preservação da regra --- api/pom.xml | 3 +- .../job/ConfirmarParametrosRequisicao.java | 24 +++- .../api/job/ConfirmarParametrosService.java | 35 +++-- .../ConfirmarParametrosControllerTests.java | 54 ++++++- .../job/ReprocessarJobPersistenciaTests.java | 135 ++++++++++++------ 5 files changed, 189 insertions(+), 62 deletions(-) diff --git a/api/pom.xml b/api/pom.xml index 1a506de..a0a712a 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -147,14 +147,13 @@ test - com.networknt json-schema-validator ${json-schema-validator.version} - test diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java index f076a9c..a451ffb 100644 --- a/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java +++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosRequisicao.java @@ -5,6 +5,12 @@ import java.util.HashSet; import java.util.List; +import com.networknt.schema.InputFormat; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SchemaValidatorsConfig; +import com.networknt.schema.SpecVersion.VersionFlag; import org.jspecify.annotations.Nullable; import tools.jackson.core.JacksonException; import tools.jackson.databind.DeserializationFeature; @@ -24,6 +30,13 @@ record ConfirmarParametrosRequisicao(RepresentacaoRegraDto representacao, @Nulla private static final List CAMPOS_NUCLEO = List.of("vigencia", "loja", "marca", "cargo", "percentual"); + private static final JsonSchema ESPECIFICACOES = JsonSchemaFactory + .getInstance(VersionFlag.V202012, + builder -> builder.schemaMappers(mappers -> mappers.mapPrefix("https://synapse.local/contracts/domain/", + "classpath:static/openapi/domain/"))) + .getSchema(SchemaLocation.of("classpath:static/openapi/domain/regra-especificacoes.schema.json"), + SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build()); + private static final JsonMapper JSON = JsonMapper.builder() .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) @@ -47,7 +60,8 @@ static ConfirmarParametrosRequisicao deJson(String corpo) { validarNucleo(regra.path("nucleo")); validarEspecificacoes(regra.path("especificacoes")); RepresentacaoRegraDto representacao = new RepresentacaoRegraDto( - JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class), List.of()); + JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class), + regra.path("especificacoes").valueStream().toList()); return new ConfirmarParametrosRequisicao(representacao, orcamento(raiz.path("orcamento")), competencias(raiz)); } @@ -96,12 +110,10 @@ private static void validarNucleo(JsonNode nucleo) { } private static void validarEspecificacoes(JsonNode especificacoes) { - if (especificacoes.isMissingNode() || especificacoes.isNull()) { - return; - } - if (!especificacoes.isArray() || !especificacoes.isEmpty()) { + if (!especificacoes.isArray() + || !ESPECIFICACOES.validate(especificacoes.toString(), InputFormat.JSON).isEmpty()) { throw ConfirmarParametrosException - .requisicao("Na Sprint 1, regra.especificacoes deve ser uma lista vazia."); + .requisicao("O campo regra.especificacoes deve ser uma lista de elementos válidos da regra."); } } diff --git a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java index 73fefd3..982bf1b 100644 --- a/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java +++ b/api/src/main/java/synapse/api/job/ConfirmarParametrosService.java @@ -7,6 +7,7 @@ import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -58,8 +59,7 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) { this.maquina.transicionar(jobId, JobStatus.GERANDO_REGRA, "usuario", null); VersaoAnterior anterior = ultimaVersao(jobId); - RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(requisicao.representacao().nucleo(), - anterior != null ? anterior.especificacoes() : List.of()); + RepresentacaoRegraDto representacao = requisicao.representacao(); String hash = HashDaRegra.calcular(representacao); boolean editado = anterior != null && !hash.equals(anterior.hash()); @@ -72,7 +72,7 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) { this.outbox.registrar(jobId, EventoOutbox.PARAMETROS_CONFIRMADOS, new ParametrosConfirmadosDto(jobId, versao.id())); - registrarTrilha(jobId, versao.id(), editado, representacao.nucleo(), anterior, timestamp); + registrarTrilha(jobId, versao.id(), editado, representacao, anterior, timestamp); RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), versao.origem(), representacao, versao.criadaEm()); @@ -130,10 +130,9 @@ private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representac UUID origemId = (anterior != null) ? anterior.id() : null; UUID id = Objects.requireNonNull(this.jdbc.queryForObject(""" INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em) - VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, - COALESCE((SELECT especificacoes FROM regras WHERE id = ?), '[]'::jsonb), ?, ?) RETURNING id + VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, ?) RETURNING id """, UUID.class, jobId, novaVersao, origemId, this.json.writeValueAsString(representacao.nucleo()), - origemId, hash, timestamp)); + this.json.writeValueAsString(representacao.especificacoes()), hash, timestamp)); return new VersaoRegra(id, novaVersao, "confirmacao_usuario", agora); } @@ -156,11 +155,15 @@ private List resolverCompetencias(UUID jobId, ConfirmarParametrosRequisi return this.jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId); } - private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, NucleoRegraDto atual, + private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, RepresentacaoRegraDto atual, @Nullable VersaoAnterior anterior, Timestamp timestamp) { - List corrigidos = (anterior != null) ? camposCorrigidos(atual, anterior.nucleo()) : List.of(); - String resumo = (editado && !corrigidos.isEmpty()) - ? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar" + List corrigidos = (anterior != null && editado) ? camposCorrigidos(atual.nucleo(), anterior.nucleo()) + : new ArrayList<>(); + if (anterior != null && editado) { + corrigidos.addAll(especificacoesCorrigidas(atual.especificacoes(), anterior.especificacoes())); + } + String resumo = editado ? "usuário corrigiu " + + (corrigidos.isEmpty() ? "a representação" : String.join(", ", corrigidos)) + " antes de confirmar" : "usuário confirmou os parâmetros"; Map conclusao = new LinkedHashMap<>(); conclusao.put("resumo", resumo); @@ -201,6 +204,18 @@ private static boolean percentualDiferente(@Nullable BigDecimal atual, @Nullable return atual.compareTo(anterior) != 0; } + private static List especificacoesCorrigidas(List atuais, List anteriores) { + var refs = new LinkedHashSet(); + atuais.forEach(elemento -> refs.add(elemento.path("ref").asString())); + anteriores.forEach(elemento -> refs.add(elemento.path("ref").asString())); + return refs.stream() + .filter(ref -> !atuais.stream() + .filter(elemento -> ref.equals(elemento.path("ref").asString())) + .toList() + .equals(anteriores.stream().filter(elemento -> ref.equals(elemento.path("ref").asString())).toList())) + .toList(); + } + private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, @Nullable UUID submissaoId, @Nullable UUID jobOrigemId) { } diff --git a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java index a0665c1..97d560b 100644 --- a/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java +++ b/api/src/test/java/synapse/api/job/ConfirmarParametrosControllerTests.java @@ -7,13 +7,21 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.json.JsonMapper; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -62,16 +70,56 @@ void confirmaResponde202ComRegraNaVersaoNova() throws Exception { } @Test - void naoPermiteEditarEspecificacoes() throws Exception { + void permiteConfirmarRepresentacaoCompletaSemPerderCamposOuPrecisao() throws Exception { + String especificacoes = """ + [{"ref":"elem.1","construto":"faixa_valor","limite_inferior":40000.123456789, + "limite_superior":50000,"efeito":{"tipo":"bonus_fixo","valor":3500.125}, + "extensao":{"criterios":["a","b"],"ativo":true,"fator":0.123456789012345678901}}] + """; this.mvc .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) - .content(CONFIRMAR.replace("\"especificacoes\":[]", - "\"especificacoes\":[{\"ref\":\"elem.1\",\"construto\":\"generico\"}]"))) + .content(CONFIRMAR.replace("\"especificacoes\":[]", "\"especificacoes\":" + especificacoes))) + .andExpect(status().isAccepted()); + var captor = ArgumentCaptor.forClass(ConfirmarParametrosRequisicao.class); + verify(this.service).confirmar(eq(JOB), captor.capture()); + var json = JsonMapper.builder().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).build(); + assertThat(captor.getValue().representacao().especificacoes()) + .containsExactlyElementsOf(json.readTree(especificacoes).valueStream().toList()); + assertThat(captor.getValue() + .representacao() + .especificacoes() + .getFirst() + .path("extensao") + .path("fator") + .decimalValue()).isEqualByComparingTo("0.123456789012345678901"); + } + + @ParameterizedTest + @ValueSource(strings = { "null", "{}", "[1]", "[null]", "[{}]", + "[{\"ref\":\"invalido\",\"construto\":\"generico\",\"descricao\":\"regra\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"desconhecido\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"generico\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"generico\",\"descricao\":\"\"}]", + "[{\"ref\":\"elem.1\",\"construto\":\"faixa_valor\",\"limite_inferior\":0,\"limite_superior\":10,\"efeito\":{\"tipo\":\"invalido\",\"valor\":1}}]", + "[{\"ref\":\"elem.1\",\"construto\":\"janela_datas\",\"data_inicial\":\"2025-99-01\",\"data_final\":\"2025-11-30\",\"efeito\":{\"tipo\":\"bonus_fixo\",\"valor\":1}}]" }) + void especificacoesInvalidasSaoRecusadasSemChamarService(String especificacoes) throws Exception { + this.mvc + .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) + .content(CONFIRMAR.replace("\"especificacoes\":[]", "\"especificacoes\":" + especificacoes))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.codigo").value("requisicao_invalida")); verifyNoInteractions(this.service); } + @Test + void especificacoesAusentesSaoRecusadas() throws Exception { + this.mvc + .perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON) + .content(CONFIRMAR.replace(",\"especificacoes\":[]", ""))) + .andExpect(status().isBadRequest()); + verifyNoInteractions(this.service); + } + @Test void corpoInvalidoResponde400() throws Exception { this.mvc.perform(post("/jobs/" + JOB + "/parameters").contentType(MediaType.APPLICATION_JSON).content("{")) diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java index 7528077..e826e8a 100644 --- a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java @@ -3,6 +3,7 @@ import java.math.BigDecimal; import java.sql.Connection; import java.sql.DriverManager; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -20,7 +21,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIf; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.testcontainers.DockerClientFactory; @@ -28,6 +28,7 @@ import tools.jackson.databind.DeserializationFeature; import tools.jackson.databind.JsonNode; import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.boot.test.context.TestConfiguration; @@ -296,73 +297,125 @@ void getPosteriorEncontraJobSemSubmissaoEComRegraSemeada() throws Exception { } @ParameterizedTest - @CsvSource({ "true,false", "false,false", "true,true", "false,true" }) - void confirmacaoPosteriorEditaParametrosPreservandoVersaoSemeada(boolean editarRegra, boolean extensoes) - throws Exception { - UUID origem = criarOrigem(extensoes); + @ValueSource(strings = { "nenhuma", "nucleo", "especificacoes", "ambos", "parametros", "adicionar", "remover" }) + void confirmaRepresentacaoDoGetPreservandoVersoesEOriginal(String edicao) throws Exception { + UUID origem = criarOrigem(true); String original = retrato(origem); JsonNode novo = reprocessar(origem, ""); UUID novoId = UUID.fromString(novo.path("id").asString()); Map semeada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ?", novoId); - String corpo = ConfirmarParametrosControllerTests.CONFIRMAR; - if (editarRegra) { - corpo = corpo.replace("0.03", "0.04"); + JsonNode representacaoOriginal = consultar(novoId).path("regras").path(0).path("representacao"); + ObjectNode enviada = (ObjectNode) representacaoOriginal.deepCopy(); + boolean mudaNucleo = edicao.equals("nucleo") || edicao.equals("ambos"); + boolean mudaEspecificacoes = List.of("especificacoes", "ambos", "adicionar", "remover").contains(edicao); + boolean editado = mudaNucleo || mudaEspecificacoes; + if (mudaNucleo) { + ((ObjectNode) enviada.path("nucleo")).put("percentual", new BigDecimal("0.04")); + } + if (edicao.equals("especificacoes") || edicao.equals("ambos")) { + ((ObjectNode) enviada.path("especificacoes").path(0).path("efeito")).put("valor", + new BigDecimal("3500.123456789012345678901")); + } + if (edicao.equals("adicionar")) { + enviada.withArray("especificacoes").add(JSON.readTree(""" + {"ref":"elem.2","construto":"generico","descricao":"bonus de aniversario", + "campos":{"fator":0.123456789012345678901}} + """)); + } + if (edicao.equals("remover")) { + enviada.putArray("especificacoes"); + } + ObjectNode pedido = JSON.createObjectNode(); + pedido.set("regra", enviada); + if (edicao.equals("parametros")) { + pedido.put("orcamento", new BigDecimal("700000.123456789012345678901")); + pedido.set("competencias", JSON.readTree("[\"2025-10\",\"2025-08\"]")); } - corpo = corpo.replace("{\"regra\"", - "{\"orcamento\":700000,\"competencias\":[\"2025-10\",\"2025-08\"],\"regra\""); + String corpo = JSON.writeValueAsString(pedido); + assertThat(JSON.valueToTree(ConfirmarParametrosRequisicao.deJson(corpo).representacao())) + .isEqualTo(enviada); String resposta = mvc .perform(post("/jobs/{id}/parameters", novoId).contentType(MediaType.APPLICATION_JSON).content(corpo)) .andExpect(status().isAccepted()) .andExpect(jsonPath("$.status").value("gerando_regra")) .andExpect(jsonPath("$.origem").value("reprocessamento")) .andExpect(jsonPath("$.job_origem_id").value(origem.toString())) - .andExpect(jsonPath("$.regra.versao").value(editarRegra ? 2 : 1)) - .andExpect(jsonPath("$.regra.origem").value(editarRegra ? "confirmacao_usuario" : "reprocessamento")) + .andExpect(jsonPath("$.regra.versao").value(editado ? 2 : 1)) + .andExpect(jsonPath("$.regra.origem").value(editado ? "confirmacao_usuario" : "reprocessamento")) .andReturn() .getResponse() .getContentAsString(); - assertThat(JSON.readTree(resposta).has("submissao_id")).isFalse(); + JsonNode confirmado = JSON.readTree(resposta); + assertThat(confirmado.has("submissao_id")).isFalse(); + assertThat(confirmado.path("regra").path("representacao")).isEqualTo(enviada); assertThat(jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = 1", novoId)).isEqualTo(semeada); assertThat(jdbc.queryForObject("SELECT status FROM jobs WHERE id = ?", String.class, novoId)) .isEqualTo("gerando_regra"); - assertThat(jdbc.queryForObject("SELECT job_origem_id FROM jobs WHERE id = ?", UUID.class, novoId)) - .isEqualTo(origem); - assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId)) - .isEqualByComparingTo("700000"); - assertThat(competencias(novoId)).containsExactly("2025-08", "2025-10"); assertThat(jdbc.queryForObject("SELECT count(*) FROM regras WHERE job_id = ?", Integer.class, novoId)) - .isEqualTo(editarRegra ? 2 : 1); - if (editarRegra) { - assertThat(jdbc.queryForObject("SELECT regra_origem_id FROM regras WHERE job_id = ? AND versao = 2", - UUID.class, novoId)) - .isEqualTo(semeada.get("id")); + .isEqualTo(editado ? 2 : 1); + Map utilizada = jdbc.queryForMap("SELECT * FROM regras WHERE job_id = ? AND versao = ?", novoId, + editado ? 2 : 1); + assertThat(JSON.readTree(Objects.requireNonNull(utilizada.get("nucleo")).toString())) + .isEqualTo(enviada.path("nucleo")); + assertThat(JSON.readTree(Objects.requireNonNull(utilizada.get("especificacoes")).toString())) + .isEqualTo(enviada.path("especificacoes")); + assertThat(Objects.requireNonNull(utilizada.get("id")).toString()) + .isEqualTo(confirmado.path("regra").path("id").asString()); + assertThat(utilizada.get("hash")) + .isEqualTo(HashDaRegra.calcular(ConfirmarParametrosRequisicao.deJson(corpo).representacao())); + if (editado) { + assertThat(utilizada.get("regra_origem_id")).isEqualTo(semeada.get("id")); + assertThat(utilizada.get("hash")).isNotEqualTo(semeada.get("hash")); } - JsonNode especificacoes = JSON.readTree(extensoes ? ESPECIFICACOES : "[]"); - String persistidas = Objects.requireNonNull( - jdbc.queryForObject("SELECT especificacoes::text FROM regras WHERE job_id = ? AND versao = ?", - String.class, novoId, editarRegra ? 2 : 1)); - assertThat(JSON.readTree(persistidas)).isEqualTo(especificacoes) - .isEqualTo(JSON.readTree(Objects.requireNonNull(semeada.get("especificacoes")).toString())); - assertThat(JSON.readTree(resposta).path("regra").path("representacao").path("especificacoes")) - .isEqualTo(especificacoes); - String consulta = mvc.perform(get("/jobs/{id}", novoId)) - .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(); - JsonNode regras = JSON.readTree(consulta).path("regras"); - assertThat(regras.size()).isEqualTo(editarRegra ? 2 : 1); - for (JsonNode regra : regras) { - assertThat(regra.path("representacao").path("especificacoes")).isEqualTo(especificacoes); + else { + assertThat(utilizada).isEqualTo(semeada); + } + if (edicao.equals("parametros")) { + assertThat(jdbc.queryForObject("SELECT orcamento FROM jobs WHERE id = ?", BigDecimal.class, novoId)) + .isEqualByComparingTo("700000.123456789012345678901"); + assertThat(competencias(novoId)).containsExactly("2025-08", "2025-10"); + assertThat(confirmado.path("orcamento")).isEqualTo(pedido.path("orcamento")); + assertThat(confirmado.path("competencias")).isEqualTo(JSON.readTree("[\"2025-08\",\"2025-10\"]")); + } + else { + assertThat(confirmado.path("orcamento")).isEqualTo(novo.path("orcamento")); + assertThat(confirmado.path("competencias")).isEqualTo(novo.path("competencias")); } + JsonNode regras = consultar(novoId).path("regras"); + assertThat(regras.size()).isEqualTo(editado ? 2 : 1); + assertThat(regras.path(0).path("representacao")).isEqualTo(representacaoOriginal); + assertThat(regras.path(editado ? 1 : 0).path("representacao")).isEqualTo(enviada); String payload = Objects.requireNonNull(jdbc.queryForObject( "SELECT payload::text FROM outbox_events WHERE job_id = ? AND tipo = 'parametros-confirmados'", String.class, novoId)); ContratoDeEvento.validar("parametros-confirmados", payload); - assertThat(JSON.readTree(payload).path("regra_id")).isEqualTo(JSON.readTree(resposta).path("regra").path("id")); + assertThat(JSON.readTree(payload).path("regra_id")).isEqualTo(confirmado.path("regra").path("id")); + JsonNode trilha = JSON.readTree(Objects.requireNonNull(jdbc.queryForObject( + "SELECT conclusao::text FROM trilhas_auditoria WHERE job_id = ? AND no = 'confirmacao'", String.class, + novoId))); + assertThat(trilha.path("editado_pelo_usuario").asBoolean()).isEqualTo(editado); + List corrigidos = new ArrayList<>(); + if (mudaNucleo) { + corrigidos.add("nucleo.percentual"); + } + if (mudaEspecificacoes) { + corrigidos.add(edicao.equals("adicionar") ? "elem.2" : "elem.1"); + } + assertThat(trilha.path("campos_corrigidos")).isEqualTo(JSON.valueToTree(corrigidos)); + assertThat(trilha.path("resumo").asString()) + .isEqualTo(editado ? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar" + : "usuário confirmou os parâmetros"); assertThat(retrato(origem)).isEqualTo(original); } + private static JsonNode consultar(UUID jobId) throws Exception { + return JSON.readTree(mvc.perform(get("/jobs/{id}", jobId)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString()); + } + @Test void falhaNoOutboxReverteJobRegraETransicao() { UUID origem = criarOrigem(true); From 4d38d4c72862a056a08564aa52b9b9a9ed85d120 Mon Sep 17 00:00:00 2001 From: Eduardo Fonseca Ribeiro Date: Thu, 24 Sep 2026 08:38:29 -0300 Subject: [PATCH 3/3] =?UTF-8?q?test(api):=20adapta=20fixture=20de=20reproc?= =?UTF-8?q?essamento=20=C3=A0=20autoriza=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inclui o autorizador real no contexto de persistência - usa o proprietário do job no mock de usuário atual - constrói a consulta de job com o novo grafo de dependências --- .../api/job/ReprocessarJobPersistenciaTests.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java index e826e8a..56d491a 100644 --- a/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java +++ b/api/src/test/java/synapse/api/job/ReprocessarJobPersistenciaTests.java @@ -44,10 +44,14 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; import synapse.api.core.outbox.Outbox; +import synapse.api.core.security.AcessoDoUsuario; +import synapse.api.core.security.UsuarioAtual; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -115,9 +119,13 @@ INSERT INTO usuarios (id, login, senha_hash, nome, papel, criado_em) contexto.register(Config.class); contexto.refresh(); service = contexto.getBean(ReprocessarJobService.class); + UsuarioAtual usuarioAtual = mock(UsuarioAtual.class); + when(usuarioAtual.obter()).thenReturn(new AcessoDoUsuario(DONO_ORIGINAL, false)); mvc = MockMvcBuilders .standaloneSetup(contexto.getBean(ReprocessarJobController.class), - contexto.getBean(BuscarJobController.class), contexto.getBean(ConfirmarParametrosController.class)) + new BuscarJobController(contexto.getBean(BuscarJobService.class), + contexto.getBean(AutorizadorDeJob.class), usuarioAtual), + contexto.getBean(ConfirmarParametrosController.class)) .setControllerAdvice(new ReprocessarJobAdvice(), new ConfirmarParametrosAdvice()) .build(); } @@ -136,7 +144,7 @@ static void encerrar() { @EnableTransactionManagement @Import({ CriarJobService.class, ReprocessarJobService.class, BuscarJobService.class, ConfirmarParametrosService.class, ExecutarAcaoService.class, MaquinaDeEstadosDoJob.class, Outbox.class, - ReprocessarJobController.class, BuscarJobController.class, ConfirmarParametrosController.class }) + ReprocessarJobController.class, AutorizadorDeJob.class, ConfirmarParametrosController.class }) static class Config { }