QUEUES = List.of(Topology.Q_NEW, Topology.Q_CANCEL,
+ Topology.Q_AUDIT, Topology.Q_ANALYTICS, Topology.Q_EU, Topology.Q_HIGH,
+ Topology.Q_PRIORITY, Topology.Q_ANY);
+
+ @BeforeEach
+ void drain() {
+ QUEUES.forEach((q) -> this.admin.purgeQueue(q, false));
+ }
+
+ /**
+ * Ready message count, straight from the broker.
+ *
+ * Note the {@code Number}: {@code RabbitAdmin.QUEUE_MESSAGE_COUNT} holds a {@code Long}
+ * in Spring AMQP 4.1. Casting it to {@code Integer}, as every older example does, is a
+ * {@code ClassCastException} at runtime.
+ */
+ private int depth(String queue) {
+ Properties properties = this.admin.getQueueProperties(queue);
+ return (properties == null) ? -1
+ : ((Number) properties.get(RabbitAdmin.QUEUE_MESSAGE_COUNT)).intValue();
+ }
+
+ /** Publishing is asynchronous; give the broker a moment to route before counting. */
+ private int settledDepth(String queue) {
+ int last = -1;
+ for (int i = 0; i < 50; i++) {
+ last = depth(queue);
+ if (last > 0) {
+ return last;
+ }
+ try {
+ Thread.sleep(20);
+ }
+ catch (InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ return last;
+ }
+
+ @Test
+ void directExchangeMatchesTheRoutingKeyExactly() {
+ this.template.convertAndSend(Topology.DIRECT, "new", OrderMessage.of("o-1"));
+ this.template.convertAndSend(Topology.DIRECT, "cancel", OrderMessage.of("o-2"));
+ // No binding for "amend". The broker discards it - see UnroutableTest.
+ this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-3"));
+
+ assertThat(settledDepth(Topology.Q_NEW)).isEqualTo(1);
+ assertThat(settledDepth(Topology.Q_CANCEL)).isEqualTo(1);
+ }
+
+ @Test
+ void fanoutIgnoresTheRoutingKeyCompletely() {
+ this.template.convertAndSend(Topology.FANOUT, "this-is-ignored", OrderMessage.of("o-1"));
+
+ assertThat(settledDepth(Topology.Q_AUDIT)).isEqualTo(1);
+ assertThat(settledDepth(Topology.Q_ANALYTICS)).isEqualTo(1);
+ }
+
+ @Test
+ void topicWildcardsAreWordsNotCharacters() {
+ // binding "order.eu.*" - exactly one word after order.eu
+ // binding "order.#.high" - zero or more words between order. and .high
+ Map routingKeys = new LinkedHashMap<>();
+ routingKeys.put("order.eu.high", "matches both");
+ routingKeys.put("order.eu.low", "matches order.eu.* only");
+ routingKeys.put("order.us.high", "matches order.#.high only");
+ routingKeys.put("order.eu.west.high", "matches order.#.high only - * is one word");
+ routingKeys.put("order.high", "matches order.#.high - # can be zero words");
+
+ System.out.println("=== topic exchange ===");
+ System.out.printf("%-24s %-12s %-12s %s%n", "routing key", "orders.eu", "orders.high", "note");
+ for (Map.Entry entry : routingKeys.entrySet()) {
+ this.admin.purgeQueue(Topology.Q_EU, false);
+ this.admin.purgeQueue(Topology.Q_HIGH, false);
+ this.template.convertAndSend(Topology.TOPIC, entry.getKey(), OrderMessage.of("o"));
+ System.out.printf("%-24s %-12s %-12s %s%n", entry.getKey(), settledDepth(Topology.Q_EU) == 1,
+ settledDepth(Topology.Q_HIGH) == 1, entry.getValue());
+ }
+
+ this.admin.purgeQueue(Topology.Q_EU, false);
+ this.admin.purgeQueue(Topology.Q_HIGH, false);
+ // The one everybody gets wrong: '*' is one WORD, so it does not match two.
+ this.template.convertAndSend(Topology.TOPIC, "order.eu.west.high", OrderMessage.of("o"));
+ assertThat(depth(Topology.Q_EU)).isEqualTo(0);
+ assertThat(settledDepth(Topology.Q_HIGH)).isEqualTo(1);
+
+ this.admin.purgeQueue(Topology.Q_HIGH, false);
+ // And '#' really does match zero words.
+ this.template.convertAndSend(Topology.TOPIC, "order.high", OrderMessage.of("o"));
+ assertThat(settledDepth(Topology.Q_HIGH)).isEqualTo(1);
+ }
+
+ @Test
+ void headersExchangeMatchesTheHeaderMapNotTheRoutingKey() {
+ send(Map.of("priority", "high", "region", "eu"));
+ assertThat(settledDepth(Topology.Q_PRIORITY)).isEqualTo(1); // x-match=all: both present
+ assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1); // x-match=any: either is enough
+
+ drain();
+ send(Map.of("priority", "high"));
+ assertThat(depth(Topology.Q_PRIORITY)).isEqualTo(0); // all: region missing
+ assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1);
+
+ drain();
+ send(Map.of("priority", "high", "region", "us"));
+ // x-match=all needs every header to match by VALUE, not merely to be present.
+ assertThat(depth(Topology.Q_PRIORITY)).isEqualTo(0);
+ assertThat(settledDepth(Topology.Q_ANY)).isEqualTo(1);
+ }
+
+ private void send(Map headers) {
+ this.template.convertAndSend(Topology.HEADERS, "routing-key-is-ignored",
+ OrderMessage.of("o-1"), (message) -> {
+ MessageProperties properties = message.getMessageProperties();
+ headers.forEach(properties::setHeader);
+ return message;
+ });
+ }
+
+}
diff --git a/rabbitmq/src/test/java/com/ankurm/rabbit/TestcontainersConfiguration.java b/rabbitmq/src/test/java/com/ankurm/rabbit/TestcontainersConfiguration.java
new file mode 100644
index 0000000..2807d90
--- /dev/null
+++ b/rabbitmq/src/test/java/com/ankurm/rabbit/TestcontainersConfiguration.java
@@ -0,0 +1,33 @@
+package com.ankurm.rabbit;
+
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.context.annotation.Bean;
+import org.testcontainers.rabbitmq.RabbitMQContainer;
+import org.testcontainers.utility.DockerImageName;
+
+/**
+ * The Testcontainers route, for machines that have a Docker daemon.
+ *
+ * {@code @ServiceConnection} supplies host, port, username and password to the
+ * auto-configuration, so no {@code spring.rabbitmq.*} property and no
+ * {@code @DynamicPropertySource} block is needed.
+ *
+ *
The Maven coordinate is {@code org.testcontainers:testcontainers-rabbitmq}.
+ * Testcontainers 2.x prefixed every module artifact; the old {@code org.testcontainers:rabbitmq}
+ * stopped at 1.21.4 and is not managed by the Boot 4.1 BOM.
+ *
+ *
The committed transcripts under {@code docs/output/} came from a broker started by
+ * {@code scripts/broker.sh} instead, on a machine with no Docker — see the module README
+ * for why that matters and what version it was.
+ */
+@TestConfiguration(proxyBeanMethods = false)
+public class TestcontainersConfiguration {
+
+ @Bean
+ @ServiceConnection
+ RabbitMQContainer rabbitContainer() {
+ return new RabbitMQContainer(DockerImageName.parse("rabbitmq:4.1-management"));
+ }
+
+}
diff --git a/rabbitmq/src/test/java/com/ankurm/rabbit/TopologyTrapsTest.java b/rabbitmq/src/test/java/com/ankurm/rabbit/TopologyTrapsTest.java
new file mode 100644
index 0000000..5c37e91
--- /dev/null
+++ b/rabbitmq/src/test/java/com/ankurm/rabbit/TopologyTrapsTest.java
@@ -0,0 +1,100 @@
+package com.ankurm.rabbit;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.amqp.AmqpIOException;
+import org.springframework.amqp.core.Queue;
+import org.springframework.amqp.core.QueueBuilder;
+import org.springframework.amqp.core.ReturnedMessage;
+import org.springframework.amqp.rabbit.core.RabbitAdmin;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Two ways a RabbitMQ topology loses your work quietly.
+ *
+ * @see docs/03-the-silent-drop.md
+ * @see docs/06-changing-your-mind.md
+ */
+@SpringBootTest
+class TopologyTrapsTest {
+
+ @Autowired
+ RabbitTemplate template;
+
+ @Autowired
+ RabbitAdmin admin;
+
+ @Test
+ void anUnroutableMessageIsDiscardedUnlessYouAskForItBack() throws Exception {
+ List returned = new CopyOnWriteArrayList<>();
+ this.template.setReturnsCallback(returned::add);
+
+ // "amend" matches no binding on orders.direct. The broker has nowhere to put it.
+ this.template.convertAndSend(Topology.DIRECT, "amend", OrderMessage.of("o-1"));
+ for (int i = 0; i < 100 && returned.isEmpty(); i++) {
+ Thread.sleep(20);
+ }
+
+ // spring.rabbitmq.template.mandatory=true is what turns a silent discard into a return.
+ assertThat(this.template.isMandatoryFor(null)).isTrue();
+ assertThat(returned).hasSize(1);
+ ReturnedMessage message = returned.get(0);
+ System.out.println("=== returned message ===");
+ System.out.println(" replyCode " + message.getReplyCode());
+ System.out.println(" replyText " + message.getReplyText());
+ System.out.println(" exchange " + message.getExchange());
+ System.out.println(" routingKey " + message.getRoutingKey());
+
+ assertThat(message.getReplyCode()).isEqualTo(312);
+ assertThat(message.getReplyText()).isEqualTo("NO_ROUTE");
+ assertThat(message.getRoutingKey()).isEqualTo("amend");
+ // Note what did NOT happen: convertAndSend returned normally. Publishing is fire and
+ // forget at the protocol level, so even with mandatory=true the failure arrives
+ // asynchronously on another thread. Nothing throws.
+ }
+
+ @Test
+ void redeclaringAQueueWithDifferentArgumentsIsAPreconditionFailure() {
+ // orders.ttl already exists with x-message-ttl=1500. Same name, different argument.
+ Queue conflicting = QueueBuilder.durable(Topology.Q_TTL)
+ .ttl(9999)
+ .deadLetterExchange(Topology.DLX)
+ .deadLetterRoutingKey("failed")
+ .build();
+
+ assertThatExceptionOfType(AmqpIOException.class)
+ .isThrownBy(() -> this.admin.declareQueue(conflicting))
+ .satisfies((ex) -> {
+ String detail = rootMessage(ex);
+ System.out.println("=== redeclaring orders.ttl with x-message-ttl=9999 ===");
+ System.out.println(" " + detail);
+ assertThat(detail).contains("PRECONDITION_FAILED")
+ .contains("inequivalent arg 'x-message-ttl'");
+ });
+
+ // Queue arguments are immutable. There is no ALTER QUEUE. Changing a TTL, a max-length
+ // or a dead-letter exchange on an existing queue means: declare a new queue, move the
+ // consumers, drain the old one, delete it. Plan the rename into the change.
+ }
+
+ private static String rootMessage(Throwable throwable) {
+ // The useful text is on the cause. AmqpIOException's own message is just
+ // "java.io.IOException", which is why this failure is so often reported as "IOException"
+ // with no further detail.
+ Throwable current = throwable;
+ StringBuilder all = new StringBuilder();
+ while (current != null) {
+ all.append(current.getMessage()).append(" | ");
+ current = current.getCause();
+ }
+ return all.toString();
+ }
+
+}