From c1173346efb98ca87009857c7f7b9229993a8f15 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Tue, 4 Aug 2026 18:19:35 +0200 Subject: [PATCH] CB-521: make the AMQP contract test runnable locally and in CI --- .gitea/workflows/ci.yml | 52 +++++++++++++++++++ bridged/pom.xml | 20 +++++++ .../msg/AmqpReplyInboxContractTest.java | 34 ++++++++++-- docs/CB-307-Reliable-Delivery.md | 37 +++++++++++++ 4 files changed, 140 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 89abe17..b4c625d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -53,3 +53,55 @@ jobs: grep -qE "Failures: [1-9]|Errors: [1-9]" "$f" && { echo "===== $f ====="; cat "$f"; } done exit 0 + + # CB-521 — actually run the AMQP contract test in CI, against a REAL broker. The broker is a + # RabbitMQ SERVICE CONTAINER, not Testcontainers-with-Docker: the runner image has no Docker, so + # AmqpReplyInboxContractTest reads AMQP_URI (set below to the service's network alias) and binds + # straight to it — no Docker, no skipped tests. This separation (build job hermetic and + # Docker-free; contract job broker-provided) is deliberate — see the default-excludes/contract + # profiles in bridged/pom.xml. `setup-java` provides the JDK only; Maven is installed separately, + # exactly as in the build job above. + contract: + runs-on: ubuntu-latest + services: + rabbitmq: + image: rabbitmq:3.13 # same AMQP 0-9-1 engine the local Testcontainers fixture uses + env: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest + ports: + - 5672:5672 + env: + # Service containers are reachable from the job by their network alias on their internal port. + AMQP_URI: amqp://guest:guest@rabbitmq:5672 + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + cache: maven + + - name: Install Maven + run: | + apt-get update && apt-get install -y --no-install-recommends maven + mvn -version + + # The `contract` profile clears the default-excludes group, so the @Tag("contract") AMQP test + # runs against the RabbitMQ service container (AMQP_URI). Pinned to the one contract test to + # avoid re-running the unit suite already covered by the `build` job. + - name: Contract tests + working-directory: bridged + run: mvn -B -Pcontract test -Dtest=AmqpReplyInboxContractTest + + - name: Failing test output + if: failure() + working-directory: bridged + run: | + for f in target/surefire-reports/*.txt; do + [ -f "$f" ] || continue + grep -qE "Failures: [1-9]|Errors: [1-9]" "$f" && { echo "===== $f ====="; cat "$f"; } + done + exit 0 diff --git a/bridged/pom.xml b/bridged/pom.xml index 6c931e9..ce22107 100644 --- a/bridged/pom.xml +++ b/bridged/pom.xml @@ -236,6 +236,26 @@ contract + + + + maven-surefire-plugin + + + + 1.43 + + + + + diff --git a/bridged/src/test/java/dev/ltms/bridged/msg/AmqpReplyInboxContractTest.java b/bridged/src/test/java/dev/ltms/bridged/msg/AmqpReplyInboxContractTest.java index b18f060..2377f6c 100644 --- a/bridged/src/test/java/dev/ltms/bridged/msg/AmqpReplyInboxContractTest.java +++ b/bridged/src/test/java/dev/ltms/bridged/msg/AmqpReplyInboxContractTest.java @@ -1,5 +1,6 @@ package dev.ltms.bridged.msg; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.testcontainers.containers.RabbitMQContainer; @@ -19,19 +20,46 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * excluded from {@code mvn test}/{@code mvn clean install} (which stay hermetic and need no Docker); * run it with Docker present via {@code mvn test -Pcontract}. * + *

Two broker modes: + *

+ * *

It proves the port contract on genuine infrastructure: eventual visibility of a published reply, * ack removal, msgId dedup, and — the reason Stage 2 exists — cross-restart durability: an unacked * reply survives closing the inbox and is redelivered to a fresh connection. */ @Tag("contract") -@Testcontainers +// disabledWithoutDocker=false on purpose: on the CI path (AMQP_URI set) no container is started and +// the class must still RUN against the external broker even though the runner has no Docker — a +// disabled-without-docker check would silently skip the whole contract suite there. +@Testcontainers(disabledWithoutDocker = false) class AmqpReplyInboxContractTest { - @Container - static final RabbitMQContainer BROKER = + // When a broker is provisioned out-of-band (CI service container), AMQP_URI takes us straight to + // it and we never touch Testcontainers. Unset locally → Testcontainers starts the container below. + private static final String EXTERNAL_URI = System.getenv("AMQP_URI"); + + private static final RabbitMQContainer BROKER = new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.13-management")); + // No @Container on BROKER: the JUnit 5 extension would force-start it even when AMQP_URI is set. + // Start it manually only on the local (no-external-broker) path; Ryuk reaps it on JVM exit. + @BeforeAll + static void startBrokerUnlessExternal() { + if (EXTERNAL_URI == null) { + BROKER.start(); + } + } + private static String uri() { + if (EXTERNAL_URI != null) { + return EXTERNAL_URI; + } // guest/guest against the mapped AMQP port. No trailing slash: an empty path is vhost "", // which does not exist — omitting it selects the default vhost "/". return "amqp://guest:guest@" + BROKER.getHost() + ":" + BROKER.getAmqpPort(); diff --git a/docs/CB-307-Reliable-Delivery.md b/docs/CB-307-Reliable-Delivery.md index c4cbce6..ba111ea 100644 --- a/docs/CB-307-Reliable-Delivery.md +++ b/docs/CB-307-Reliable-Delivery.md @@ -162,3 +162,40 @@ Stage-2 AMQP adapter: absent → in-memory, present → AMQP). Commit on your feature branch and reply with: the commit SHA, the surefire total (run/failures/errors), a one-line note on the drain-surface decision (§2.4) you shipped, and confirmation that `.mcp.json`/`wiki/` were untouched. The primary re-gates and integrates. + +## 8. Running the contract tests (CB-521) + +`AmqpReplyInboxContractTest` is the real-broker proof of the `ReplyInbox` port (eventual visibility, ack +removal, msgId dedup, cross-restart redelivery). It is `@Tag("contract")`, so the default +`mvn test` / `mvn clean install` **skip it** — that hermetic, Docker-free default is deliberate and +untouched. Run it explicitly when Docker (or a broker) is available: + +```bash +cd bridged +mvn -Pcontract test -Dtest=AmqpReplyInboxContractTest # local: spins a RabbitMQ Testcontainers fixture +``` + +### Two broker modes + +| Mode | Trigger | Broker | Needs Docker? | +|---|---|---|---| +| Local | `AMQP_URI` unset | Testcontainers starts `rabbitmq:3.13-management` | Yes | +| CI / external | `AMQP_URI` set | the broker at that URI (CI RabbitMQ service container) | **No** — binds straight to the URI, never touches Testcontainers | + +In CI the broker is provided as a RabbitMQ **service container** and `AMQP_URI` points at it, so the +contract job runs the same assertions with no Docker on the runner and no skipped test +(see `.gitea/workflows/ci.yml` → `contract`). The `build` job stays hermetic and Docker-free — keep +that separation. + +### Docker-engine discovery (why the contract profile pins `api.version`) + +Out of the box, Testcontainers 1.20.4's docker-java client defaults to Docker API **1.32** when no +version is requested. Modern engines reject that as too old — on this host's OrbStack (`min API 1.40`) +testcontainers fails with *"Could not find a valid Docker environment … client version 1.32 is too +old"* even though the `docker` CLI works (the CLI negotiates a newer API). + +The `contract` Maven profile sets `api.version=1.43` in surefire, which works on OrbStack and Docker +24+, and is overridable per host: `mvn -Pcontract -Dapi.version=1.54 test …`. It only applies under +`-Pcontract`, so the default build is unaffected. If your engine differs, set `-Dapi.version` to a +version ≥ your engine's minimum API (e.g. `docker version` shows `API version`). +