From 082cf53ad928ef82f1a5ff7321838d1700a10a4e Mon Sep 17 00:00:00 2001 From: dancodingbr Date: Mon, 27 Apr 2026 09:59:14 -0300 Subject: [PATCH 1/4] fix(notification): disable mail health contributor in DlqIntegrationTest @MockitoBean replaces JavaMailSenderImpl with a mock; MailHealthContributorAutoConfiguration requires a concrete implementation and fails with 'beans must not be empty' on CI. Disabling the mail health check in this test context fixes the context startup failure. --- .../com/ecommerce/notification_service/DlqIntegrationTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java index afc7c2c..1166dd9 100644 --- a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java +++ b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java @@ -56,6 +56,9 @@ static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.mail.host", () -> "127.0.0.1"); registry.add("spring.mail.port", () -> "25"); + // @MockitoBean replaces JavaMailSenderImpl with a mock; the mail health + // contributor requires a concrete implementation, so disable it here. + registry.add("management.health.mail.enabled", () -> "false"); // Fast retries so the test doesn't wait ~19 s (production: 3 attempts × 10 s) registry.add("spring.rabbitmq.listener.simple.retry.max-attempts", () -> "2"); From 802a0f0e7570e8fae1cb8203babd500b5600d68c Mon Sep 17 00:00:00 2001 From: dancodingbr Date: Mon, 27 Apr 2026 10:38:41 -0300 Subject: [PATCH 2/4] fix(notification): assert DLQ via DeadLetterConsumer spy instead of polling queue DeadLetterConsumer actively acks messages from the DLQ so rabbitTemplate.receive() always returns null. Use @MockitoSpyBean to verify the consumer was called instead. --- .../DlqIntegrationTest.java | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java index 1166dd9..c93a456 100644 --- a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java +++ b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java @@ -1,29 +1,30 @@ package com.ecommerce.notification_service; import com.ecommerce.notification_service.config.RabbitMQConfig; +import com.ecommerce.notification_service.consumer.DeadLetterConsumer; import com.ecommerce.notification_service.event.OrderPlacedEvent; import jakarta.mail.Session; import jakarta.mail.internet.MimeMessage; import org.junit.jupiter.api.Test; -import org.springframework.amqp.core.Message; -import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.testcontainers.containers.RabbitMQContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import java.util.concurrent.TimeUnit; -import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -32,9 +33,14 @@ * Infrastructure: * - RabbitMQ → Testcontainers (real broker; DLX/DLQ declared by Spring context) * - JavaMailSender → @MockitoBean (throws MailSendException on every send) + * - DeadLetterConsumer → @MockitoSpyBean (spy to verify it received the dead-lettered message) * * Retry intervals are overridden via @DynamicPropertySource to keep the test fast * (2 attempts × ~100 ms intervals instead of the production 3 × 10 s). + * + * Note: DeadLetterConsumer actively consumes and acks messages from the DLQ, so + * rabbitTemplate.receive() would always return null. The spy verifies the consumer + * was invoked instead. */ @SpringBootTest @Testcontainers @@ -47,6 +53,9 @@ class DlqIntegrationTest { @MockitoBean JavaMailSender javaMailSender; + @MockitoSpyBean + DeadLetterConsumer deadLetterConsumer; + @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.rabbitmq.host", rabbitMQContainer::getHost); @@ -86,16 +95,13 @@ void publishOrderEvent_emailAlwaysFails_messageDeadLetters() { RabbitMQConfig.ROUTING_KEY, event); - // Assert – wait up to 15 s for the dead-lettered message to appear in the DLQ. - // rabbitTemplate.receive() does a non-blocking peek with a 1-second timeout. + // Assert – wait up to 15 s for DeadLetterConsumer to receive the dead-lettered + // message. The consumer acks it immediately, so polling the queue via + // rabbitTemplate.receive() would always return null — verify the spy instead. await() .atMost(15, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .untilAsserted(() -> { - Message deadLettered = rabbitTemplate.receive(RabbitMQConfig.DLQ, 1_000); - assertThat(deadLettered) - .as("Expected a dead-lettered message in %s", RabbitMQConfig.DLQ) - .isNotNull(); - }); + .untilAsserted(() -> + verify(deadLetterConsumer).consumeDeadLetter(any(OrderPlacedEvent.class))); } } From 29e9d7d8f602112c1573b1265b93675f84a64835 Mon Sep 17 00:00:00 2001 From: dancodingbr Date: Mon, 27 Apr 2026 11:21:50 -0300 Subject: [PATCH 3/4] fix(notification): stop DLQ listener by ID before polling queue in integration test @MockitoSpyBean cannot intercept @RabbitListener calls because Spring AMQP captures the original bean reference before the spy wrapper is applied. Assign a stable ID to the DLQ listener and stop it via the registry before the test so messages remain in the queue for direct polling. --- .../consumer/DeadLetterConsumer.java | 2 +- .../DlqIntegrationTest.java | 48 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/notification-service/src/main/java/com/ecommerce/notification_service/consumer/DeadLetterConsumer.java b/notification-service/src/main/java/com/ecommerce/notification_service/consumer/DeadLetterConsumer.java index c900f32..0053646 100644 --- a/notification-service/src/main/java/com/ecommerce/notification_service/consumer/DeadLetterConsumer.java +++ b/notification-service/src/main/java/com/ecommerce/notification_service/consumer/DeadLetterConsumer.java @@ -14,7 +14,7 @@ public class DeadLetterConsumer { * Consumes messages rejected by the main listener after all retry attempts. * Logs a structured ERROR for Loki/Grafana alerting and acks the message. */ - @RabbitListener(queues = RabbitMQConfig.DLQ) + @RabbitListener(id = "dlqListener", queues = RabbitMQConfig.DLQ) public void consumeDeadLetter(OrderPlacedEvent event) { log.error("DEAD_LETTER_RECEIVED: orderReference={}, email={}", event.orderReference(), event.email()); diff --git a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java index c93a456..57fa9f4 100644 --- a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java +++ b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java @@ -1,30 +1,31 @@ package com.ecommerce.notification_service; import com.ecommerce.notification_service.config.RabbitMQConfig; -import com.ecommerce.notification_service.consumer.DeadLetterConsumer; import com.ecommerce.notification_service.event.OrderPlacedEvent; import jakarta.mail.Session; import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistry; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.testcontainers.containers.RabbitMQContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -33,14 +34,9 @@ * Infrastructure: * - RabbitMQ → Testcontainers (real broker; DLX/DLQ declared by Spring context) * - JavaMailSender → @MockitoBean (throws MailSendException on every send) - * - DeadLetterConsumer → @MockitoSpyBean (spy to verify it received the dead-lettered message) - * - * Retry intervals are overridden via @DynamicPropertySource to keep the test fast - * (2 attempts × ~100 ms intervals instead of the production 3 × 10 s). * - * Note: DeadLetterConsumer actively consumes and acks messages from the DLQ, so - * rabbitTemplate.receive() would always return null. The spy verifies the consumer - * was invoked instead. + * The DeadLetterConsumer listener is stopped before the test so messages remain + * in the DLQ and can be polled directly via rabbitTemplate.receive(). */ @SpringBootTest @Testcontainers @@ -53,9 +49,6 @@ class DlqIntegrationTest { @MockitoBean JavaMailSender javaMailSender; - @MockitoSpyBean - DeadLetterConsumer deadLetterConsumer; - @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.rabbitmq.host", rabbitMQContainer::getHost); @@ -79,6 +72,18 @@ static void configureProperties(DynamicPropertyRegistry registry) { @Autowired private RabbitTemplate rabbitTemplate; + @Autowired + private RabbitListenerEndpointRegistry listenerRegistry; + + @BeforeEach + void stopDlqConsumer() { + // Stop the DeadLetterConsumer listener so messages stay in the DLQ + // and can be polled directly. Spring AMQP captures the original bean + // reference before any spy/mock wrapper, making spy-based verification + // unreliable — polling a paused queue is the reliable alternative. + listenerRegistry.getListenerContainer("dlqListener").stop(); + } + @Test void publishOrderEvent_emailAlwaysFails_messageDeadLetters() { // Arrange – simulate SMTP rejection after message construction succeeds @@ -95,13 +100,16 @@ void publishOrderEvent_emailAlwaysFails_messageDeadLetters() { RabbitMQConfig.ROUTING_KEY, event); - // Assert – wait up to 15 s for DeadLetterConsumer to receive the dead-lettered - // message. The consumer acks it immediately, so polling the queue via - // rabbitTemplate.receive() would always return null — verify the spy instead. + // Assert – wait up to 15 s for the dead-lettered message to appear in the DLQ. + // The DLQ consumer is stopped so the message stays until we receive it. await() .atMost(15, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .untilAsserted(() -> - verify(deadLetterConsumer).consumeDeadLetter(any(OrderPlacedEvent.class))); + .untilAsserted(() -> { + Message deadLettered = rabbitTemplate.receive(RabbitMQConfig.DLQ, 1_000); + assertThat(deadLettered) + .as("Expected a dead-lettered message in %s", RabbitMQConfig.DLQ) + .isNotNull(); + }); } } From 92b618e682356102c85ec9b35b06cc453eb7288b Mon Sep 17 00:00:00 2001 From: dancodingbr Date: Mon, 27 Apr 2026 14:56:56 -0300 Subject: [PATCH 4/4] fix(notification): enable retry explicitly in DlqIntegrationTest The test classpath application.yaml replaces the production one, losing retry.enabled=true. Without retry the listener nacks with requeue=true and RabbitMQ redelivers the message forever instead of routing to the DLQ. Adding retry.enabled=true to @DynamicPropertySource activates Spring AMQP's in-memory retry interceptor, which rejects with requeue=false after max-attempts and triggers dead-letter routing. --- .../ecommerce/notification_service/DlqIntegrationTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java index 57fa9f4..32c8cf5 100644 --- a/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java +++ b/notification-service/src/test/java/com/ecommerce/notification_service/DlqIntegrationTest.java @@ -62,7 +62,10 @@ static void configureProperties(DynamicPropertyRegistry registry) { // contributor requires a concrete implementation, so disable it here. registry.add("management.health.mail.enabled", () -> "false"); - // Fast retries so the test doesn't wait ~19 s (production: 3 attempts × 10 s) + // Retry is disabled by default in the test classpath application.yaml (which replaces + // the production yaml). Explicitly enable it and use fast intervals so the test + // exhausts retries quickly instead of waiting ~19 s (production: 3 attempts × 10 s). + registry.add("spring.rabbitmq.listener.simple.retry.enabled", () -> "true"); registry.add("spring.rabbitmq.listener.simple.retry.max-attempts", () -> "2"); registry.add("spring.rabbitmq.listener.simple.retry.initial-interval", () -> "100ms"); registry.add("spring.rabbitmq.listener.simple.retry.max-interval", () -> "200ms");