diff --git a/payment-orchestrator/payment-orchestrator/build.gradle.kts b/payment-orchestrator/payment-orchestrator/build.gradle.kts index dbab8b43..3da205f3 100644 --- a/payment-orchestrator/payment-orchestrator/build.gradle.kts +++ b/payment-orchestrator/payment-orchestrator/build.gradle.kts @@ -127,6 +127,8 @@ dependencies { // Test testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.kafka:spring-kafka-test") testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/CancelPaymentRequest.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/CancelPaymentRequest.java new file mode 100644 index 00000000..2ded362b --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/CancelPaymentRequest.java @@ -0,0 +1,11 @@ +package com.stablecoin.payments.orchestrator.application.controller; + +import jakarta.validation.constraints.NotBlank; + +/** + * Request DTO for cancelling a payment. + */ +public record CancelPaymentRequest( + @NotBlank(message = "reason is required") + String reason +) {} diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java index 1adda119..f5cb1ef2 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java @@ -1,22 +1,32 @@ package com.stablecoin.payments.orchestrator.application.controller; import com.stablecoin.payments.orchestrator.api.ApiError; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.validation.FieldError; import org.springframework.validation.ObjectError; +import org.springframework.validation.method.ParameterValidationResult; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.INTERNAL_ERROR; +import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_CANCELLABLE; +import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_FOUND; import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.VALIDATION_ERROR; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; @Slf4j @@ -35,6 +45,31 @@ public ApiError handleValidation(MethodArgumentNotValidException ex) { "Invalid request content", errors); } + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(HandlerMethodValidationException.class) + public ApiError handleMethodValidation(HandlerMethodValidationException ex) { + Map> errors = ex.getParameterValidationResults().stream() + .flatMap(result -> result.getResolvableErrors().stream() + .map(error -> Map.entry( + resolveParameterName(result, error), + error.getDefaultMessage()))) + .collect(Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()))); + log.info("Method validation failed: {}", errors); + return ApiError.withErrors(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), + "Invalid request content", errors); + } + + private static String resolveParameterName(ParameterValidationResult result, + org.springframework.context.MessageSourceResolvable error) { + if (error instanceof FieldError fieldError) { + return fieldError.getField(); + } + var paramName = result.getMethodParameter().getParameterName(); + return paramName != null ? paramName : "unknown"; + } + @ResponseStatus(BAD_REQUEST) @ExceptionHandler(ConstraintViolationException.class) public ApiError handleConstraintViolation(ConstraintViolationException ex) { @@ -54,6 +89,20 @@ public ApiError handleIllegalArgument(IllegalArgumentException ex) { return ApiError.of(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), ex.getMessage()); } + @ResponseStatus(NOT_FOUND) + @ExceptionHandler(PaymentNotFoundException.class) + public ApiError handlePaymentNotFound(PaymentNotFoundException ex) { + log.info("Payment not found: {}", ex.getMessage()); + return ApiError.of(PAYMENT_NOT_FOUND, NOT_FOUND.getReasonPhrase(), ex.getMessage()); + } + + @ResponseStatus(CONFLICT) + @ExceptionHandler(PaymentNotCancellableException.class) + public ApiError handlePaymentNotCancellable(PaymentNotCancellableException ex) { + log.info("Payment not cancellable: {}", ex.getMessage()); + return ApiError.of(PAYMENT_NOT_CANCELLABLE, CONFLICT.getReasonPhrase(), ex.getMessage()); + } + @ResponseStatus(UNPROCESSABLE_ENTITY) @ExceptionHandler(IllegalStateException.class) public ApiError handleInvalidState(IllegalStateException ex) { diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/InitiatePaymentRequest.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/InitiatePaymentRequest.java new file mode 100644 index 00000000..4d66f923 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/InitiatePaymentRequest.java @@ -0,0 +1,35 @@ +package com.stablecoin.payments.orchestrator.application.controller; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; +import java.util.UUID; + +/** + * Request DTO for initiating a cross-border payment. + */ +public record InitiatePaymentRequest( + @NotNull(message = "senderId is required") + UUID senderId, + + @NotNull(message = "recipientId is required") + UUID recipientId, + + @NotNull(message = "sourceAmount is required") + @DecimalMin(value = "0.01", message = "sourceAmount must be positive") + BigDecimal sourceAmount, + + @NotBlank(message = "sourceCurrency is required") + String sourceCurrency, + + @NotBlank(message = "targetCurrency is required") + String targetCurrency, + + @NotBlank(message = "sourceCountry is required") + String sourceCountry, + + @NotBlank(message = "targetCountry is required") + String targetCountry +) {} diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentController.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentController.java new file mode 100644 index 00000000..3dcbcb53 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentController.java @@ -0,0 +1,84 @@ +package com.stablecoin.payments.orchestrator.application.controller; + +import com.stablecoin.payments.orchestrator.domain.service.PaymentCommandHandler; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +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; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * REST controller for payment lifecycle management. + *

+ * Thin HTTP handler that delegates all business logic to {@link PaymentCommandHandler}. + */ +@Slf4j +@RestController +@RequestMapping("/v1/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentCommandHandler commandHandler; + + /** + * Initiates a new cross-border payment. + *

+ * Idempotent: returns 200 with existing payment if the same Idempotency-Key is reused. + * Returns 201 on first creation. + */ + @PostMapping + public ResponseEntity initiatePayment( + @NotBlank(message = "Idempotency-Key header is required") + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @RequestBody InitiatePaymentRequest request) { + log.info("POST /v1/payments idempotencyKey={}, senderId={}", idempotencyKey, request.senderId()); + + var result = commandHandler.initiatePayment( + idempotencyKey, + UUID.randomUUID(), + request.senderId(), + request.recipientId(), + request.sourceAmount(), + request.sourceCurrency(), + request.targetCurrency(), + request.sourceCountry(), + request.targetCountry() + ); + + var response = PaymentResponse.from(result.payment()); + var status = result.replay() ? HttpStatus.OK : HttpStatus.CREATED; + + return ResponseEntity.status(status).body(response); + } + + /** + * Retrieves a payment by its ID. + */ + @GetMapping("/{paymentId}") + public PaymentResponse getPayment(@PathVariable UUID paymentId) { + log.info("GET /v1/payments/{}", paymentId); + return PaymentResponse.from(commandHandler.getPayment(paymentId)); + } + + /** + * Cancels a payment by sending a cancel signal to the workflow. + * Returns 200 if accepted, 409 if the payment is in a terminal state. + */ + @PostMapping("/{paymentId}/cancel") + public PaymentResponse cancelPayment( + @PathVariable UUID paymentId, + @Valid @RequestBody CancelPaymentRequest request) { + log.info("POST /v1/payments/{}/cancel reason={}", paymentId, request.reason()); + return PaymentResponse.from(commandHandler.cancelPayment(paymentId, request.reason())); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentResponse.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentResponse.java new file mode 100644 index 00000000..0e7cea8d --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/PaymentResponse.java @@ -0,0 +1,54 @@ +package com.stablecoin.payments.orchestrator.application.controller; + +import com.stablecoin.payments.orchestrator.domain.model.Payment; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +/** + * Response DTO representing a payment in the API layer. + */ +public record PaymentResponse( + UUID paymentId, + String idempotencyKey, + UUID correlationId, + String state, + UUID senderId, + UUID recipientId, + BigDecimal sourceAmount, + String sourceCurrency, + String targetCurrency, + BigDecimal targetAmount, + String sourceCountry, + String targetCountry, + String failureReason, + Instant createdAt, + Instant updatedAt, + Instant expiresAt +) { + + /** + * Maps a domain {@link Payment} aggregate to the API response DTO. + */ + public static PaymentResponse from(Payment payment) { + return new PaymentResponse( + payment.paymentId(), + payment.idempotencyKey(), + payment.correlationId(), + payment.state().name(), + payment.senderId(), + payment.recipientId(), + payment.sourceAmount().amount(), + payment.sourceCurrency(), + payment.targetCurrency(), + payment.targetAmount() != null ? payment.targetAmount().amount() : null, + payment.corridor().sourceCountry(), + payment.corridor().targetCountry(), + payment.failureReason(), + payment.createdAt(), + payment.updatedAt(), + payment.expiresAt() + ); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/PaymentNotCancellableException.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/PaymentNotCancellableException.java new file mode 100644 index 00000000..5209e2a4 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/PaymentNotCancellableException.java @@ -0,0 +1,14 @@ +package com.stablecoin.payments.orchestrator.domain.model; + +import java.util.UUID; + +/** + * Thrown when attempting to cancel a payment that is in a terminal state + * and cannot be cancelled. + */ +public class PaymentNotCancellableException extends RuntimeException { + + public PaymentNotCancellableException(UUID paymentId, PaymentState state) { + super("Payment %s cannot be cancelled in state %s".formatted(paymentId, state)); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/PaymentNotFoundException.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/PaymentNotFoundException.java new file mode 100644 index 00000000..f53cc144 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/model/PaymentNotFoundException.java @@ -0,0 +1,13 @@ +package com.stablecoin.payments.orchestrator.domain.model; + +import java.util.UUID; + +/** + * Thrown when a payment cannot be found by its ID. + */ +public class PaymentNotFoundException extends RuntimeException { + + public PaymentNotFoundException(UUID paymentId) { + super("Payment not found: " + paymentId); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java new file mode 100644 index 00000000..38107c57 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java @@ -0,0 +1,164 @@ +package com.stablecoin.payments.orchestrator.domain.service; + +import com.stablecoin.payments.orchestrator.domain.model.Corridor; +import com.stablecoin.payments.orchestrator.domain.model.Money; +import com.stablecoin.payments.orchestrator.domain.model.Payment; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException; +import com.stablecoin.payments.orchestrator.domain.port.PaymentRepository; +import com.stablecoin.payments.orchestrator.domain.workflow.PaymentWorkflow; +import com.stablecoin.payments.orchestrator.domain.workflow.dto.CancelRequest; +import com.stablecoin.payments.orchestrator.domain.workflow.dto.PaymentRequest; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import static com.stablecoin.payments.orchestrator.application.config.TemporalConfig.TASK_QUEUE; + +/** + * Domain command handler for payment lifecycle operations. + *

+ * Orchestrates payment creation, retrieval, and cancellation. + * Creates the Payment aggregate, persists it, and starts the Temporal workflow. + */ +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PaymentCommandHandler { + + private final PaymentRepository paymentRepository; + private final WorkflowClient workflowClient; + + /** + * Initiates a new payment or returns existing one for idempotent replay. + * + * @param idempotencyKey unique key for idempotent replay + * @param correlationId trace correlation ID + * @param senderId sender merchant ID + * @param recipientId recipient merchant ID + * @param sourceAmount payment amount + * @param sourceCurrency source currency code + * @param targetCurrency target currency code + * @param sourceCountry source country code + * @param targetCountry target country code + * @return the created or existing Payment, and whether it was a replay + */ + public InitiateResult initiatePayment(String idempotencyKey, UUID correlationId, + UUID senderId, UUID recipientId, + BigDecimal sourceAmount, + String sourceCurrency, String targetCurrency, + String sourceCountry, String targetCountry) { + log.info("Initiating payment idempotencyKey={}, correlationId={}", idempotencyKey, correlationId); + + Optional existing = paymentRepository.findByIdempotencyKey(idempotencyKey); + if (existing.isPresent()) { + log.info("Idempotent replay for idempotencyKey={}, paymentId={}", + idempotencyKey, existing.get().paymentId()); + return new InitiateResult(existing.get(), true); + } + + var payment = Payment.initiate( + idempotencyKey, + correlationId, + senderId, + recipientId, + new Money(sourceAmount, sourceCurrency), + sourceCurrency, + targetCurrency, + new Corridor(sourceCountry, targetCountry) + ); + + Payment saved; + try { + saved = paymentRepository.save(payment); + } catch (DataIntegrityViolationException ex) { + log.info("Concurrent duplicate for idempotencyKey={}, returning existing", idempotencyKey); + var concurrent = paymentRepository.findByIdempotencyKey(idempotencyKey) + .orElseThrow(() -> ex); + return new InitiateResult(concurrent, true); + } + + startWorkflow(saved, sourceCountry, targetCountry); + + log.info("Payment initiated paymentId={}, state={}", saved.paymentId(), saved.state()); + return new InitiateResult(saved, false); + } + + /** + * Retrieves a payment by its ID. + * + * @throws PaymentNotFoundException if no payment exists with the given ID + */ + @Transactional(readOnly = true) + public Payment getPayment(UUID paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentNotFoundException(paymentId)); + } + + /** + * Cancels a payment by sending a cancel signal to the Temporal workflow. + * + * @throws PaymentNotFoundException if no payment exists with the given ID + * @throws PaymentNotCancellableException if the payment is in a terminal state + */ + public Payment cancelPayment(UUID paymentId, String reason) { + log.info("Cancelling payment paymentId={}, reason={}", paymentId, reason); + + var payment = paymentRepository.findById(paymentId) + .orElseThrow(() -> new PaymentNotFoundException(paymentId)); + + if (payment.isTerminal()) { + throw new PaymentNotCancellableException(paymentId, payment.state()); + } + + var workflowStub = workflowClient.newWorkflowStub( + PaymentWorkflow.class, + "payment-" + paymentId + ); + workflowStub.cancelPayment(new CancelRequest(paymentId, reason, "API")); + + log.info("Cancel signal sent for payment paymentId={}", paymentId); + return payment; + } + + private void startWorkflow(Payment payment, String sourceCountry, String targetCountry) { + var workflowOptions = WorkflowOptions.newBuilder() + .setWorkflowId("payment-" + payment.paymentId()) + .setTaskQueue(TASK_QUEUE) + .setWorkflowExecutionTimeout(Duration.ofMinutes(30)) + .build(); + + var workflow = workflowClient.newWorkflowStub(PaymentWorkflow.class, workflowOptions); + + var request = new PaymentRequest( + payment.paymentId(), + payment.idempotencyKey(), + payment.correlationId(), + payment.senderId(), + payment.recipientId(), + payment.sourceAmount().amount(), + payment.sourceCurrency(), + payment.targetCurrency(), + sourceCountry, + targetCountry + ); + + WorkflowClient.start(workflow::executePayment, request); + log.info("Temporal workflow started workflowId=payment-{}", payment.paymentId()); + } + + /** + * Result of initiatePayment, indicating whether this was an idempotent replay. + */ + public record InitiateResult(Payment payment, boolean replay) {} +} diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java new file mode 100644 index 00000000..ae1c62df --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java @@ -0,0 +1,244 @@ +package com.stablecoin.payments.orchestrator.application.controller; + +import com.stablecoin.payments.orchestrator.application.security.SecurityConfig; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentState; +import com.stablecoin.payments.orchestrator.domain.service.PaymentCommandHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.IDEMPOTENCY_KEY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.RECIPIENT_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SENDER_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_AMOUNT_VALUE; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_COUNTRY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_COUNTRY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anIdempotentReplayResult; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiateResult; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +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; + +@WebMvcTest(controllers = PaymentController.class) +@Import(SecurityConfig.class) +@TestPropertySource(properties = "app.security.enabled=false") +@DisplayName("PaymentController MVC") +class PaymentControllerMvcTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PaymentCommandHandler commandHandler; + + @Nested + @DisplayName("POST /v1/payments") + class InitiatePayment { + + @Test + @DisplayName("should return 201 Created for new payment") + void shouldReturn201() throws Exception { + // given + var result = anInitiateResult(); + given(commandHandler.initiatePayment( + eq(IDEMPOTENCY_KEY), any(UUID.class), + eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), + eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), + eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + .willReturn(result); + + // when/then + mockMvc.perform(post("/v1/payments") + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", IDEMPOTENCY_KEY) + .content(""" + { + "senderId": "%s", + "recipientId": "%s", + "sourceAmount": %s, + "sourceCurrency": "%s", + "targetCurrency": "%s", + "sourceCountry": "%s", + "targetCountry": "%s" + } + """.formatted(SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.paymentId").exists()) + .andExpect(jsonPath("$.state").value("INITIATED")); + } + + @Test + @DisplayName("should return 200 OK for idempotent replay") + void shouldReturn200ForReplay() throws Exception { + // given + var result = anIdempotentReplayResult(); + given(commandHandler.initiatePayment( + eq(IDEMPOTENCY_KEY), any(UUID.class), + eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), + eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), + eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + .willReturn(result); + + // when/then + mockMvc.perform(post("/v1/payments") + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", IDEMPOTENCY_KEY) + .content(""" + { + "senderId": "%s", + "recipientId": "%s", + "sourceAmount": %s, + "sourceCurrency": "%s", + "targetCurrency": "%s", + "sourceCountry": "%s", + "targetCountry": "%s" + } + """.formatted(SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").exists()) + .andExpect(jsonPath("$.state").value("INITIATED")); + } + + @Test + @DisplayName("should return 400 Bad Request for invalid request body") + void shouldReturn400ForInvalidBody() throws Exception { + // when/then + mockMvc.perform(post("/v1/payments") + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", IDEMPOTENCY_KEY) + .content(""" + { + "sourceAmount": -1 + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("PO-0001")); + } + } + + @Nested + @DisplayName("GET /v1/payments/{paymentId}") + class GetPayment { + + @Test + @DisplayName("should return 200 OK with payment details") + void shouldReturn200() throws Exception { + // given + var payment = anInitiatedPayment(); + given(commandHandler.getPayment(payment.paymentId())) + .willReturn(payment); + + // when/then + mockMvc.perform(get("/v1/payments/{paymentId}", payment.paymentId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").value(payment.paymentId().toString())) + .andExpect(jsonPath("$.state").value("INITIATED")) + .andExpect(jsonPath("$.senderId").value(payment.senderId().toString())); + } + + @Test + @DisplayName("should return 404 Not Found when payment does not exist") + void shouldReturn404WhenNotFound() throws Exception { + // given + var paymentId = UUID.randomUUID(); + given(commandHandler.getPayment(paymentId)) + .willThrow(new PaymentNotFoundException(paymentId)); + + // when/then + mockMvc.perform(get("/v1/payments/{paymentId}", paymentId)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PO-2001")); + } + } + + @Nested + @DisplayName("POST /v1/payments/{paymentId}/cancel") + class CancelPayment { + + @Test + @DisplayName("should return 200 OK when cancel accepted") + void shouldReturn200() throws Exception { + // given + var payment = anInitiatedPayment(); + given(commandHandler.cancelPayment(payment.paymentId(), "Customer requested")) + .willReturn(payment); + + // when/then + mockMvc.perform(post("/v1/payments/{paymentId}/cancel", payment.paymentId()) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", "cancel-key-1") + .content(""" + { + "reason": "Customer requested" + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.paymentId").value(payment.paymentId().toString())) + .andExpect(jsonPath("$.state").value("INITIATED")); + } + + @Test + @DisplayName("should return 409 Conflict for terminal payment") + void shouldReturn409WhenTerminal() throws Exception { + // given + var paymentId = UUID.randomUUID(); + given(commandHandler.cancelPayment(paymentId, "reason")) + .willThrow(new PaymentNotCancellableException(paymentId, PaymentState.COMPLETED)); + + // when/then + mockMvc.perform(post("/v1/payments/{paymentId}/cancel", paymentId) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", "cancel-key-2") + .content(""" + { + "reason": "reason" + } + """)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PO-2002")); + } + + @Test + @DisplayName("should return 404 Not Found when payment does not exist") + void shouldReturn404WhenNotFound() throws Exception { + // given + var paymentId = UUID.randomUUID(); + given(commandHandler.cancelPayment(paymentId, "reason")) + .willThrow(new PaymentNotFoundException(paymentId)); + + // when/then + mockMvc.perform(post("/v1/payments/{paymentId}/cancel", paymentId) + .contentType(MediaType.APPLICATION_JSON) + .header("Idempotency-Key", "cancel-key-3") + .content(""" + { + "reason": "reason" + } + """)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("PO-2001")); + } + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java new file mode 100644 index 00000000..57d8ee53 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java @@ -0,0 +1,187 @@ +package com.stablecoin.payments.orchestrator.application.controller; + +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentState; +import com.stablecoin.payments.orchestrator.domain.service.PaymentCommandHandler; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; +import org.springframework.http.HttpStatus; + +import java.util.UUID; + +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.IDEMPOTENCY_KEY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.RECIPIENT_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SENDER_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_AMOUNT_VALUE; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_COUNTRY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_COUNTRY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anIdempotentReplayResult; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiateResult; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; +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.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentController") +class PaymentControllerTest { + + @Mock + private PaymentCommandHandler commandHandler; + + @InjectMocks + private PaymentController controller; + + @Nested + @DisplayName("POST /v1/payments") + class InitiatePayment { + + @Test + @DisplayName("should return 201 Created for new payment") + void shouldReturn201ForNewPayment() { + // given + var request = new InitiatePaymentRequest( + SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY + ); + var initiateResult = anInitiateResult(); + + given(commandHandler.initiatePayment( + eq(IDEMPOTENCY_KEY), any(UUID.class), + eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), + eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), + eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + .willReturn(initiateResult); + + // when + var response = controller.initiatePayment(IDEMPOTENCY_KEY, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + var expected = PaymentResponse.from(initiateResult.payment()); + assertThat(response.getBody()) + .usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + @DisplayName("should return 200 OK for idempotent replay") + void shouldReturn200ForReplay() { + // given + var request = new InitiatePaymentRequest( + SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY + ); + var replayResult = anIdempotentReplayResult(); + + given(commandHandler.initiatePayment( + eq(IDEMPOTENCY_KEY), any(UUID.class), + eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), + eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), + eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + .willReturn(replayResult); + + // when + var response = controller.initiatePayment(IDEMPOTENCY_KEY, request); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + var expected = PaymentResponse.from(replayResult.payment()); + assertThat(response.getBody()) + .usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("GET /v1/payments/{paymentId}") + class GetPayment { + + @Test + @DisplayName("should return payment response when found") + void shouldReturnPayment() { + // given + var payment = anInitiatedPayment(); + given(commandHandler.getPayment(payment.paymentId())) + .willReturn(payment); + + // when + var result = controller.getPayment(payment.paymentId()); + + // then + var expected = PaymentResponse.from(payment); + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + @DisplayName("should propagate PaymentNotFoundException") + void shouldPropagateNotFound() { + // given + var paymentId = UUID.randomUUID(); + given(commandHandler.getPayment(paymentId)) + .willThrow(new PaymentNotFoundException(paymentId)); + + // when/then + assertThatThrownBy(() -> controller.getPayment(paymentId)) + .isInstanceOf(PaymentNotFoundException.class) + .hasMessageContaining(paymentId.toString()); + } + } + + @Nested + @DisplayName("POST /v1/payments/{paymentId}/cancel") + class CancelPayment { + + @Test + @DisplayName("should return payment response when cancel accepted") + void shouldReturnPaymentOnCancel() { + // given + var payment = anInitiatedPayment(); + var cancelRequest = new CancelPaymentRequest("Customer requested"); + + given(commandHandler.cancelPayment(payment.paymentId(), "Customer requested")) + .willReturn(payment); + + // when + var result = controller.cancelPayment(payment.paymentId(), cancelRequest); + + // then + var expected = PaymentResponse.from(payment); + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(expected); + } + + @Test + @DisplayName("should propagate PaymentNotCancellableException for terminal payment") + void shouldPropagateNotCancellable() { + // given + var paymentId = UUID.randomUUID(); + var cancelRequest = new CancelPaymentRequest("reason"); + + given(commandHandler.cancelPayment(paymentId, "reason")) + .willThrow(new PaymentNotCancellableException(paymentId, PaymentState.COMPLETED)); + + // when/then + assertThatThrownBy(() -> controller.cancelPayment(paymentId, cancelRequest)) + .isInstanceOf(PaymentNotCancellableException.class) + .hasMessageContaining(paymentId.toString()); + } + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java new file mode 100644 index 00000000..cab7a172 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java @@ -0,0 +1,278 @@ +package com.stablecoin.payments.orchestrator.domain.service; + +import com.stablecoin.payments.orchestrator.domain.model.Corridor; +import com.stablecoin.payments.orchestrator.domain.model.Money; +import com.stablecoin.payments.orchestrator.domain.model.Payment; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException; +import com.stablecoin.payments.orchestrator.domain.model.PaymentState; +import com.stablecoin.payments.orchestrator.domain.port.PaymentRepository; +import com.stablecoin.payments.orchestrator.domain.workflow.PaymentWorkflow; +import com.stablecoin.payments.orchestrator.domain.workflow.dto.CancelRequest; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; +import java.util.UUID; + +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.CORRELATION_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.IDEMPOTENCY_KEY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.RECIPIENT_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SENDER_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_AMOUNT_VALUE; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_COUNTRY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_COUNTRY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aCompletedPayment; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aFailedPayment; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; +import static com.stablecoin.payments.orchestrator.fixtures.TestUtils.eqIgnoring; +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.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentCommandHandler") +class PaymentCommandHandlerTest { + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private WorkflowClient workflowClient; + + @InjectMocks + private PaymentCommandHandler handler; + + @Nested + @DisplayName("initiatePayment") + class InitiatePayment { + + @Test + @DisplayName("should create payment, save, and start Temporal workflow") + void shouldCreateAndStartWorkflow() { + // given + var expectedPayment = Payment.initiate( + IDEMPOTENCY_KEY, CORRELATION_ID, SENDER_ID, RECIPIENT_ID, + new Money(SOURCE_AMOUNT_VALUE, SOURCE_CURRENCY), + SOURCE_CURRENCY, TARGET_CURRENCY, + new Corridor(SOURCE_COUNTRY, TARGET_COUNTRY) + ); + + given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) + .willReturn(Optional.empty()); + given(paymentRepository.save(any(Payment.class))) + .willAnswer(inv -> inv.getArgument(0)); + + var workflowStub = mock(PaymentWorkflow.class); + given(workflowClient.newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class))) + .willReturn(workflowStub); + + // when + var result = handler.initiatePayment( + IDEMPOTENCY_KEY, CORRELATION_ID, + SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY + ); + + // then + assertThat(result.replay()).isFalse(); + assertThat(result.payment()) + .usingRecursiveComparison() + .ignoringFields("paymentId", "createdAt", "updatedAt", "expiresAt") + .isEqualTo(expectedPayment); + + then(paymentRepository).should().save(eqIgnoring(expectedPayment, "paymentId")); + then(workflowClient).should().newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class)); + } + + @Test + @DisplayName("should return existing payment on idempotent replay") + void shouldReturnExistingOnReplay() { + // given + var existingPayment = anInitiatedPayment(); + given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) + .willReturn(Optional.of(existingPayment)); + + // when + var result = handler.initiatePayment( + IDEMPOTENCY_KEY, CORRELATION_ID, + SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY + ); + + // then + assertThat(result.replay()).isTrue(); + assertThat(result.payment()) + .usingRecursiveComparison() + .isEqualTo(existingPayment); + + then(paymentRepository).should(never()).save(any()); + then(workflowClient).should(never()).newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class)); + } + + @Test + @DisplayName("should handle concurrent duplicate by returning existing payment") + void shouldHandleConcurrentDuplicate() { + // given + var existingPayment = anInitiatedPayment(); + given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) + .willReturn(Optional.empty()); + given(paymentRepository.save(any(Payment.class))) + .willThrow(new DataIntegrityViolationException("duplicate key")); + given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) + .willReturn(Optional.empty()) + .willReturn(Optional.of(existingPayment)); + + // when + var result = handler.initiatePayment( + IDEMPOTENCY_KEY, CORRELATION_ID, + SENDER_ID, RECIPIENT_ID, SOURCE_AMOUNT_VALUE, + SOURCE_CURRENCY, TARGET_CURRENCY, + SOURCE_COUNTRY, TARGET_COUNTRY + ); + + // then + assertThat(result.replay()).isTrue(); + assertThat(result.payment()) + .usingRecursiveComparison() + .isEqualTo(existingPayment); + + then(workflowClient).should(never()).newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class)); + } + } + + @Nested + @DisplayName("getPayment") + class GetPayment { + + @Test + @DisplayName("should return payment when found") + void shouldReturnPayment() { + // given + var payment = anInitiatedPayment(); + given(paymentRepository.findById(payment.paymentId())) + .willReturn(Optional.of(payment)); + + // when + var result = handler.getPayment(payment.paymentId()); + + // then + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(payment); + } + + @Test + @DisplayName("should throw PaymentNotFoundException when not found") + void shouldThrowWhenNotFound() { + // given + var paymentId = UUID.randomUUID(); + given(paymentRepository.findById(paymentId)) + .willReturn(Optional.empty()); + + // when/then + assertThatThrownBy(() -> handler.getPayment(paymentId)) + .isInstanceOf(PaymentNotFoundException.class) + .hasMessageContaining(paymentId.toString()); + } + } + + @Nested + @DisplayName("cancelPayment") + class CancelPayment { + + @Test + @DisplayName("should send cancel signal to Temporal workflow") + void shouldSendCancelSignal() { + // given + var payment = anInitiatedPayment(); + given(paymentRepository.findById(payment.paymentId())) + .willReturn(Optional.of(payment)); + + var workflowStub = mock(PaymentWorkflow.class); + given(workflowClient.newWorkflowStub( + PaymentWorkflow.class, + "payment-" + payment.paymentId())) + .willReturn(workflowStub); + + // when + var result = handler.cancelPayment(payment.paymentId(), "Customer requested"); + + // then + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(payment); + + var cancelCaptor = ArgumentCaptor.forClass(CancelRequest.class); + then(workflowStub).should().cancelPayment(cancelCaptor.capture()); + + var capturedRequest = cancelCaptor.getValue(); + var expectedCancel = new CancelRequest(payment.paymentId(), "Customer requested", "API"); + assertThat(capturedRequest) + .usingRecursiveComparison() + .isEqualTo(expectedCancel); + } + + @Test + @DisplayName("should throw PaymentNotFoundException when not found") + void shouldThrowWhenNotFound() { + // given + var paymentId = UUID.randomUUID(); + given(paymentRepository.findById(paymentId)) + .willReturn(Optional.empty()); + + // when/then + assertThatThrownBy(() -> handler.cancelPayment(paymentId, "reason")) + .isInstanceOf(PaymentNotFoundException.class) + .hasMessageContaining(paymentId.toString()); + } + + @Test + @DisplayName("should throw PaymentNotCancellableException for completed payment") + void shouldThrowWhenCompletedPayment() { + // given + var payment = aCompletedPayment(); + given(paymentRepository.findById(payment.paymentId())) + .willReturn(Optional.of(payment)); + + // when/then + assertThatThrownBy(() -> handler.cancelPayment(payment.paymentId(), "reason")) + .isInstanceOf(PaymentNotCancellableException.class) + .hasMessageContaining(payment.paymentId().toString()) + .hasMessageContaining(PaymentState.COMPLETED.name()); + } + + @Test + @DisplayName("should throw PaymentNotCancellableException for failed payment") + void shouldThrowWhenFailedPayment() { + // given + var payment = aFailedPayment(); + given(paymentRepository.findById(payment.paymentId())) + .willReturn(Optional.of(payment)); + + // when/then + assertThatThrownBy(() -> handler.cancelPayment(payment.paymentId(), "reason")) + .isInstanceOf(PaymentNotCancellableException.class) + .hasMessageContaining(payment.paymentId().toString()) + .hasMessageContaining(PaymentState.FAILED.name()); + } + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/PaymentFixtures.java b/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/PaymentFixtures.java index 1cbe7169..b5524b82 100644 --- a/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/PaymentFixtures.java +++ b/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/PaymentFixtures.java @@ -5,6 +5,7 @@ import com.stablecoin.payments.orchestrator.domain.model.FxRate; import com.stablecoin.payments.orchestrator.domain.model.Money; import com.stablecoin.payments.orchestrator.domain.model.Payment; +import com.stablecoin.payments.orchestrator.domain.service.PaymentCommandHandler; import java.math.BigDecimal; import java.time.Instant; @@ -19,6 +20,7 @@ public final class PaymentFixtures { public static final Money SOURCE_AMOUNT = new Money(new BigDecimal("1000.00"), "USD"); + public static final BigDecimal SOURCE_AMOUNT_VALUE = new BigDecimal("1000.00"); public static final Corridor US_TO_DE = new Corridor("US", "DE"); public static final String IDEMPOTENCY_KEY = "idem-key-123"; public static final UUID CORRELATION_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); @@ -26,11 +28,27 @@ public final class PaymentFixtures { public static final UUID RECIPIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000003"); public static final String SOURCE_CURRENCY = "USD"; public static final String TARGET_CURRENCY = "EUR"; + public static final String SOURCE_COUNTRY = "US"; + public static final String TARGET_COUNTRY = "DE"; public static final ChainId BASE_CHAIN = new ChainId("base"); public static final String TX_HASH = "0xabc123def456"; private PaymentFixtures() {} + /** + * Creates an {@link PaymentCommandHandler.InitiateResult} representing a new payment. + */ + public static PaymentCommandHandler.InitiateResult anInitiateResult() { + return new PaymentCommandHandler.InitiateResult(anInitiatedPayment(), false); + } + + /** + * Creates an {@link PaymentCommandHandler.InitiateResult} representing an idempotent replay. + */ + public static PaymentCommandHandler.InitiateResult anIdempotentReplayResult() { + return new PaymentCommandHandler.InitiateResult(anInitiatedPayment(), true); + } + public static FxRate aValidFxRate() { var now = Instant.now(); return new FxRate(