Ciclo red-green-refactor · POST /api/v1/payments · PaymentService + PaymentController
@ExtendWith(MockitoExtension.class) class PaymentServiceTest { @Mock MerchantRepository merchantRepository; @Mock TransactionRepository transactionRepository; @Mock RedisEventPublisher eventPublisher; @InjectMocks PaymentService service; @Test void processPayment_whenValidRequest_createsTransactionPending() { // Arrange var merchant = new Merchant("mer_123", "CULTIVA Store", MerchantStatus.ACTIVE); var req = new PaymentRequest("mer_123", 4999L, "EUR", "Suscripción CULTIVA IA"); when(merchantRepository.findById("mer_123")).thenReturn(Optional.of(merchant)); when(transactionRepository.save(any())).thenAnswer(inv -> { Transaction t = inv.getArgument(0); t.setId("txn_abc789"); return t; }); // Act PaymentResult result = service.processPayment(req); // Assert assertThat(result.transactionId()).isEqualTo("txn_abc789"); assertThat(result.status()).isEqualTo(TransactionStatus.PENDING); verify(transactionRepository).save(argThat(t -> t.getAmount() == 4999L && t.getStatus() == TransactionStatus.PENDING )); verify(eventPublisher).publish("payment.created", any()); } @Test void processPayment_whenMerchantNotFound_throwsMerchantNotFoundException() { when(merchantRepository.findById(any())).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.processPayment( new PaymentRequest("mer_unknown", 100L, "EUR", "Test") )).isInstanceOf(MerchantNotFoundException.class); } @ParameterizedTest @ValueSource(longs = {0L, -1L, 100_001L}) void processPayment_whenAmountInvalid_throwsInvalidAmountException(long amount) { when(merchantRepository.findById(any())).thenReturn(Optional.of(activeMerchant())); assertThatThrownBy(() -> service.processPayment( new PaymentRequest("mer_123", amount, "EUR", "Test") )).isInstanceOf(InvalidAmountException.class); } }
@Service @Transactional public class PaymentService { private static final long MIN_AMOUNT = 1L; private static final long MAX_AMOUNT = 100_000L; private final MerchantRepository merchantRepository; private final TransactionRepository transactionRepository; private final RedisEventPublisher eventPublisher; public PaymentResult processPayment(PaymentRequest req) { // Guard: montante válido if (req.amount() < MIN_AMOUNT || req.amount() > MAX_AMOUNT) { throw new InvalidAmountException(req.amount()); } // Guard: comerciante activo Merchant merchant = merchantRepository .findById(req.merchantId()) .orElseThrow(() -> new MerchantNotFoundException(req.merchantId())); if (merchant.status() != MerchantStatus.ACTIVE) { throw new InactiveMerchantException(req.merchantId()); } // Persistir transacción PENDING Transaction txn = transactionRepository.save( Transaction.builder() .merchantId(req.merchantId()) .amount(req.amount()) .currency(req.currency()) .description(req.description()) .status(TransactionStatus.PENDING) .createdAt(Instant.now()) .build() ); // Publicar evento Redis eventPublisher.publish("payment.created", new PaymentCreatedEvent(txn)); return new PaymentResult(txn.getId(), TransactionStatus.PENDING); } }
@WebMvcTest(PaymentController.class) class PaymentControllerTest { @Autowired MockMvc mockMvc; @MockBean PaymentService paymentService; @Test void createPayment_whenValidBody_returns201() throws Exception { when(paymentService.processPayment(any())) .thenReturn(new PaymentResult( "txn_abc789", TransactionStatus.PENDING )); mockMvc.perform(post("/api/v1/payments") .contentType(APPLICATION_JSON) .content(""" { "merchantId": "mer_123", "amount": 4999, "currency": "EUR", "description": "Suscripción CULTIVA IA" } """)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.transactionId") .value("txn_abc789")) .andExpect(jsonPath("$.status") .value("PENDING")); } @Test void createPayment_whenMerchantNotFound_404() throws Exception { doThrow(new MerchantNotFoundException("x")) .when(paymentService).processPayment(any()); mockMvc.perform(post("/api/v1/payments") .contentType(APPLICATION_JSON) .content("...")) .andExpect(status().isNotFound()); } }
@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") class PaymentIntegrationTest { @Autowired MockMvc mockMvc; @Autowired TransactionRepository repo; // PostgreSQL + Redis via Testcontainers @Import(TestContainersConfig.class) static class Cfg {} @Test void createPayment_endToEnd_persistsAndPublishes() throws Exception { mockMvc.perform(post("/api/v1/payments") .contentType(APPLICATION_JSON) .content(""" { "merchantId": "mer_123", "amount": 4999, "currency": "EUR", "description": "Plan Pro CULTIVA IA" } """)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.status") .value("PENDING")); // Verificar persistencia real en Postgres assertThat(repo.count()).isEqualTo(1); Transaction saved = repo.findAll().get(0); assertThat(saved.getStatus()) .isEqualTo(TransactionStatus.PENDING); assertThat(saved.getAmount()) .isEqualTo(4999L); } }
<plugin> <groupId>org.jacoco</groupId> <artifactId>jacoco-maven-plugin</artifactId> <version>0.8.14</version> <executions> <execution><goals><goal>prepare-agent</goal></goals></execution> <execution> <id>report</id> <phase>verify</phase> <goals><goal>report</goal></goals> </execution> <execution> <id>check</id> <phase>verify</phase> <goals><goal>check</goal></goals> <configuration> <rules><rule> <limits> <limit> <counter>INSTRUCTION</counter> <value>COVEREDRATIO</value> <minimum>0.80</minimum> <!-- BUILD FAILS si < 80% --> </limit> <limit> <counter>BRANCH</counter> <value>COVEREDRATIO</value> <minimum>0.80</minimum> </limit> </limits> </rule></rules> </configuration> </execution> </executions> </plugin>