diff --git a/pom.xml b/pom.xml index 4fe6da7..13054bf 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,14 @@ postgresql runtime + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + @@ -118,6 +126,12 @@ junit-jupiter test + + com.tngtech.archunit + archunit-junit5 + 1.3.0 + test + diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java index 5d20dd5..de4f410 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/ChargeController.java @@ -4,6 +4,9 @@ import com.lmoraesdev.payment.application.port.in.CreateCharge; import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import com.lmoraesdev.payment.application.port.in.GetCharge; +import com.lmoraesdev.payment.application.port.in.GetChargeResult; +import java.util.UUID; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; @@ -11,19 +14,35 @@ @RestController public class ChargeController implements ChargeControllerDoc { private final CreateCharge createCharge; + private final GetCharge getCharge; - public ChargeController(CreateCharge createCharge) { + public ChargeController(CreateCharge createCharge, GetCharge getCharge) { this.createCharge = createCharge; + this.getCharge = getCharge; } @Override - public ResponseEntity create(CreateChargeRequest request) { - CreateChargeResult result = createCharge.create(new CreateChargeCommand(request.amount())); + public ResponseEntity create( + CreateChargeRequest request, String idempotencyKey) { + CreateChargeResult result = + createCharge.create(new CreateChargeCommand(request.amount(), idempotencyKey)); CreateChargeResponse response = new CreateChargeResponse( result.id(), result.status(), result.amount(), result.createdAt()); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + HttpStatus status = result.replayed() ? HttpStatus.OK : HttpStatus.CREATED; + return ResponseEntity.status(status).body(response); + } + + @Override + public ResponseEntity get(UUID id) { + GetChargeResult result = getCharge.find(id); + + GetChargeResponse response = + new GetChargeResponse( + result.id(), result.status(), result.amount(), result.createdAt()); + + return ResponseEntity.ok(response); } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GetChargeResponse.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GetChargeResponse.java new file mode 100644 index 0000000..ef55339 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GetChargeResponse.java @@ -0,0 +1,13 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +@Schema(description = "Cobrança Pix") +public record GetChargeResponse( + @Schema(description = "Identificador único da cobrança") UUID id, + @Schema(description = "Status da cobrança", example = "ACTIVE") String status, + @Schema(description = "Valor da cobrança em reais", example = "150.00") BigDecimal amount, + @Schema(description = "Data e hora de criação (UTC)") Instant createdAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java index df614e1..a557629 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.lmoraesdev.payment.adapter.in.web; import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import com.lmoraesdev.payment.domain.exception.ChargeNotFoundException; import com.lmoraesdev.payment.domain.exception.DomainException; import java.util.HashMap; import java.util.Map; @@ -10,6 +11,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -33,6 +35,28 @@ public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { return problem; } + // 400 — header obrigatório ausente. Erro esperado do cliente: NÃO loga. + @ExceptionHandler(MissingRequestHeaderException.class) + public ProblemDetail handleMissingHeader(MissingRequestHeaderException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + "Header obrigatório ausente: %s".formatted(ex.getHeaderName())); + problem.setTitle("Missing required header"); + addTraceId(problem); + return problem; + } + + // 404 — recurso não encontrado. Esperado: NÃO loga. + @ExceptionHandler(ChargeNotFoundException.class) + public ProblemDetail handleChargeNotFound(ChargeNotFoundException ex) { + ProblemDetail problem = + ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + problem.setTitle("Charge not found"); + addTraceId(problem); + return problem; + } + // 422 — regra de negócio. Também esperado: NÃO loga. @ExceptionHandler(DomainException.class) public ProblemDetail handleDomain(DomainException ex) { diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookController.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookController.java new file mode 100644 index 0000000..bf8767e --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookController.java @@ -0,0 +1,24 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import com.lmoraesdev.payment.adapter.in.web.documentation.WebhookControllerDoc; +import com.lmoraesdev.payment.application.port.in.ProcessWebhook; +import com.lmoraesdev.payment.application.port.in.ProcessWebhookCommand; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WebhookController implements WebhookControllerDoc { + private final ProcessWebhook processWebhook; + + public WebhookController(ProcessWebhook processWebhook) { + this.processWebhook = processWebhook; + } + + @Override + public ResponseEntity receive(WebhookProviderPayload payload) { + processWebhook.process( + new ProcessWebhookCommand(payload.eventId(), payload.chargeId(), payload.status())); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookProviderPayload.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookProviderPayload.java new file mode 100644 index 0000000..8af04ff --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/WebhookProviderPayload.java @@ -0,0 +1,19 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import java.util.UUID; + +@Schema(description = "Payload de webhook recebido do provedor Pix") +public record WebhookProviderPayload( + @Schema(description = "Identificador único do evento, usado para deduplicação") @NotBlank + String eventId, + @Schema(description = "Identificador da cobrança") @NotNull UUID chargeId, + @Schema(description = "Novo status da cobrança", example = "PAID") + @NotBlank + @Pattern( + regexp = "PAID|EXPIRED|CANCELLED", + message = "status deve ser PAID, EXPIRED ou CANCELLED") + String status) {} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java index c4fdc93..a6ff69c 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/ChargeControllerDoc.java @@ -2,17 +2,23 @@ import com.lmoraesdev.payment.adapter.in.web.CreateChargeRequest; import com.lmoraesdev.payment.adapter.in.web.CreateChargeResponse; +import com.lmoraesdev.payment.adapter.in.web.GetChargeResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.util.UUID; import org.springframework.http.ProblemDetail; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @Tag(name = "Charges", description = "Gerenciamento de cobranças Pix") @@ -27,13 +33,19 @@ public interface ChargeControllerDoc { responseCode = "201", description = "Cobrança criada com sucesso", content = @Content(schema = @Schema(implementation = CreateChargeResponse.class))), + @ApiResponse( + responseCode = "200", + description = + "Idempotency-Key já utilizada com o mesmo corpo — cobrança existente retornada", + content = @Content(schema = @Schema(implementation = CreateChargeResponse.class))), @ApiResponse( responseCode = "400", - description = "Dados inválidos — Problem Details com erros por campo", + description = "Dados inválidos ou header Idempotency-Key ausente — Problem Details", content = @Content(schema = @Schema(implementation = ProblemDetail.class))), @ApiResponse( responseCode = "422", - description = "Regra de negócio violada", + description = + "Regra de negócio violada ou Idempotency-Key reutilizada com corpo diferente", content = @Content(schema = @Schema(implementation = ProblemDetail.class))), @ApiResponse( responseCode = "500", @@ -41,5 +53,23 @@ public interface ChargeControllerDoc { content = @Content(schema = @Schema(implementation = ProblemDetail.class))) }) @PostMapping - ResponseEntity create(@Valid @RequestBody CreateChargeRequest request); + ResponseEntity create( + @Valid @RequestBody CreateChargeRequest request, + @Parameter(description = "Chave de idempotência do cliente", required = true) + @RequestHeader("Idempotency-Key") + String idempotencyKey); + + @Operation(summary = "Consultar cobrança", description = "Busca uma cobrança Pix pelo id") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Cobrança encontrada", + content = @Content(schema = @Schema(implementation = GetChargeResponse.class))), + @ApiResponse( + responseCode = "404", + description = "Cobrança não encontrada", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))) + }) + @GetMapping("/{id}") + ResponseEntity get(@PathVariable UUID id); } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/WebhookControllerDoc.java b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/WebhookControllerDoc.java new file mode 100644 index 0000000..3f3c987 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/in/web/documentation/WebhookControllerDoc.java @@ -0,0 +1,45 @@ +package com.lmoraesdev.payment.adapter.in.web.documentation; + +import com.lmoraesdev.payment.adapter.in.web.WebhookProviderPayload; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; + +@Tag(name = "Webhooks", description = "Recebimento de eventos do provedor Pix") +@RequestMapping("/webhooks") +public interface WebhookControllerDoc { + + @Operation( + summary = "Receber webhook do provedor", + description = + "Processa uma notificação de mudança de status da cobrança. Sempre retorna 200," + + " inclusive para eventos duplicados (idempotente por eventId)") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Webhook processado (ou já processado anteriormente)"), + @ApiResponse( + responseCode = "400", + description = "Payload inválido — Problem Details com erros por campo", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))), + @ApiResponse( + responseCode = "404", + description = "Cobrança não encontrada", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))), + @ApiResponse( + responseCode = "422", + description = "Transição de status inválida para o estado atual da cobrança", + content = @Content(schema = @Schema(implementation = ProblemDetail.class))) + }) + @PostMapping("/provider") + ResponseEntity receive(@Valid @RequestBody WebhookProviderPayload payload); +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java index 0b686fb..1e07e06 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelay.java @@ -2,11 +2,15 @@ import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventEntity; import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventJpaRepository; -import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxStatus; +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.UUID; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -15,34 +19,71 @@ @Component public class OutboxRelay { - private static final Logger LOG = LoggerFactory.getLogger(OutboxRelay.class); private static final String TOPIC = "payments.charge-created"; private final OutboxEventJpaRepository repository; private final KafkaTemplate kafkaTemplate; + private final Counter publishedCounter; + private final Counter failedCounter; + private final Timer publishLagTimer; public OutboxRelay( - OutboxEventJpaRepository repository, KafkaTemplate kafkaTemplate) { + OutboxEventJpaRepository repository, + KafkaTemplate kafkaTemplate, + MeterRegistry meterRegistry) { this.repository = repository; this.kafkaTemplate = kafkaTemplate; + this.publishedCounter = meterRegistry.counter("outbox_events_published_total"); + this.failedCounter = meterRegistry.counter("outbox_events_failed_total"); + this.publishLagTimer = meterRegistry.timer("outbox_publish_lag"); } @Scheduled(fixedDelay = 5000) public void publishPending() { - List pending = - repository.findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus.PENDING); + List claimed = claimBatch(); - for (OutboxEventEntity event : pending) { + for (OutboxEventEntity event : claimed) { publish(event); } } + @Transactional + public List claimBatch() { + List claimed = repository.findBatchForUpdateSkipLocked(); + claimed.forEach(OutboxEventEntity::markInFlight); + return repository.saveAll(claimed); + } + private void publish(OutboxEventEntity event) { try { kafkaTemplate.send(TOPIC, event.getAggregateId(), event.getPayload()).get(); markPublished(event.getId()); + publishedCounter.increment(); + publishLagTimer.record(Duration.between(event.getCreatedAt(), Instant.now())); } catch (Exception e) { - LOG.warn("failed to publish outbox event {}, will retry next poll", event.getId(), e); + failedCounter.increment(); + logPublishFailure(event, e); + revertToPending(event.getId()); + } + } + + private void logPublishFailure(OutboxEventEntity event, Exception e) { + String correlationId = event.getCorrelationId(); + if (correlationId != null) { + MDC.put("traceId", correlationId); + } + try { + Logger5w1hBuilder.create(OutboxRelay.class) + .where("OutboxRelay") + .what("outbox_publish_failed") + .why("kafka send failed for event " + event.getId() + ", will retry next poll") + .who("system") + .how("publishPending") + .error(e); + } finally { + if (correlationId != null) { + MDC.remove("traceId"); + } } } @@ -56,4 +97,15 @@ public void markPublished(UUID eventId) { repository.save(event); }); } + + @Transactional + public void revertToPending(UUID eventId) { + repository + .findById(eventId) + .ifPresent( + event -> { + event.revertToPending(); + repository.save(event); + }); + } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java index 23bea11..c86fae1 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeJpaEntity.java @@ -25,6 +25,9 @@ public class ChargeJpaEntity { @Column(nullable = false) private Instant createdAt; + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + protected ChargeJpaEntity() {} public UUID getId() { @@ -58,4 +61,12 @@ public Instant getCreatedAt() { public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java index 0dacf77..4a0a0ef 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeMapper.java @@ -5,6 +5,7 @@ import java.math.BigDecimal; final class ChargeMapper { + private ChargeMapper() {} static ChargeJpaEntity toEntity(Charge charge) { @@ -14,16 +15,17 @@ static ChargeJpaEntity toEntity(Charge charge) { entity.setAmountCentavos(charge.getAmount().amount().movePointRight(2).longValueExact()); entity.setStatus(charge.getStatus()); entity.setCreatedAt(charge.getCreatedAt()); + entity.setExpiresAt(charge.getExpiresAt()); return entity; } static Charge toDomain(ChargeJpaEntity entity) { - return Charge.restore( entity.getId(), new Money(BigDecimal.valueOf(entity.getAmountCentavos(), 2)), entity.getStatus(), - entity.getCreatedAt()); + entity.getCreatedAt(), + entity.getExpiresAt()); } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java index 3446f69..42c226b 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryAdapter.java @@ -2,6 +2,9 @@ import com.lmoraesdev.payment.application.port.out.ChargeRepository; import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; import org.springframework.stereotype.Component; @@ -27,4 +30,13 @@ public Charge save(Charge charge) { public Optional findById(UUID id) { return repository.findById(id).map(ChargeMapper::toDomain); } + + @Override + public List findExpiredActive(Instant now) { + return repository + .findTop50ByStatusAndExpiresAtBeforeOrderByExpiresAtAsc(ChargeStatus.ACTIVE, now) + .stream() + .map(ChargeMapper::toDomain) + .toList(); + } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java index 1ac5203..aa1df7f 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/SpringDataChargeRepository.java @@ -1,6 +1,13 @@ package com.lmoraesdev.payment.adapter.out.persistence; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import java.time.Instant; +import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; -public interface SpringDataChargeRepository extends JpaRepository {} +public interface SpringDataChargeRepository extends JpaRepository { + + List findTop50ByStatusAndExpiresAtBeforeOrderByExpiresAtAsc( + ChargeStatus status, Instant instant); +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyPersistenceAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyPersistenceAdapter.java new file mode 100644 index 0000000..bbbc1f1 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyPersistenceAdapter.java @@ -0,0 +1,54 @@ +package com.lmoraesdev.payment.adapter.out.persistence.idempotency; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import com.lmoraesdev.payment.application.port.out.IdempotencyPort; +import java.util.Optional; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class IdempotencyPersistenceAdapter implements IdempotencyPort { + + private final IdempotencyRecordJpaRepository repository; + private final ObjectMapper objectMapper; + + public IdempotencyPersistenceAdapter( + IdempotencyRecordJpaRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + @Override + public Optional findByKey(String key) { + return repository + .findByKey(key) + .map( + entity -> + new StoredIdempotency( + entity.getRequestHash(), + toResult(entity.getResponseBody()))); + } + + @Override + public void save(String key, String requestHash, UUID chargeId, CreateChargeResult result) { + repository.save(IdempotencyRecordEntity.create(key, requestHash, chargeId, toJson(result))); + } + + private String toJson(CreateChargeResult result) { + try { + return objectMapper.writeValueAsString(result); + } catch (JsonProcessingException e) { + throw new IllegalStateException("failed to serialize idempotency response", e); + } + } + + private CreateChargeResult toResult(String json) { + try { + return objectMapper.readValue(json, CreateChargeResult.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("failed to deserialize idempotency response", e); + } + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyRecordEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyRecordEntity.java new file mode 100644 index 0000000..d6553cf --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyRecordEntity.java @@ -0,0 +1,79 @@ +package com.lmoraesdev.payment.adapter.out.persistence.idempotency; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.UUID; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Table( + name = "idempotency_records", + uniqueConstraints = + @UniqueConstraint( + name = "uk_idempotency_records_key", + columnNames = "idempotency_key")) +public class IdempotencyRecordEntity { + + @Id private UUID id; + + @Column(name = "idempotency_key", nullable = false, updatable = false) + private String key; + + @Column(name = "request_hash", nullable = false, updatable = false) + private String requestHash; + + @Column(name = "charge_id", nullable = false, updatable = false) + private UUID chargeId; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "response_body", nullable = false, updatable = false, columnDefinition = "jsonb") + private String responseBody; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected IdempotencyRecordEntity() { + // JPA + } + + public static IdempotencyRecordEntity create( + String key, String requestHash, UUID chargeId, String responseBodyJson) { + IdempotencyRecordEntity entity = new IdempotencyRecordEntity(); + entity.id = UUID.randomUUID(); + entity.key = key; + entity.requestHash = requestHash; + entity.chargeId = chargeId; + entity.responseBody = responseBodyJson; + entity.createdAt = Instant.now(); + return entity; + } + + public UUID getId() { + return id; + } + + public String getKey() { + return key; + } + + public String getRequestHash() { + return requestHash; + } + + public UUID getChargeId() { + return chargeId; + } + + public String getResponseBody() { + return responseBody; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyRecordJpaRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyRecordJpaRepository.java new file mode 100644 index 0000000..0ee23cf --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/idempotency/IdempotencyRecordJpaRepository.java @@ -0,0 +1,11 @@ +package com.lmoraesdev.payment.adapter.out.persistence.idempotency; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IdempotencyRecordJpaRepository + extends JpaRepository { + + Optional findByKey(String key); +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java index a1c56b8..116a843 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapter.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import org.slf4j.MDC; import org.springframework.stereotype.Component; @Component @@ -19,7 +20,8 @@ public OutboxEventAdapter(OutboxEventJpaRepository repository, ObjectMapper obje @Override public void record(String aggregateType, String aggregateId, String eventType, Object payload) { OutboxEventEntity event = - OutboxEventEntity.pending(aggregateType, aggregateId, eventType, toJson(payload)); + OutboxEventEntity.pending( + aggregateType, aggregateId, eventType, toJson(payload), MDC.get("traceId")); repository.save(event); } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java index 67445b0..73b4089 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventEntity.java @@ -43,12 +43,19 @@ public class OutboxEventEntity { @Column(name = "published_at") private Instant publishedAt; + @Column(name = "correlation_id") + private String correlationId; + protected OutboxEventEntity() { // JPA } public static OutboxEventEntity pending( - String aggregateType, String aggregateId, String eventType, String payloadJson) { + String aggregateType, + String aggregateId, + String eventType, + String payloadJson, + String correlationId) { OutboxEventEntity event = new OutboxEventEntity(); event.id = UUID.randomUUID(); event.aggregateType = aggregateType; @@ -57,14 +64,23 @@ public static OutboxEventEntity pending( event.payload = payloadJson; event.status = OutboxStatus.PENDING; event.createdAt = Instant.now(); + event.correlationId = correlationId; return event; } + public void markInFlight() { + this.status = OutboxStatus.IN_FLIGHT; + } + public void markPublished() { this.status = OutboxStatus.PUBLISHED; this.publishedAt = Instant.now(); } + public void revertToPending() { + this.status = OutboxStatus.PENDING; + } + public UUID getId() { return id; } @@ -84,4 +100,12 @@ public String getPayload() { public OutboxStatus getStatus() { return status; } + + public Instant getCreatedAt() { + return createdAt; + } + + public String getCorrelationId() { + return correlationId; + } } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java index b30a97f..096d8ca 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventJpaRepository.java @@ -3,8 +3,19 @@ import java.util.List; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface OutboxEventJpaRepository extends JpaRepository { - List findTop50ByStatusOrderByCreatedAtAsc(OutboxStatus status); + @Query( + value = + """ + select * from outbox_events + where status = 'PENDING' + order by created_at asc + limit 50 + for update skip locked + """, + nativeQuery = true) + List findBatchForUpdateSkipLocked(); } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java index 88e609e..cd2b4b5 100644 --- a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxStatus.java @@ -2,5 +2,6 @@ public enum OutboxStatus { PENDING, + IN_FLIGHT, PUBLISHED } diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventEntity.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventEntity.java new file mode 100644 index 0000000..8b0d658 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventEntity.java @@ -0,0 +1,57 @@ +package com.lmoraesdev.payment.adapter.out.persistence.webhook; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table( + name = "webhook_events", + uniqueConstraints = + @UniqueConstraint(name = "uk_webhook_events_event_id", columnNames = "event_id")) +public class WebhookEventEntity { + + @Id private UUID id; + + @Column(name = "event_id", nullable = false, updatable = false) + private String eventId; + + @Column(name = "charge_id", nullable = false, updatable = false) + private UUID chargeId; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + protected WebhookEventEntity() { + // JPA + } + + public static WebhookEventEntity create(String eventId, UUID chargeId) { + WebhookEventEntity entity = new WebhookEventEntity(); + entity.id = UUID.randomUUID(); + entity.eventId = eventId; + entity.chargeId = chargeId; + entity.createdAt = Instant.now(); + return entity; + } + + public UUID getId() { + return id; + } + + public String getEventId() { + return eventId; + } + + public UUID getChargeId() { + return chargeId; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventJpaRepository.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventJpaRepository.java new file mode 100644 index 0000000..fce04f4 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventJpaRepository.java @@ -0,0 +1,9 @@ +package com.lmoraesdev.payment.adapter.out.persistence.webhook; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface WebhookEventJpaRepository extends JpaRepository { + + boolean existsByEventId(String eventId); +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventPersistenceAdapter.java b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventPersistenceAdapter.java new file mode 100644 index 0000000..8051f2c --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/persistence/webhook/WebhookEventPersistenceAdapter.java @@ -0,0 +1,25 @@ +package com.lmoraesdev.payment.adapter.out.persistence.webhook; + +import com.lmoraesdev.payment.application.port.out.WebhookEventPort; +import java.util.UUID; +import org.springframework.stereotype.Component; + +@Component +public class WebhookEventPersistenceAdapter implements WebhookEventPort { + + private final WebhookEventJpaRepository repository; + + public WebhookEventPersistenceAdapter(WebhookEventJpaRepository repository) { + this.repository = repository; + } + + @Override + public boolean existsByEventId(String eventId) { + return repository.existsByEventId(eventId); + } + + @Override + public void save(String eventId, UUID chargeId) { + repository.save(WebhookEventEntity.create(eventId, chargeId)); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/adapter/out/scheduling/ChargeExpirationJob.java b/src/main/java/com/lmoraesdev/payment/adapter/out/scheduling/ChargeExpirationJob.java new file mode 100644 index 0000000..1cc333b --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/adapter/out/scheduling/ChargeExpirationJob.java @@ -0,0 +1,61 @@ +package com.lmoraesdev.payment.adapter.out.scheduling; + +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import com.lmoraesdev.payment.domain.event.ChargeStatusChangedEvent; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import java.time.Instant; +import java.util.List; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class ChargeExpirationJob { + + private final ChargeRepository chargeRepository; + private final OutboxEventPort outboxEventPort; + + public ChargeExpirationJob(ChargeRepository chargeRepository, OutboxEventPort outboxEventPort) { + this.chargeRepository = chargeRepository; + this.outboxEventPort = outboxEventPort; + } + + @Scheduled(fixedDelay = 60000) + @Transactional + public void expireOverdueCharges() { + List expired = chargeRepository.findExpiredActive(Instant.now()); + + for (Charge charge : expired) { + expire(charge); + } + } + + private void expire(Charge charge) { + ChargeStatus previousStatus = charge.getStatus(); + + charge.transitionTo(ChargeStatus.EXPIRED); + + chargeRepository.save(charge); + + outboxEventPort.record( + "Charge", + charge.getId().toString(), + "ChargeExpired", + new ChargeStatusChangedEvent( + charge.getId(), + previousStatus.name(), + ChargeStatus.EXPIRED.name(), + Instant.now())); + + Logger5w1hBuilder.create(ChargeExpirationJob.class) + .where("ChargeExpirationJob") + .what("charge_state_transitioned") + .why("ttl_expired") + .who("system") + .how("scheduled_expiration") + .info(); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java index 0027294..6e0d58e 100644 --- a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeCommand.java @@ -2,4 +2,4 @@ import java.math.BigDecimal; -public record CreateChargeCommand(BigDecimal amount) {} +public record CreateChargeCommand(BigDecimal amount, String idempotencyKey) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java index 748d461..317dac0 100644 --- a/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/CreateChargeResult.java @@ -4,4 +4,5 @@ import java.time.Instant; import java.util.UUID; -public record CreateChargeResult(UUID id, String status, BigDecimal amount, Instant createdAt) {} +public record CreateChargeResult( + UUID id, String status, BigDecimal amount, Instant createdAt, boolean replayed) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/GetCharge.java b/src/main/java/com/lmoraesdev/payment/application/port/in/GetCharge.java new file mode 100644 index 0000000..32c0d9d --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/GetCharge.java @@ -0,0 +1,7 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.util.UUID; + +public interface GetCharge { + GetChargeResult find(UUID id); +} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/GetChargeResult.java b/src/main/java/com/lmoraesdev/payment/application/port/in/GetChargeResult.java new file mode 100644 index 0000000..585b5e2 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/GetChargeResult.java @@ -0,0 +1,7 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +public record GetChargeResult(UUID id, String status, BigDecimal amount, Instant createdAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/ProcessWebhook.java b/src/main/java/com/lmoraesdev/payment/application/port/in/ProcessWebhook.java new file mode 100644 index 0000000..9aa8588 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/ProcessWebhook.java @@ -0,0 +1,5 @@ +package com.lmoraesdev.payment.application.port.in; + +public interface ProcessWebhook { + void process(ProcessWebhookCommand command); +} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/in/ProcessWebhookCommand.java b/src/main/java/com/lmoraesdev/payment/application/port/in/ProcessWebhookCommand.java new file mode 100644 index 0000000..6577f2b --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/in/ProcessWebhookCommand.java @@ -0,0 +1,5 @@ +package com.lmoraesdev.payment.application.port.in; + +import java.util.UUID; + +public record ProcessWebhookCommand(String eventId, UUID chargeId, String status) {} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java index 1597f53..0871d43 100644 --- a/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/ChargeRepository.java @@ -1,6 +1,8 @@ package com.lmoraesdev.payment.application.port.out; import com.lmoraesdev.payment.domain.model.Charge; +import java.time.Instant; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -8,4 +10,6 @@ public interface ChargeRepository { Charge save(Charge charge); Optional findById(UUID id); + + List findExpiredActive(Instant now); } diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/IdempotencyPort.java b/src/main/java/com/lmoraesdev/payment/application/port/out/IdempotencyPort.java new file mode 100644 index 0000000..054f0e8 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/IdempotencyPort.java @@ -0,0 +1,13 @@ +package com.lmoraesdev.payment.application.port.out; + +import com.lmoraesdev.payment.application.port.in.CreateChargeResult; +import java.util.Optional; +import java.util.UUID; + +public interface IdempotencyPort { + Optional findByKey(String key); + + void save(String key, String requestHash, UUID chargeId, CreateChargeResult result); + + record StoredIdempotency(String requestHash, CreateChargeResult result) {} +} diff --git a/src/main/java/com/lmoraesdev/payment/application/port/out/WebhookEventPort.java b/src/main/java/com/lmoraesdev/payment/application/port/out/WebhookEventPort.java new file mode 100644 index 0000000..f20cfbf --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/port/out/WebhookEventPort.java @@ -0,0 +1,9 @@ +package com.lmoraesdev.payment.application.port.out; + +import java.util.UUID; + +public interface WebhookEventPort { + boolean existsByEventId(String eventId); + + void save(String eventId, UUID chargeId); +} diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java index 97bd222..43b4134 100644 --- a/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/CreateChargeService.java @@ -4,11 +4,22 @@ import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; import com.lmoraesdev.payment.application.port.in.CreateChargeResult; import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.application.port.out.IdempotencyPort; +import com.lmoraesdev.payment.application.port.out.IdempotencyPort.StoredIdempotency; import com.lmoraesdev.payment.application.port.out.OutboxEventPort; import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; import com.lmoraesdev.payment.domain.event.ChargeCreatedEvent; +import com.lmoraesdev.payment.domain.exception.IdempotencyConflictException; import com.lmoraesdev.payment.domain.model.Charge; import com.lmoraesdev.payment.domain.model.Money; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,10 +27,18 @@ public class CreateChargeService implements CreateCharge { private final ChargeRepository chargeRepository; private final OutboxEventPort outboxEventPort; + private final IdempotencyPort idempotencyPort; + private final Counter chargesCreatedCounter; - public CreateChargeService(ChargeRepository chargeRepository, OutboxEventPort outboxEventPort) { + public CreateChargeService( + ChargeRepository chargeRepository, + OutboxEventPort outboxEventPort, + IdempotencyPort idempotencyPort, + MeterRegistry meterRegistry) { this.chargeRepository = chargeRepository; this.outboxEventPort = outboxEventPort; + this.idempotencyPort = idempotencyPort; + this.chargesCreatedCounter = meterRegistry.counter("charges_created_total"); } @Override @@ -27,6 +46,12 @@ public CreateChargeService(ChargeRepository chargeRepository, OutboxEventPort ou public CreateChargeResult create(CreateChargeCommand command) { Money amount = new Money(command.amount()); + String requestHash = hash(command.amount()); + + Optional existing = idempotencyPort.findByKey(command.idempotencyKey()); + if (existing.isPresent()) { + return replay(existing.get(), requestHash, command.idempotencyKey()); + } Charge charge = Charge.create(amount); @@ -47,10 +72,37 @@ public CreateChargeResult create(CreateChargeCommand command) { .how("createCharge") .info(); + CreateChargeResult result = + new CreateChargeResult( + saved.getId(), + saved.getStatus().name(), + saved.getAmount().amount(), + saved.getCreatedAt(), + false); + + idempotencyPort.save(command.idempotencyKey(), requestHash, saved.getId(), result); + chargesCreatedCounter.increment(); + + return result; + } + + private CreateChargeResult replay(StoredIdempotency existing, String requestHash, String key) { + if (!existing.requestHash().equals(requestHash)) { + throw new IdempotencyConflictException(key); + } + + CreateChargeResult previous = existing.result(); return new CreateChargeResult( - saved.getId(), - saved.getStatus().name(), - saved.getAmount().amount(), - saved.getCreatedAt()); + previous.id(), previous.status(), previous.amount(), previous.createdAt(), true); + } + + private String hash(BigDecimal amount) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(amount.toPlainString().getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 indisponível", e); + } } } diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/GetChargeService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/GetChargeService.java new file mode 100644 index 0000000..5aabf41 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/GetChargeService.java @@ -0,0 +1,32 @@ +package com.lmoraesdev.payment.application.usecase; + +import com.lmoraesdev.payment.application.port.in.GetCharge; +import com.lmoraesdev.payment.application.port.in.GetChargeResult; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.exception.ChargeNotFoundException; +import com.lmoraesdev.payment.domain.model.Charge; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GetChargeService implements GetCharge { + private final ChargeRepository chargeRepository; + + public GetChargeService(ChargeRepository chargeRepository) { + this.chargeRepository = chargeRepository; + } + + @Override + @Transactional(readOnly = true) + public GetChargeResult find(UUID id) { + Charge charge = + chargeRepository.findById(id).orElseThrow(() -> new ChargeNotFoundException(id)); + + return new GetChargeResult( + charge.getId(), + charge.getStatus().name(), + charge.getAmount().amount(), + charge.getCreatedAt()); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/application/usecase/ProcessWebhookService.java b/src/main/java/com/lmoraesdev/payment/application/usecase/ProcessWebhookService.java new file mode 100644 index 0000000..1f837a2 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/application/usecase/ProcessWebhookService.java @@ -0,0 +1,92 @@ +package com.lmoraesdev.payment.application.usecase; + +import com.lmoraesdev.payment.application.port.in.ProcessWebhook; +import com.lmoraesdev.payment.application.port.in.ProcessWebhookCommand; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import com.lmoraesdev.payment.application.port.out.WebhookEventPort; +import com.lmoraesdev.payment.config.logging.Logger5w1hBuilder; +import com.lmoraesdev.payment.domain.event.ChargeStatusChangedEvent; +import com.lmoraesdev.payment.domain.exception.ChargeNotFoundException; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.Instant; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ProcessWebhookService implements ProcessWebhook { + private final ChargeRepository chargeRepository; + private final OutboxEventPort outboxEventPort; + private final WebhookEventPort webhookEventPort; + private final Counter webhooksProcessedCounter; + + public ProcessWebhookService( + ChargeRepository chargeRepository, + OutboxEventPort outboxEventPort, + WebhookEventPort webhookEventPort, + MeterRegistry meterRegistry) { + this.chargeRepository = chargeRepository; + this.outboxEventPort = outboxEventPort; + this.webhookEventPort = webhookEventPort; + this.webhooksProcessedCounter = meterRegistry.counter("webhooks_processed_total"); + } + + @Override + @Transactional + public void process(ProcessWebhookCommand command) { + webhooksProcessedCounter.increment(); + + if (webhookEventPort.existsByEventId(command.eventId())) { + Logger5w1hBuilder.create(ProcessWebhookService.class) + .where("ProcessWebhookService") + .what("webhook_already_processed") + .why("duplicate webhook event received") + .who("system") + .how("processWebhook") + .info(); + return; + } + + Charge charge = + chargeRepository + .findById(command.chargeId()) + .orElseThrow(() -> new ChargeNotFoundException(command.chargeId())); + + ChargeStatus previousStatus = charge.getStatus(); + ChargeStatus newStatus = ChargeStatus.valueOf(command.status()); + + charge.transitionTo(newStatus); + + chargeRepository.save(charge); + + String eventType = + switch (newStatus) { + case PAID -> "ChargePaid"; + case EXPIRED -> "ChargeExpired"; + case CANCELLED -> "ChargeCancelled"; + default -> + throw new IllegalStateException( + "status inesperado no outbox: " + newStatus); + }; + + outboxEventPort.record( + "Charge", + charge.getId().toString(), + eventType, + new ChargeStatusChangedEvent( + charge.getId(), previousStatus.name(), newStatus.name(), Instant.now())); + + webhookEventPort.save(command.eventId(), command.chargeId()); + + Logger5w1hBuilder.create(ProcessWebhookService.class) + .where("ProcessWebhookService") + .what("charge_state_transitioned") + .why("webhook received from provider") + .who("system") + .how("processWebhook") + .info(); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java index 031b531..95f783f 100644 --- a/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java +++ b/src/main/java/com/lmoraesdev/payment/config/logging/Logger5w1h.java @@ -17,19 +17,19 @@ public static Logger5w1h of(Class clazz) { } public void info(Log5w1h data) { - withFields(logger.atInfo(), data).log(); + withFields(logger.atInfo(), data).log(message(data)); } public void debug(Log5w1h data) { - withFields(logger.atDebug(), data).log(); + withFields(logger.atDebug(), data).log(message(data)); } public void warn(Log5w1h data) { - withFields(logger.atWarn(), data).log(); + withFields(logger.atWarn(), data).log(message(data)); } public void error(Log5w1h data, Throwable cause) { - withFields(logger.atError(), data).setCause(cause).log(); + withFields(logger.atError(), data).setCause(cause).log(message(data)); } private LoggingEventBuilder withFields(LoggingEventBuilder builder, Log5w1h data) { @@ -39,4 +39,8 @@ private LoggingEventBuilder withFields(LoggingEventBuilder builder, Log5w1h data .addKeyValue("what", data.what()) .addKeyValue("how", data.how()); } + + private String message(Log5w1h data) { + return "%s: %s".formatted(data.what(), data.why()); + } } diff --git a/src/main/java/com/lmoraesdev/payment/domain/event/ChargeStatusChangedEvent.java b/src/main/java/com/lmoraesdev/payment/domain/event/ChargeStatusChangedEvent.java new file mode 100644 index 0000000..f23652b --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/event/ChargeStatusChangedEvent.java @@ -0,0 +1,6 @@ +package com.lmoraesdev.payment.domain.event; + +import java.time.Instant; +import java.util.UUID; + +public record ChargeStatusChangedEvent(UUID chargeId, String from, String to, Instant occurredAt) {} diff --git a/src/main/java/com/lmoraesdev/payment/domain/exception/ChargeNotFoundException.java b/src/main/java/com/lmoraesdev/payment/domain/exception/ChargeNotFoundException.java new file mode 100644 index 0000000..8e4fba4 --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/exception/ChargeNotFoundException.java @@ -0,0 +1,10 @@ +package com.lmoraesdev.payment.domain.exception; + +import java.util.UUID; + +public class ChargeNotFoundException extends DomainException { + + public ChargeNotFoundException(UUID id) { + super("CHARGE_NOT_FOUND", "Charge não encontrada: %s".formatted(id)); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/exception/IdempotencyConflictException.java b/src/main/java/com/lmoraesdev/payment/domain/exception/IdempotencyConflictException.java new file mode 100644 index 0000000..9a5697a --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/exception/IdempotencyConflictException.java @@ -0,0 +1,11 @@ +package com.lmoraesdev.payment.domain.exception; + +public class IdempotencyConflictException extends DomainException { + + public IdempotencyConflictException(String key) { + super( + "IDEMPOTENCY_CONFLICT", + "Idempotency-Key já utilizada com um corpo de requisição diferente: %s" + .formatted(key)); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidStateTransitionException.java b/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidStateTransitionException.java new file mode 100644 index 0000000..a7a150b --- /dev/null +++ b/src/main/java/com/lmoraesdev/payment/domain/exception/InvalidStateTransitionException.java @@ -0,0 +1,12 @@ +package com.lmoraesdev.payment.domain.exception; + +import com.lmoraesdev.payment.domain.model.ChargeStatus; + +public class InvalidStateTransitionException extends DomainException { + + public InvalidStateTransitionException(ChargeStatus from, ChargeStatus to) { + super( + "INVALID_STATE_TRANSITION", + "Não é possível transicionar de %s para %s".formatted(from, to)); + } +} diff --git a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java index 5e4544f..ce1c826 100644 --- a/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java +++ b/src/main/java/com/lmoraesdev/payment/domain/model/Charge.java @@ -1,35 +1,66 @@ package com.lmoraesdev.payment.domain.model; +import com.lmoraesdev.payment.domain.exception.InvalidStateTransitionException; +import java.time.Duration; import java.time.Instant; +import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; public class Charge { + + private static final Duration EXPIRATION = Duration.ofMinutes(30); + + private static final Map> VALID_TRANSITIONS = + Map.of( + ChargeStatus.ACTIVE, + Set.of(ChargeStatus.PAID, ChargeStatus.EXPIRED, ChargeStatus.CANCELLED)); + private final UUID id; private final Money amount; private ChargeStatus status; private final Instant createdAt; + private final Instant expiresAt; - private Charge(UUID id, Money amount, ChargeStatus status, Instant createdAt) { + private Charge( + UUID id, Money amount, ChargeStatus status, Instant createdAt, Instant expiresAt) { this.id = id; this.amount = amount; this.status = status; this.createdAt = createdAt; + this.expiresAt = expiresAt; } public static Charge create(Money amount) { Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); - return new Charge(UUID.randomUUID(), amount, ChargeStatus.ACTIVE, Instant.now()); + Instant createdAt = Instant.now(); + return new Charge( + UUID.randomUUID(), + amount, + ChargeStatus.ACTIVE, + createdAt, + createdAt.plus(EXPIRATION)); } - public static Charge restore(UUID id, Money amount, ChargeStatus status, Instant createdAt) { + public static Charge restore( + UUID id, Money amount, ChargeStatus status, Instant createdAt, Instant expiresAt) { Objects.requireNonNull(id, "O id é obrigatório"); Objects.requireNonNull(amount, "O montante (Money) é obrigatório"); Objects.requireNonNull(status, "O status é obrigatório"); Objects.requireNonNull(createdAt, "A data de criação é obrigatória"); + Objects.requireNonNull(expiresAt, "A data de expiração é obrigatória"); + + return new Charge(id, amount, status, createdAt, expiresAt); + } - return new Charge(id, amount, status, createdAt); + public void transitionTo(ChargeStatus newStatus) { + Set allowed = VALID_TRANSITIONS.getOrDefault(status, Set.of()); + if (!allowed.contains(newStatus)) { + throw new InvalidStateTransitionException(status, newStatus); + } + this.status = newStatus; } public UUID getId() { @@ -48,6 +79,10 @@ public Instant getCreatedAt() { return createdAt; } + public Instant getExpiresAt() { + return expiresAt; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b4b1255..9eb1296 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,7 +7,7 @@ spring: password: ${SPRING_DATASOURCE_PASSWORD:admin_pass} jpa: hibernate: - ddl-auto: update + ddl-auto: validate open-in-view: false properties: hibernate: diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..b7719d5 --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,42 @@ +create table charges ( + id uuid not null, + amount_centavos bigint not null, + status varchar(255) not null check (status in ('ACTIVE','PAID','EXPIRED','CANCELLED')), + created_at timestamp(6) with time zone not null, + expires_at timestamp(6) with time zone not null, + primary key (id) +); + +create table idempotency_records ( + id uuid not null, + idempotency_key varchar(255) not null, + request_hash varchar(255) not null, + charge_id uuid not null, + response_body jsonb not null, + created_at timestamp(6) with time zone not null, + primary key (id), + constraint uk_idempotency_records_key unique (idempotency_key) +); + +create table outbox_events ( + id uuid not null, + aggregate_type varchar(255) not null, + aggregate_id varchar(255) not null, + event_type varchar(255) not null, + payload jsonb not null, + status varchar(255) not null check (status in ('PENDING','PUBLISHED')), + created_at timestamp(6) with time zone not null, + published_at timestamp(6) with time zone, + primary key (id) +); + +create index idx_outbox_status_created_at on outbox_events (status, created_at); + +create table webhook_events ( + id uuid not null, + event_id varchar(255) not null, + charge_id uuid not null, + created_at timestamp(6) with time zone not null, + primary key (id), + constraint uk_webhook_events_event_id unique (event_id) +); diff --git a/src/main/resources/db/migration/V2__outbox_events_add_in_flight_status.sql b/src/main/resources/db/migration/V2__outbox_events_add_in_flight_status.sql new file mode 100644 index 0000000..49b54da --- /dev/null +++ b/src/main/resources/db/migration/V2__outbox_events_add_in_flight_status.sql @@ -0,0 +1,5 @@ +alter table outbox_events drop constraint outbox_events_status_check; + +alter table outbox_events + add constraint outbox_events_status_check + check (status in ('PENDING','IN_FLIGHT','PUBLISHED')); diff --git a/src/main/resources/db/migration/V3__outbox_events_add_correlation_id.sql b/src/main/resources/db/migration/V3__outbox_events_add_correlation_id.sql new file mode 100644 index 0000000..15710e6 --- /dev/null +++ b/src/main/resources/db/migration/V3__outbox_events_add_correlation_id.sql @@ -0,0 +1 @@ +alter table outbox_events add column correlation_id varchar(255); diff --git a/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java b/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java index 72855b5..410392e 100644 --- a/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java +++ b/src/test/java/com/lmoraesdev/payment/adapter/in/web/ChargeControllerIT.java @@ -1,10 +1,14 @@ package com.lmoraesdev.payment.adapter.in.web; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.lmoraesdev.payment.support.TestcontainersConfiguration; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +18,7 @@ import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; @SpringBootTest @AutoConfigureMockMvc @@ -29,6 +34,7 @@ class ChargeControllerIT { void createChargeReturns201() throws Exception { mockMvc.perform( post("/charges") + .header("Idempotency-Key", UUID.randomUUID().toString()) .contentType(MediaType.APPLICATION_JSON) .content("{\"amount\": 100.00}")) .andExpect(status().isCreated()) @@ -43,6 +49,7 @@ void createChargeReturns201() throws Exception { void createChargeWithNegativeAmountReturns400() throws Exception { mockMvc.perform( post("/charges") + .header("Idempotency-Key", UUID.randomUUID().toString()) .contentType(MediaType.APPLICATION_JSON) .content("{\"amount\": -1}")) .andExpect(status().isBadRequest()) @@ -53,9 +60,100 @@ void createChargeWithNegativeAmountReturns400() throws Exception { @Test @DisplayName("POST /charges sem amount → 400 Problem Details com errors.amount") void createChargeWithMissingAmountReturns400() throws Exception { - mockMvc.perform(post("/charges").contentType(MediaType.APPLICATION_JSON).content("{}")) + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.errors.amount").exists()); } + + @Test + @DisplayName("POST /charges sem header Idempotency-Key → 400 Problem Details") + void createChargeWithoutIdempotencyKeyReturns400() throws Exception { + mockMvc.perform( + post("/charges") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + @DisplayName( + "POST /charges duas vezes com mesma Idempotency-Key e mesmo body → 201 depois 200 com o mesmo id") + void createChargeWithRepeatedIdempotencyKeyReturnsReplay() throws Exception { + String idempotencyKey = UUID.randomUUID().toString(); + + MvcResult first = + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", idempotencyKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isCreated()) + .andReturn(); + + JsonNode firstBody = new ObjectMapper().readTree(first.getResponse().getContentAsString()); + String id = firstBody.get("id").asText(); + + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", idempotencyKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id)); + } + + @Test + @DisplayName("POST /charges duas vezes com mesma Idempotency-Key e body diferente → 422") + void createChargeWithRepeatedIdempotencyKeyAndDifferentBodyReturns422() throws Exception { + String idempotencyKey = UUID.randomUUID().toString(); + + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", idempotencyKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isCreated()); + + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", idempotencyKey) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 200.00}")) + .andExpect(status().is(422)); + } + + @Test + @DisplayName("GET /charges/{id} de charge existente → 200 com id, status e amount") + void getChargeReturns200() throws Exception { + MvcResult created = + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isCreated()) + .andReturn(); + + JsonNode body = new ObjectMapper().readTree(created.getResponse().getContentAsString()); + String id = body.get("id").asText(); + + mockMvc.perform(get("/charges/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id)) + .andExpect(jsonPath("$.status").value("ACTIVE")) + .andExpect(jsonPath("$.amount").value(100.00)) + .andExpect(jsonPath("$.createdAt").isNotEmpty()); + } + + @Test + @DisplayName("GET /charges/{id} com UUID inexistente → 404") + void getChargeWithUnknownIdReturns404() throws Exception { + mockMvc.perform(get("/charges/{id}", UUID.randomUUID())).andExpect(status().isNotFound()); + } } diff --git a/src/test/java/com/lmoraesdev/payment/adapter/in/web/WebhookControllerIT.java b/src/test/java/com/lmoraesdev/payment/adapter/in/web/WebhookControllerIT.java new file mode 100644 index 0000000..1ad9fe5 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/in/web/WebhookControllerIT.java @@ -0,0 +1,106 @@ +package com.lmoraesdev.payment.adapter.in.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.lmoraesdev.payment.support.TestcontainersConfiguration; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +@ActiveProfiles("test") +@DisplayName("WebhookControllerIT") +class WebhookControllerIT { + + @Autowired MockMvc mockMvc; + + private String createCharge() throws Exception { + MvcResult created = + mockMvc.perform( + post("/charges") + .header("Idempotency-Key", UUID.randomUUID().toString()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"amount\": 100.00}")) + .andExpect(status().isCreated()) + .andReturn(); + + JsonNode body = new ObjectMapper().readTree(created.getResponse().getContentAsString()); + return body.get("id").asText(); + } + + @Test + @DisplayName("POST /webhooks/provider com status PAID → 200 e transiciona a charge para PAID") + void webhookTransitionsChargeToPaid() throws Exception { + String chargeId = createCharge(); + String eventId = UUID.randomUUID().toString(); + + mockMvc.perform( + post("/webhooks/provider") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"eventId\": \"%s\", \"chargeId\": \"%s\", \"status\": \"PAID\"}" + .formatted(eventId, chargeId))) + .andExpect(status().isOk()); + + mockMvc.perform(get("/charges/{id}", chargeId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PAID")); + } + + @Test + @DisplayName("POST /webhooks/provider com eventId repetido → 200 e não duplica o processamento") + void webhookWithRepeatedEventIdIsIdempotent() throws Exception { + String chargeId = createCharge(); + String eventId = UUID.randomUUID().toString(); + String payload = + "{\"eventId\": \"%s\", \"chargeId\": \"%s\", \"status\": \"PAID\"}" + .formatted(eventId, chargeId); + + mockMvc.perform( + post("/webhooks/provider") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()); + + mockMvc.perform( + post("/webhooks/provider") + .contentType(MediaType.APPLICATION_JSON) + .content(payload)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/charges/{id}", chargeId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PAID")); + } + + @Test + @DisplayName("POST /webhooks/provider com status inválido → 400 Problem Details") + void webhookWithInvalidStatusReturns400() throws Exception { + String chargeId = createCharge(); + String eventId = UUID.randomUUID().toString(); + + mockMvc.perform( + post("/webhooks/provider") + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"eventId\": \"%s\", \"chargeId\": \"%s\", \"status\": \"FOO\"}" + .formatted(eventId, chargeId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelayTest.java b/src/test/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelayTest.java new file mode 100644 index 0000000..6e3e2ea --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/messaging/OutboxRelayTest.java @@ -0,0 +1,161 @@ +package com.lmoraesdev.payment.adapter.out.messaging; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventEntity; +import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxEventJpaRepository; +import com.lmoraesdev.payment.adapter.out.persistence.outbox.OutboxStatus; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; + +@DisplayName("OutboxRelay") +@ExtendWith(MockitoExtension.class) +class OutboxRelayTest { + + @Mock OutboxEventJpaRepository repository; + + @Mock KafkaTemplate kafkaTemplate; + + SimpleMeterRegistry meterRegistry; + + OutboxRelay relay; + + private final ListAppender appender = new ListAppender<>(); + private Logger logger; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + relay = new OutboxRelay(repository, kafkaTemplate, meterRegistry); + logger = (Logger) LoggerFactory.getLogger(OutboxRelay.class); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + } + + @Test + @DisplayName("reivindica lote PENDING como IN_FLIGHT, publica e marca PUBLISHED") + void claimsPublishesAndMarksPublished() { + OutboxEventEntity event = + OutboxEventEntity.pending("Charge", "charge-1", "ChargeCreated", "{}", null); + when(repository.findBatchForUpdateSkipLocked()).thenReturn(List.of(event)); + when(repository.saveAll(List.of(event))).thenReturn(List.of(event)); + when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(kafkaTemplate.send(any(String.class), any(), any())) + .thenReturn(CompletableFuture.completedFuture(mockSendResult())); + + relay.publishPending(); + + assertThat(event.getStatus()).isEqualTo(OutboxStatus.PUBLISHED); + verify(repository).saveAll(List.of(event)); + verify(repository).save(event); + assertThat(meterRegistry.counter("outbox_events_published_total").count()).isEqualTo(1.0); + assertThat(meterRegistry.counter("outbox_events_failed_total").count()).isEqualTo(0.0); + assertThat(meterRegistry.timer("outbox_publish_lag").count()).isEqualTo(1L); + } + + @Test + @DisplayName("claimBatch marca o lote reivindicado como IN_FLIGHT antes de publicar") + void claimBatchMarksEventsInFlight() { + OutboxEventEntity event = + OutboxEventEntity.pending("Charge", "charge-2", "ChargeCreated", "{}", null); + when(repository.findBatchForUpdateSkipLocked()).thenReturn(List.of(event)); + when(repository.saveAll(List.of(event))) + .thenAnswer( + inv -> { + assertThat(event.getStatus()).isEqualTo(OutboxStatus.IN_FLIGHT); + return List.of(event); + }); + + List claimed = relay.claimBatch(); + + assertThat(claimed).containsExactly(event); + } + + @Test + @DisplayName("falha ao publicar loga via Logger5w1hBuilder e reverte pra PENDING") + void logsFailureAndRevertsToPending() { + OutboxEventEntity event = + OutboxEventEntity.pending( + "Charge", "charge-3", "ChargeCreated", "{}", "trace-original-request"); + when(repository.findBatchForUpdateSkipLocked()).thenReturn(List.of(event)); + when(repository.saveAll(List.of(event))).thenReturn(List.of(event)); + when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(kafkaTemplate.send(any(String.class), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("kafka down"))); + + relay.publishPending(); + + assertThat(event.getStatus()).isEqualTo(OutboxStatus.PENDING); + verify(repository).save(event); + + assertThat(appender.list).hasSize(1); + ILoggingEvent logged = appender.list.get(0); + assertThat(logged.getLevel().toString()).isEqualTo("ERROR"); + assertThat(logged.getFormattedMessage()).startsWith("outbox_publish_failed:"); + assertThat(logged.getThrowableProxy().getMessage()).contains("kafka down"); + assertThat(meterRegistry.counter("outbox_events_failed_total").count()).isEqualTo(1.0); + assertThat(meterRegistry.counter("outbox_events_published_total").count()).isEqualTo(0.0); + assertThat(logged.getMDCPropertyMap()).containsEntry("traceId", "trace-original-request"); + assertThat(MDC.get("traceId")).isNull(); + } + + @Test + @DisplayName("evento sem correlationId não mexe no MDC ao logar a falha") + void doesNotTouchMdcWhenCorrelationIdIsAbsent() { + OutboxEventEntity event = + OutboxEventEntity.pending("Charge", "charge-6", "ChargeCreated", "{}", null); + when(repository.findBatchForUpdateSkipLocked()).thenReturn(List.of(event)); + when(repository.saveAll(List.of(event))).thenReturn(List.of(event)); + when(repository.findById(event.getId())).thenReturn(Optional.of(event)); + when(kafkaTemplate.send(any(String.class), any(), any())) + .thenReturn(CompletableFuture.failedFuture(new RuntimeException("kafka down"))); + + relay.publishPending(); + + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getMDCPropertyMap()).doesNotContainKey("traceId"); + assertThat(MDC.get("traceId")).isNull(); + } + + @Test + @DisplayName("lote vazio não publica nem salva nada") + void doesNothingWhenBatchIsEmpty() { + when(repository.findBatchForUpdateSkipLocked()).thenReturn(List.of()); + when(repository.saveAll(List.of())).thenReturn(List.of()); + + relay.publishPending(); + + verify(kafkaTemplate, never()).send(any(String.class), any(), any()); + verify(repository, never()).save(any()); + } + + @SuppressWarnings("unchecked") + private SendResult mockSendResult() { + return org.mockito.Mockito.mock(SendResult.class); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java index 48343be..476a0bf 100644 --- a/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/ChargeRepositoryIT.java @@ -4,9 +4,14 @@ import com.lmoraesdev.payment.application.port.out.ChargeRepository; import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; import com.lmoraesdev.payment.support.AbstractIntegrationTest; import com.lmoraesdev.payment.testdata.ChargeTestData; +import java.time.Duration; +import java.time.Instant; +import java.util.List; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -41,4 +46,27 @@ void findByIdReturnsEmptyForUnknownId() { assertThat(found).isEmpty(); } + + @Test + @DisplayName("findExpiredActive retorna charges ACTIVE com expiresAt no passado") + void findExpiredActiveReturnsOverdueActiveCharges() { + Instant createdAt = Instant.now().minus(Duration.ofMinutes(31)); + Instant overdueExpiresAt = Instant.now().minus(Duration.ofMinutes(1)); + Charge overdue = + Charge.restore( + UUID.randomUUID(), + ChargeTestData.money("10.00"), + ChargeStatus.ACTIVE, + createdAt, + overdueExpiresAt); + chargeRepository.save(overdue); + + Charge notYetExpired = ChargeTestData.aCharge().withStatus(ChargeStatus.ACTIVE).build(); + chargeRepository.save(notYetExpired); + + List result = chargeRepository.findExpiredActive(Instant.now()); + + assertThat(result).extracting(Charge::getId).contains(overdue.getId()); + assertThat(result).extracting(Charge::getId).doesNotContain(notYetExpired.getId()); + } } diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapterTest.java b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapterTest.java new file mode 100644 index 0000000..02537c1 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventAdapterTest.java @@ -0,0 +1,57 @@ +package com.lmoraesdev.payment.adapter.out.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; + +@DisplayName("OutboxEventAdapter") +@ExtendWith(MockitoExtension.class) +class OutboxEventAdapterTest { + + @Mock OutboxEventJpaRepository repository; + + OutboxEventAdapter adapter; + + @BeforeEach + void setUp() { + adapter = new OutboxEventAdapter(repository, new ObjectMapper()); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Test + @DisplayName("captura o traceId do MDC e grava junto com o evento") + void capturesCorrelationIdFromMdc() { + MDC.put("traceId", "trace-123"); + + adapter.record("Charge", "charge-1", "ChargeCreated", Map.of("amount", "10.00")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEventEntity.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getCorrelationId()).isEqualTo("trace-123"); + } + + @Test + @DisplayName("grava correlationId nulo quando não há traceId no MDC") + void recordsNullCorrelationIdWhenMdcIsEmpty() { + adapter.record("Charge", "charge-2", "ChargeCreated", Map.of("amount", "20.00")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEventEntity.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getCorrelationId()).isNull(); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventRepositoryIT.java b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventRepositoryIT.java new file mode 100644 index 0000000..cda4d45 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/persistence/outbox/OutboxEventRepositoryIT.java @@ -0,0 +1,54 @@ +package com.lmoraesdev.payment.adapter.out.persistence.outbox; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.lmoraesdev.payment.support.AbstractIntegrationTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("OutboxEventJpaRepository") +class OutboxEventRepositoryIT extends AbstractIntegrationTest { + + @Autowired OutboxEventJpaRepository repository; + + @Test + @DisplayName("findBatchForUpdateSkipLocked retorna só PENDING, ordenado por created_at") + void findBatchForUpdateSkipLockedReturnsOnlyPendingOrderedByCreatedAt() { + OutboxEventEntity first = repository.save(pending("aggregate-1")); + OutboxEventEntity second = repository.save(pending("aggregate-2")); + OutboxEventEntity published = repository.save(pending("aggregate-3")); + published.markPublished(); + repository.save(published); + + List batch = repository.findBatchForUpdateSkipLocked(); + + assertThat(batch) + .extracting(OutboxEventEntity::getId) + .containsExactly(first.getId(), second.getId()); + assertThat(batch) + .extracting(OutboxEventEntity::getStatus) + .containsOnly(OutboxStatus.PENDING); + } + + @Test + @DisplayName("save e findBatchForUpdateSkipLocked preservam o correlationId") + void findBatchForUpdateSkipLockedPreservesCorrelationId() { + OutboxEventEntity saved = + repository.save( + OutboxEventEntity.pending( + "Charge", "aggregate-4", "ChargeCreated", "{}", "trace-abc")); + + List batch = repository.findBatchForUpdateSkipLocked(); + + assertThat(batch) + .filteredOn(event -> event.getId().equals(saved.getId())) + .extracting(OutboxEventEntity::getCorrelationId) + .containsExactly("trace-abc"); + } + + private OutboxEventEntity pending(String aggregateId) { + return OutboxEventEntity.pending("Charge", aggregateId, "ChargeCreated", "{}", null); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/adapter/out/scheduling/ChargeExpirationJobTest.java b/src/test/java/com/lmoraesdev/payment/adapter/out/scheduling/ChargeExpirationJobTest.java new file mode 100644 index 0000000..aba5903 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/adapter/out/scheduling/ChargeExpirationJobTest.java @@ -0,0 +1,57 @@ +package com.lmoraesdev.payment.adapter.out.scheduling; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import com.lmoraesdev.payment.testdata.ChargeTestData; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("ChargeExpirationJob") +@ExtendWith(MockitoExtension.class) +class ChargeExpirationJobTest { + + @Mock ChargeRepository chargeRepository; + + @Mock OutboxEventPort outboxEventPort; + + @InjectMocks ChargeExpirationJob job; + + @Test + @DisplayName("charge vencida transiciona pra EXPIRED, salva e grava outbox") + void expiresOverdueCharge() { + Charge charge = ChargeTestData.aCharge().withStatus(ChargeStatus.ACTIVE).build(); + when(chargeRepository.findExpiredActive(any())).thenReturn(List.of(charge)); + + job.expireOverdueCharges(); + + assertThat(charge.getStatus()).isEqualTo(ChargeStatus.EXPIRED); + verify(chargeRepository).save(charge); + verify(outboxEventPort) + .record(eq("Charge"), eq(charge.getId().toString()), eq("ChargeExpired"), any()); + } + + @Test + @DisplayName("lista vazia não faz nada") + void doesNothingWhenListIsEmpty() { + when(chargeRepository.findExpiredActive(any())).thenReturn(List.of()); + + job.expireOverdueCharges(); + + verify(chargeRepository, never()).save(any()); + verify(outboxEventPort, never()).record(any(), any(), any(), any()); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java index 4837df0..41d4384 100644 --- a/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/CreateChargeServiceTest.java @@ -4,23 +4,35 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.lmoraesdev.payment.application.port.in.CreateChargeCommand; import com.lmoraesdev.payment.application.port.in.CreateChargeResult; import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.application.port.out.IdempotencyPort; +import com.lmoraesdev.payment.application.port.out.IdempotencyPort.StoredIdempotency; import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import com.lmoraesdev.payment.domain.exception.IdempotencyConflictException; import com.lmoraesdev.payment.domain.exception.InvalidAmountException; import com.lmoraesdev.payment.domain.model.ChargeStatus; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import java.util.Optional; +import java.util.UUID; import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -32,7 +44,19 @@ class CreateChargeServiceTest { @Mock OutboxEventPort outboxEventPort; - @InjectMocks CreateChargeService service; + @Mock IdempotencyPort idempotencyPort; + + SimpleMeterRegistry meterRegistry; + + CreateChargeService service; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + service = + new CreateChargeService( + chargeRepository, outboxEventPort, idempotencyPort, meterRegistry); + } record Case(String name, String amount) { @Override @@ -48,34 +72,99 @@ static Stream validAmounts() { new Case("valor alto", "50000.00")); } + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + @ParameterizedTest @MethodSource("validAmounts") - @DisplayName("cria cobrança e retorna result com status ACTIVE") + @DisplayName("cria cobrança nova, grava outbox e registra idempotency record") void createsChargeSuccessfully(Case c) { when(chargeRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); CreateChargeResult result = - service.create(new CreateChargeCommand(new BigDecimal(c.amount()))); + service.create( + new CreateChargeCommand(new BigDecimal(c.amount()), "key-" + c.name())); assertThat(result.id()).isNotNull(); assertThat(result.status()).isEqualTo(ChargeStatus.ACTIVE.name()); assertThat(result.amount()).isEqualByComparingTo(c.amount()); assertThat(result.createdAt()).isNotNull(); + assertThat(result.replayed()).isFalse(); verify(chargeRepository).save(any()); verify(outboxEventPort).record(eq("Charge"), any(), eq("ChargeCreated"), any()); + verify(idempotencyPort).save(eq("key-" + c.name()), any(), any(), eq(result)); + assertThat(meterRegistry.counter("charges_created_total").count()).isEqualTo(1.0); } @Test @DisplayName("propaga InvalidAmountException para amount zero ou negativo") void propagatesExceptionForInvalidAmount() { - assertThatThrownBy(() -> service.create(new CreateChargeCommand(BigDecimal.ZERO))) + assertThatThrownBy( + () -> + service.create( + new CreateChargeCommand(BigDecimal.ZERO, "key-invalid"))) .isInstanceOf(InvalidAmountException.class); } @Test @DisplayName("propaga InvalidAmountException para amount nulo") void propagatesExceptionForNullAmount() { - assertThatThrownBy(() -> service.create(new CreateChargeCommand(null))) + assertThatThrownBy(() -> service.create(new CreateChargeCommand(null, "key-null"))) .isInstanceOf(InvalidAmountException.class); } + + @Test + @DisplayName("idempotency key repetida com mesmo body retorna replay sem criar charge nova") + void returnsReplayForRepeatedKeyWithSameBody() { + CreateChargeResult stored = + new CreateChargeResult( + UUID.randomUUID(), + "ACTIVE", + new BigDecimal("100.00"), + Instant.now(), + false); + when(idempotencyPort.findByKey("key-replay")) + .thenReturn(Optional.of(new StoredIdempotency(sha256("100.00"), stored))); + + CreateChargeResult result = + service.create(new CreateChargeCommand(new BigDecimal("100.00"), "key-replay")); + + assertThat(result.id()).isEqualTo(stored.id()); + assertThat(result.status()).isEqualTo(stored.status()); + assertThat(result.amount()).isEqualByComparingTo(stored.amount()); + assertThat(result.createdAt()).isEqualTo(stored.createdAt()); + assertThat(result.replayed()).isTrue(); + verify(chargeRepository, never()).save(any()); + verify(outboxEventPort, never()).record(any(), any(), any(), any()); + assertThat(meterRegistry.counter("charges_created_total").count()).isEqualTo(0.0); + } + + @Test + @DisplayName("idempotency key repetida com body diferente lança IdempotencyConflictException") + void throwsConflictForRepeatedKeyWithDifferentBody() { + CreateChargeResult stored = + new CreateChargeResult( + UUID.randomUUID(), + "ACTIVE", + new BigDecimal("100.00"), + Instant.now(), + false); + when(idempotencyPort.findByKey("key-conflict")) + .thenReturn(Optional.of(new StoredIdempotency(sha256("100.00"), stored))); + + assertThatThrownBy( + () -> + service.create( + new CreateChargeCommand( + new BigDecimal("200.00"), "key-conflict"))) + .isInstanceOf(IdempotencyConflictException.class); + verify(chargeRepository, never()).save(any()); + } } diff --git a/src/test/java/com/lmoraesdev/payment/application/usecase/GetChargeServiceTest.java b/src/test/java/com/lmoraesdev/payment/application/usecase/GetChargeServiceTest.java new file mode 100644 index 0000000..b5da0fc --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/GetChargeServiceTest.java @@ -0,0 +1,53 @@ +package com.lmoraesdev.payment.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.lmoraesdev.payment.application.port.in.GetChargeResult; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.domain.exception.ChargeNotFoundException; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import com.lmoraesdev.payment.testdata.ChargeTestData; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("GetChargeService") +@ExtendWith(MockitoExtension.class) +class GetChargeServiceTest { + + @Mock ChargeRepository chargeRepository; + + @InjectMocks GetChargeService service; + + @Test + @DisplayName("retorna result correspondente à charge encontrada") + void returnsResultForExistingCharge() { + Charge charge = ChargeTestData.aCharge().withStatus(ChargeStatus.PAID).build(); + when(chargeRepository.findById(eq(charge.getId()))).thenReturn(Optional.of(charge)); + + GetChargeResult result = service.find(charge.getId()); + + assertThat(result.id()).isEqualTo(charge.getId()); + assertThat(result.status()).isEqualTo(ChargeStatus.PAID.name()); + assertThat(result.amount()).isEqualByComparingTo(charge.getAmount().amount()); + assertThat(result.createdAt()).isEqualTo(charge.getCreatedAt()); + } + + @Test + @DisplayName("lança ChargeNotFoundException quando a charge não existe") + void throwsChargeNotFoundExceptionWhenMissing() { + UUID id = UUID.randomUUID(); + when(chargeRepository.findById(eq(id))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.find(id)).isInstanceOf(ChargeNotFoundException.class); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/application/usecase/ProcessWebhookServiceTest.java b/src/test/java/com/lmoraesdev/payment/application/usecase/ProcessWebhookServiceTest.java new file mode 100644 index 0000000..99ea4c6 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/application/usecase/ProcessWebhookServiceTest.java @@ -0,0 +1,128 @@ +package com.lmoraesdev.payment.application.usecase; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.lmoraesdev.payment.application.port.in.ProcessWebhookCommand; +import com.lmoraesdev.payment.application.port.out.ChargeRepository; +import com.lmoraesdev.payment.application.port.out.OutboxEventPort; +import com.lmoraesdev.payment.application.port.out.WebhookEventPort; +import com.lmoraesdev.payment.domain.exception.ChargeNotFoundException; +import com.lmoraesdev.payment.domain.exception.InvalidStateTransitionException; +import com.lmoraesdev.payment.domain.model.Charge; +import com.lmoraesdev.payment.domain.model.ChargeStatus; +import com.lmoraesdev.payment.testdata.ChargeTestData; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayName("ProcessWebhookService") +@ExtendWith(MockitoExtension.class) +class ProcessWebhookServiceTest { + + @Mock ChargeRepository chargeRepository; + + @Mock OutboxEventPort outboxEventPort; + + @Mock WebhookEventPort webhookEventPort; + + SimpleMeterRegistry meterRegistry; + + ProcessWebhookService service; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + service = + new ProcessWebhookService( + chargeRepository, outboxEventPort, webhookEventPort, meterRegistry); + } + + @Test + @DisplayName("evento novo transiciona ACTIVE->PAID, grava outbox e registra dedup") + void transitionsChargeAndRecordsOutboxAndDedup() { + Charge charge = ChargeTestData.aCharge().withStatus(ChargeStatus.ACTIVE).build(); + when(webhookEventPort.existsByEventId("event-1")).thenReturn(false); + when(chargeRepository.findById(charge.getId())).thenReturn(Optional.of(charge)); + + service.process(new ProcessWebhookCommand("event-1", charge.getId(), "PAID")); + + assertThat(charge.getStatus()).isEqualTo(ChargeStatus.PAID); + verify(chargeRepository).save(charge); + verify(outboxEventPort) + .record(eq("Charge"), eq(charge.getId().toString()), eq("ChargePaid"), any()); + verify(webhookEventPort).save("event-1", charge.getId()); + assertThat(meterRegistry.counter("webhooks_processed_total").count()).isEqualTo(1.0); + } + + @Test + @DisplayName("evento duplicado não toca em charge nem outbox") + void ignoresDuplicateEvent() { + when(webhookEventPort.existsByEventId("event-2")).thenReturn(true); + + service.process(new ProcessWebhookCommand("event-2", UUID.randomUUID(), "PAID")); + + verify(chargeRepository, never()).findById(any()); + verify(chargeRepository, never()).save(any()); + verify(outboxEventPort, never()).record(any(), any(), any(), any()); + verify(webhookEventPort, never()).save(any(), any()); + assertThat(meterRegistry.counter("webhooks_processed_total").count()).isEqualTo(1.0); + } + + @Test + @DisplayName("charge inexistente lança ChargeNotFoundException") + void throwsChargeNotFoundExceptionWhenChargeMissing() { + UUID chargeId = UUID.randomUUID(); + when(webhookEventPort.existsByEventId("event-3")).thenReturn(false); + when(chargeRepository.findById(chargeId)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + service.process( + new ProcessWebhookCommand("event-3", chargeId, "PAID"))) + .isInstanceOf(ChargeNotFoundException.class); + } + + @Test + @DisplayName( + "evento CANCELLED transiciona ACTIVE->CANCELLED e grava outbox como ChargeCancelled") + void transitionsChargeToCancelledAndRecordsOutboxAsChargeCancelled() { + Charge charge = ChargeTestData.aCharge().withStatus(ChargeStatus.ACTIVE).build(); + when(webhookEventPort.existsByEventId("event-5")).thenReturn(false); + when(chargeRepository.findById(charge.getId())).thenReturn(Optional.of(charge)); + + service.process(new ProcessWebhookCommand("event-5", charge.getId(), "CANCELLED")); + + assertThat(charge.getStatus()).isEqualTo(ChargeStatus.CANCELLED); + verify(outboxEventPort) + .record(eq("Charge"), eq(charge.getId().toString()), eq("ChargeCancelled"), any()); + } + + @Test + @DisplayName("transição inválida (charge já PAID) lança InvalidStateTransitionException") + void throwsInvalidStateTransitionExceptionForInvalidTransition() { + Charge charge = ChargeTestData.aCharge().withStatus(ChargeStatus.PAID).build(); + when(webhookEventPort.existsByEventId("event-4")).thenReturn(false); + when(chargeRepository.findById(charge.getId())).thenReturn(Optional.of(charge)); + + assertThatThrownBy( + () -> + service.process( + new ProcessWebhookCommand( + "event-4", charge.getId(), "PAID"))) + .isInstanceOf(InvalidStateTransitionException.class); + verify(chargeRepository, never()).save(any()); + verify(webhookEventPort, never()).save(any(), any()); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/architecture/DomainPurityArchTest.java b/src/test/java/com/lmoraesdev/payment/architecture/DomainPurityArchTest.java new file mode 100644 index 0000000..ca3717a --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/architecture/DomainPurityArchTest.java @@ -0,0 +1,30 @@ +package com.lmoraesdev.payment.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("DomainPurity") +class DomainPurityArchTest { + + @Test + @DisplayName("domain/** não pode depender de Spring nem de jakarta.persistence") + void domainMustNotDependOnSpringOrJpa() { + JavaClasses importedClasses = + new ClassFileImporter().importPackages("com.lmoraesdev.payment"); + + ArchRule rule = + noClasses() + .that() + .resideInAPackage("..domain..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.springframework..", "jakarta.persistence.."); + + rule.check(importedClasses); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/config/logging/Logger5w1hTest.java b/src/test/java/com/lmoraesdev/payment/config/logging/Logger5w1hTest.java new file mode 100644 index 0000000..ea662c2 --- /dev/null +++ b/src/test/java/com/lmoraesdev/payment/config/logging/Logger5w1hTest.java @@ -0,0 +1,59 @@ +package com.lmoraesdev.payment.config.logging; + +import static org.assertj.core.api.Assertions.assertThat; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +@DisplayName("Logger5w1h") +class Logger5w1hTest { + + private final ListAppender appender = new ListAppender<>(); + private Logger logger; + + @BeforeEach + void setUp() { + logger = (Logger) LoggerFactory.getLogger(Logger5w1hTest.class); + appender.start(); + logger.addAppender(appender); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + } + + @Test + @DisplayName("info() grava mensagem formatada com what e why, nunca null") + void infoLogsFormattedMessageNotNull() { + Logger5w1h.of(Logger5w1hTest.class) + .info(new Log5w1h("where", "why happened", "who", "charge_created", "how")); + + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getFormattedMessage()) + .isEqualTo("charge_created: why happened"); + } + + @Test + @DisplayName("error() define a causa e mantém a mensagem formatada") + void errorLogsFormattedMessageAndCause() { + RuntimeException cause = new RuntimeException("boom"); + + Logger5w1h.of(Logger5w1hTest.class) + .error( + new Log5w1h( + "where", "unhandled_exception", "who", "unexpected_error", "how"), + cause); + + assertThat(appender.list).hasSize(1); + assertThat(appender.list.get(0).getFormattedMessage()) + .isEqualTo("unexpected_error: unhandled_exception"); + assertThat(appender.list.get(0).getThrowableProxy().getMessage()).isEqualTo("boom"); + } +} diff --git a/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java b/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java index fdc9e53..81f00b0 100644 --- a/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java +++ b/src/test/java/com/lmoraesdev/payment/domain/model/ChargeTest.java @@ -3,7 +3,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.lmoraesdev.payment.domain.exception.InvalidStateTransitionException; import java.math.BigDecimal; +import java.time.Duration; import java.time.Instant; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -32,32 +34,81 @@ void createRejectsNullAmount() { assertThatThrownBy(() -> Charge.create(null)).isInstanceOf(NullPointerException.class); } + @Test + @DisplayName("create define expiresAt 30 minutos após createdAt") + void createSetsExpiresAtThirtyMinutesAhead() { + Charge charge = Charge.create(amount); + + assertThat(charge.getExpiresAt()) + .isEqualTo(charge.getCreatedAt().plus(Duration.ofMinutes(30))); + } + @Test @DisplayName("restore reconstitui sem gerar novo id ou data") void restorePreservesIdAndTimestamp() { UUID id = UUID.randomUUID(); Instant createdAt = Instant.parse("2025-01-01T00:00:00Z"); + Instant expiresAt = createdAt.plus(Duration.ofMinutes(30)); - Charge charge = Charge.restore(id, amount, ChargeStatus.PAID, createdAt); + Charge charge = Charge.restore(id, amount, ChargeStatus.PAID, createdAt, expiresAt); assertThat(charge.getId()).isEqualTo(id); assertThat(charge.getStatus()).isEqualTo(ChargeStatus.PAID); assertThat(charge.getAmount()).isEqualTo(amount); assertThat(charge.getCreatedAt()).isEqualTo(createdAt); + assertThat(charge.getExpiresAt()).isEqualTo(expiresAt); } @Test @DisplayName("equals e hashCode baseados somente no id") void equalityIsIdBased() { UUID id = UUID.randomUUID(); - Charge a = Charge.restore(id, amount, ChargeStatus.ACTIVE, Instant.now()); + Instant now = Instant.now(); + Charge a = + Charge.restore( + id, amount, ChargeStatus.ACTIVE, now, now.plus(Duration.ofMinutes(30))); Charge b = Charge.restore( - id, new Money(new BigDecimal("99.00")), ChargeStatus.PAID, Instant.now()); - Charge c = Charge.restore(UUID.randomUUID(), amount, ChargeStatus.ACTIVE, Instant.now()); + id, + new Money(new BigDecimal("99.00")), + ChargeStatus.PAID, + now, + now.plus(Duration.ofMinutes(30))); + Charge c = + Charge.restore( + UUID.randomUUID(), + amount, + ChargeStatus.ACTIVE, + now, + now.plus(Duration.ofMinutes(30))); assertThat(a).isEqualTo(b); assertThat(a).isNotEqualTo(c); assertThat(a.hashCode()).isEqualTo(b.hashCode()); } + + @Test + @DisplayName("transitionTo muda status em uma transição válida") + void transitionToChangesStatusOnValidTransition() { + Charge charge = Charge.create(amount); + + charge.transitionTo(ChargeStatus.PAID); + + assertThat(charge.getStatus()).isEqualTo(ChargeStatus.PAID); + } + + @Test + @DisplayName("transitionTo rejeita transição inválida") + void transitionToRejectsInvalidTransition() { + UUID id = UUID.randomUUID(); + Instant now = Instant.now(); + Charge charge = + Charge.restore( + id, amount, ChargeStatus.PAID, now, now.plus(Duration.ofMinutes(30))); + + assertThatThrownBy(() -> charge.transitionTo(ChargeStatus.ACTIVE)) + .isInstanceOf(InvalidStateTransitionException.class) + .hasMessageContaining("PAID") + .hasMessageContaining("ACTIVE"); + } } diff --git a/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java index 24700db..909e214 100644 --- a/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java +++ b/src/test/java/com/lmoraesdev/payment/support/AbstractIntegrationTest.java @@ -5,7 +5,7 @@ import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; -@DataJpaTest +@DataJpaTest(showSql = false) @Import(TestcontainersConfiguration.class) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @ActiveProfiles("test") diff --git a/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java b/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java index 7b11682..322b204 100644 --- a/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java +++ b/src/test/java/com/lmoraesdev/payment/testdata/ChargeTestData.java @@ -4,6 +4,7 @@ import com.lmoraesdev.payment.domain.model.ChargeStatus; import com.lmoraesdev.payment.domain.model.Money; import java.math.BigDecimal; +import java.time.Duration; import java.time.Instant; import java.util.UUID; @@ -33,6 +34,8 @@ public ChargeTestData withStatus(ChargeStatus s) { } public Charge build() { - return Charge.restore(UUID.randomUUID(), amount, status, Instant.now()); + Instant now = Instant.now(); + return Charge.restore( + UUID.randomUUID(), amount, status, now, now.plus(Duration.ofMinutes(30))); } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 15196f1..2fdd816 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -1,11 +1,13 @@ spring: + main: + banner-mode: off mvc: problemdetails: enabled: true jpa: hibernate: - ddl-auto: create-drop - show-sql: true + ddl-auto: validate + show-sql: false properties: hibernate: format_sql: true @@ -22,3 +24,16 @@ management: tracing: export: enabled: false + +logging: + level: + root: WARN + com.lmoraesdev.payment: INFO + org.hibernate.SQL: WARN + org.hibernate.orm.jdbc.bind: WARN + org.hibernate.orm.jdbc.extract: WARN + org.hibernate: WARN + org.testcontainers: WARN + com.zaxxer.hikari: WARN + org.springframework: WARN + org.flywaydb: INFO diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..01e8ab6 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,6 @@ + + + + + +