-
Notifications
You must be signed in to change notification settings - Fork 1
feat(testing): InMemoryAmqpBroker — run a contract without Docker
#677
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
btravers
wants to merge
1
commit into
main
Choose a base branch
from
feat/in-memory-broker
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| --- | ||
| "@amqp-contract/testing": minor | ||
| "@amqp-contract/client": minor | ||
| "@amqp-contract/worker": minor | ||
| "@amqp-contract/core": minor | ||
| --- | ||
|
|
||
| `InMemoryAmqpBroker`: run a contract end to end with no Docker. | ||
|
|
||
| ```ts | ||
| import { InMemoryAmqpBroker } from "@amqp-contract/testing"; | ||
|
|
||
| const broker = new InMemoryAmqpBroker(); | ||
| const worker = await TypedAmqpWorker.create({ | ||
| contract, | ||
| handlers, | ||
| transport: broker.createTransport(contract), | ||
| }).getOrThrow(); | ||
| const client = await TypedAmqpClient.create({ | ||
| contract, | ||
| transport: broker.createTransport(contract), | ||
| }).getOrThrow(); | ||
| ``` | ||
|
|
||
| Testing a contract and its handlers meant a container, and a container is a | ||
| 30-second tax on a question the broker was never going to answer differently. | ||
| What runs for real here is everything above the wire: routing, both validation | ||
| passes, middleware and interceptors, RPC correlation over direct reply-to, | ||
| retry routing, TTL dead-lettering. | ||
|
|
||
| **The seam is `AmqpTransport`**, new in `@amqp-contract/core`: the eight | ||
| members `TypedAmqpClient` and `TypedAmqpWorker` actually use, out of | ||
| `AmqpClient`'s full surface. A compile-time assertion keeps `AmqpClient` | ||
| satisfying it, so a signature change there is a type error rather than a | ||
| substitute that silently stops matching. Both facades now take | ||
| `transport?: AmqpTransport` beside `urls`, and **exactly one is required** — | ||
| passing both is refused rather than silently preferring one, because a test | ||
| that supplies a transport and inherits a `urls` default would otherwise reach | ||
| a real broker while believing it had not. | ||
|
|
||
| `urls` becomes optional on both option types. Existing code is unaffected. | ||
|
|
||
| The fake is deliberately not kinder than a broker: an unroutable publish is | ||
| dropped and confirmed, and a dead-letter exchange with nothing bound loses the | ||
| message. Topology refusals, reconnection, flow control and persistence are not | ||
| modelled and stay the integration suite's job — the boundary is written down | ||
| in the new `how-to/test-without-a-broker` page. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| --- | ||
| title: Test without a broker - amqp-contract | ||
| description: Run the contract pipeline against an in-memory broker — no Docker, in the unit suite. | ||
| --- | ||
|
|
||
| # Test without a broker | ||
|
|
||
| `InMemoryAmqpBroker` runs a contract end to end with no container: publish, | ||
| routing, both validation passes, middleware and interceptors, RPC correlation, | ||
| retry routing and dead-lettering. It is for the tests that are about **your | ||
| contract and your handlers**, where a container is a 30-second tax on a | ||
| question the broker was never going to answer differently. | ||
|
|
||
| It does not replace [testing against RabbitMQ](/how-to/test-with-rabbitmq) — | ||
| see [what it does not model](#what-it-does-not-model) below. | ||
|
|
||
| ## Use it | ||
|
|
||
| ```ts | ||
| import { InMemoryAmqpBroker } from "@amqp-contract/testing"; | ||
| import { TypedAmqpClient } from "@amqp-contract/client"; | ||
| import { TypedAmqpWorker } from "@amqp-contract/worker"; | ||
|
|
||
| const broker = new InMemoryAmqpBroker(); | ||
|
|
||
| const worker = await TypedAmqpWorker.create({ | ||
| contract, | ||
| handlers, | ||
| transport: broker.createTransport(contract), | ||
| }).getOrThrow(); | ||
|
|
||
| const client = await TypedAmqpClient.create({ | ||
| contract, | ||
| transport: broker.createTransport(contract), | ||
| }).getOrThrow(); | ||
|
|
||
| await client.publish("placeOrder", { orderId: "o-1", total: 42 }).getOrThrow(); | ||
| ``` | ||
|
|
||
| `transport` replaces `urls`, and exactly one of the two is required — passing | ||
| both is refused rather than silently preferring one, so a test that supplies a | ||
| transport can never quietly reach a real broker instead. | ||
|
|
||
| A transport per facade, as a real deployment has a connection per facade: that | ||
| is what gives direct reply-to somewhere to route back to. | ||
|
|
||
| ## Look inside the broker | ||
|
|
||
| Two methods exist for assertions a consumer cannot make: | ||
|
|
||
| ```ts | ||
| broker.queueNames(); // every queue the contract declared, wait queues included | ||
| broker.peek("orders-dlq"); // the messages parked on a queue, unconsumed | ||
| ``` | ||
|
|
||
| `peek` is the one worth reaching for: a dead-letter assertion otherwise needs a | ||
| consumer on the DLQ, which changes what you are testing. | ||
|
|
||
| ## What it models | ||
|
|
||
| - **Routing** — topic (`*` one word, `#` zero or more), direct, fanout, headers | ||
| (`x-match` `all`/`any`), and the default exchange, where the routing key is a | ||
| queue name. That last one carries every RPC request and every retry | ||
| republish. | ||
| - **Direct reply-to** — `amq.rabbitmq.reply-to` is rewritten to a per-transport | ||
| pseudo-queue and routed back to the transport that published, which is what | ||
| RabbitMQ does per channel. | ||
| - **Settlement** — `ack` drops the delivery; `nack(requeue: false)` | ||
| dead-letters through the queue's `x-dead-letter-exchange` or drops; | ||
| `nack(requeue: true)` redelivers with `redelivered: true` and an incremented | ||
| `x-delivery-count`, which is the header a quorum queue's retry budget counts. | ||
| - **TTL** — per-message `expiration` and queue-level `x-message-ttl`, whichever | ||
| is shorter, dead-lettering on expiry. That is what makes TTL-backoff retry | ||
| run for real: the republish carries the `expiration`, the wait queue carries | ||
| the ceiling. | ||
| - **Serialization** — a Buffer passes through byte for byte and anything else | ||
| is `JSON.stringify`d, exactly as `AmqpClient` encodes, so a compressed | ||
| payload survives the round trip and the decompression path runs. | ||
| - **Asynchronous delivery**, so nothing is delivered re-entrantly inside the | ||
| publish that caused it. | ||
|
|
||
| ## What it does not model | ||
|
|
||
| An unroutable publish is **dropped and confirmed**, as AMQP does without a | ||
| mandatory flag — the fake is not kinder than the broker. | ||
|
|
||
| Beyond that it is not a RabbitMQ, and these stay the integration suite's job: | ||
|
|
||
| - **Topology refusals.** A real broker answers `406 PRECONDITION_FAILED` when a | ||
| queue is redeclared with different arguments; here declaring is idempotent | ||
| and additive. | ||
| - **Reconnection.** `currentChannelEpoch` is always `0`, so the stale-delivery | ||
| guard never fires. A reconnect is precisely the behaviour this does not have. | ||
| - **Flow control, prefetch limits, persistence and clustering.** `prefetch` is | ||
| accepted and ignored. | ||
| - **Exchange-to-exchange bindings** are declarable but carry no traffic. | ||
|
|
||
| The rule of thumb: if the assertion is about **your** contract, handlers or | ||
| middleware, use this; if it is about what the **broker** does, use | ||
| [a real one](/how-to/test-with-rabbitmq). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import type { ContractDefinition } from "@amqp-contract/contract"; | ||
| import type { AsyncResult } from "unthrown"; | ||
|
|
||
| import { | ||
| AmqpClient, | ||
| type AmqpClientOptions, | ||
| type AmqpConsumeOptions, | ||
| type AmqpPublishOptions, | ||
| type ConsumeCallback, | ||
| } from "./amqp-client.js"; | ||
| import type { ConnectionError } from "./errors.js"; | ||
| import { TechnicalError } from "./errors.js"; | ||
|
|
||
| /** | ||
| * The transport surface `TypedAmqpClient` and `TypedAmqpWorker` actually use. | ||
| * | ||
| * {@link AmqpClient} is the one that speaks to a broker, and this is the | ||
| * subset of it the typed facades depend on — eight members out of its full | ||
| * surface. Naming that subset is what lets a test substitute an in-memory | ||
| * broker for a real one without either facade knowing. | ||
| * | ||
| * It is deliberately **structural and small**. `sendToQueue`, `addSetup`, | ||
| * `on` and `getConnection` are absent because no facade calls them; an | ||
| * implementation is free to have them, and a future facade that reaches for | ||
| * one has to widen this type first, which is the point. | ||
| * | ||
| * The compile-time assertion below is what keeps it honest: `AmqpClient` must | ||
| * satisfy it, so a signature change there is a type error here rather than a | ||
| * substitution that silently stops matching. | ||
| */ | ||
| export type AmqpTransport = { | ||
| /** | ||
| * Resolve once the transport is ready to carry messages. The one member | ||
| * with a modeled error: a broker that cannot be dialled is an anticipated | ||
| * outcome, where every other failure here is infrastructure and rides the | ||
| * defect channel. | ||
| */ | ||
| waitForConnect(): AsyncResult<void, ConnectionError>; | ||
| publish( | ||
| target: { exchange: string; routingKey: string }, | ||
| content: Buffer | unknown, | ||
| options?: AmqpPublishOptions, | ||
| ): AsyncResult<void, never>; | ||
| consume( | ||
| queue: string, | ||
| callback: ConsumeCallback, | ||
| options?: AmqpConsumeOptions, | ||
| ): AsyncResult<string, never>; | ||
| cancel(consumerTag: string): AsyncResult<void, never>; | ||
| ack(msg: ConsumeMessageLike, options?: AckOptions): void; | ||
| nack(msg: ConsumeMessageLike, options?: NackOptions): void; | ||
| close(): AsyncResult<void, never>; | ||
| /** | ||
| * Bumped whenever the underlying channel is re-established. A delivery | ||
| * carries the epoch it arrived on, and `ack`/`nack` drop a delivery whose | ||
| * epoch has moved — the broker has already requeued it, so acknowledging | ||
| * would settle a different message. | ||
| */ | ||
| readonly currentChannelEpoch: number; | ||
| }; | ||
|
|
||
| /** What `ack` / `nack` accept — amqplib's `ConsumeMessage`, structurally. */ | ||
| type ConsumeMessageLike = Parameters<AmqpClient["ack"]>[0]; | ||
| type AckOptions = NonNullable<Parameters<AmqpClient["ack"]>[1]>; | ||
| type NackOptions = NonNullable<Parameters<AmqpClient["nack"]>[1]>; | ||
|
|
||
| // The seam is only worth having if the real client still fits through it. | ||
| // A signature change in `AmqpClient` fails here rather than silently | ||
| // diverging from every substitute. | ||
| type AssertAmqpClientIsATransport = AmqpClient extends AmqpTransport ? true : never; | ||
| const _assertAmqpClientIsATransport: AssertAmqpClientIsATransport = true; | ||
| void _assertAmqpClientIsATransport; | ||
|
|
||
| /** What a facade was handed as its connection source. */ | ||
| export type TransportSource = Omit<AmqpClientOptions, "urls"> & { | ||
| urls?: AmqpClientOptions["urls"] | undefined; | ||
| transport?: AmqpTransport | undefined; | ||
| }; | ||
|
|
||
| /** | ||
| * Pick the transport a facade will run on: the supplied one, or a real | ||
| * {@link AmqpClient} dialled from `urls`. | ||
| * | ||
| * **Exactly one source**, and neither degenerate case is allowed to pass | ||
| * quietly. With neither there is nothing to dial and every later call would | ||
| * fail one at a time; with both, preferring one silently would mean a test | ||
| * that passes a transport AND inherits a `urls` default connects to a real | ||
| * broker while believing it did not — the failure this option exists to | ||
| * prevent, arriving in disguise. | ||
| * | ||
| * @throws TechnicalError when the sources are not exactly one. Both facades | ||
| * call this inside their `OkAsync(...).flatMap(...)` safety net, so it | ||
| * surfaces as a Defect from `create()` rather than as a raw throw. | ||
| */ | ||
| export const resolveTransport = ( | ||
| contract: ContractDefinition, | ||
| { urls, transport, ...options }: TransportSource, | ||
| ): AmqpTransport => { | ||
| if (transport !== undefined && urls !== undefined) { | ||
| // oxlint-disable-next-line unthrown/no-throw -- a synchronous misuse at the create() boundary; both facades adopt it as a Defect through their OkAsync safety net (documented @throws) | ||
| throw new TechnicalError( | ||
| "Both `urls` and `transport` were supplied. Pass exactly one connection source: `urls` to dial a broker, or `transport` to run on a supplied one.", | ||
| ); | ||
| } | ||
| if (transport !== undefined) return transport; | ||
| if (urls === undefined) { | ||
| // oxlint-disable-next-line unthrown/no-throw -- same boundary, same adoption | ||
| throw new TechnicalError( | ||
| "No connection source. Pass `urls` to dial a broker, or `transport` to run on a supplied one.", | ||
| ); | ||
| } | ||
| return new AmqpClient(contract, { ...options, urls }); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Import the facade classes used by the example.
The snippet instantiates
TypedAmqpWorkerandTypedAmqpClienton Lines 14 and 19, but imports neither. Copying it fails before the transport example runs.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents