Skip to main content

An API call publishes an event

POST /api/v1/invoices/{id}/pay, then AwaitAsync("invoice.paid", id-predicate, 15 s).

The situation​

Paying an invoice should publish invoice.paid. A 200 on the pay call only says the payment was accepted. It does not say the application told the rest of the system.

[Test] --POST /pay--> [App] --publish--> [Broker] --await (15 s)--> [Assert]
| | |
+-- 200 says accepted --+----- tap says the system was told ----+

The test pays over the API and then waits for the event with a predicate and a timeout. That wait links the API call to the broker. It passes only when the application publishes the event. The sample suite runs this journey in BrokerJourney.cs.

The test​

The sample suite test pays an invoice it provisioned through Data, then awaits the event:

[Application(NorthstarTargets.Api)]
[NorthstarMember(PlanIds.Growth)]
[RequiresCapability(ProtoCapabilityKinds.Broker)]
public sealed class BrokerJourney
{
[ProtoTest]
[SignedInAs]
public async Task PayingAnInvoicePublishesAnInvoicePaidEvent()
{
var invoice = await Proto.Context.Data().IssueInvoiceAsync();

using var paid = await Proto.Context.Rest()
.Body(new PayInvoiceRequest(PaymentMethods.Visa))
.PostAsync("/api/v1/invoices/{invoiceId}/pay", new { invoiceId = invoice.Id });
paid.Should.HaveHttpStatus(HttpStatusCode.OK);

var message = await Proto.Context.Messaging().AwaitAsync(
"invoice.paid",
candidate => candidate.Payload is not null
&& candidate.Payload.Contains(
$"\"id\":{invoice.Id.ToString(CultureInfo.InvariantCulture)}",
StringComparison.Ordinal),
TimeSpan.FromSeconds(15));

using (Assert.EnterMultipleScope())
{
Assert.That(message.ContentType, Is.EqualTo("application/json"));
Assert.That(message.Payload, Does.Contain($"\"status\":\"{InvoiceStatuses.Paid}\""));
}
}
}

[NorthstarMember] is the sample suite's own composite attribute: it groups an isolated tenant provisioned through Data with the authenticator that carries the member's token through REST. IssueInvoiceAsync is a shortcut over the same data surface.

The predicate matches this test's invoice id. Parallel tests pay invoices too. The timeout is 15 seconds.

:::warning Declare before you await Declare and Tap in setup bind the destination before the test acts. A destination first bound in AwaitAsync misses events published before the call. The setup below shows both lines. :::

Compose​

The run owns a RabbitMQ broker and hands its address to the application and to the tests. The sample suite starts one only when the run selects it:

// Setup.cs: the broker the run owns, behind the configured address.
if (run.OwnsMessagingBroker)
{
builder.AddInfrastructure(
"MessagingBroker",
chain => chain
.UseConfigured()
.UseContainer(RabbitMqBroker.Container()),
RabbitMqOptions.ConnectionStringSetting,
"Messaging:RabbitMq:ConnectionString");
}

Messaging is registered last, after the application, and declares the destination in code:

// Setup.cs: registered last, so the application has declared its exchanges before the tap binds.
builder.AddMessaging(messaging => messaging
.CaptureAttachments()
.UseRabbitMq()
.Declare("invoice.paid")
.Tap("invoice.paid"));
setup: Declare + Tap (bind) ...... test: POST -> publish -> await matches the tap
vs missing Declare: publish ----x (no binding yet)

What the trace shows​

The trace reads as the story in order:

  • the provisioning requests the data fixture made for this test,
  • the REST http.request for the pay call, with assert.http.status and the response body as an http.response observation,
  • the messaging.await operation with the destination invoice.paid and its timeout, and the matched delivery recorded as a messaging.receive observation with the payload section.

On timeout the operation names the destination and the wait. The REST call stays in the trace. Where no broker is configured, the Broker capability is dropped with its reason in the run's capability evidence, and the gated test never starts.

In short, the trace reads in test order:

01 data fixture provisions the invoice for this test
02 http.request POST /pay -> assert.http.status
03 messaging.await invoice.paid (15 s) -> messaging.receive

A timeout fails at 03 and names the destination. The REST call at 02 stays in the trace.

Variations​

WhenChange one line
The run has no real brokerLeave UseRabbitMq() off. The same await works against the in-memory broker. It proves the publication path, not the transport.
The application composes its own MassTransit harnessAwait through UseMassTransit<Program>(). Destinations name message contracts and Declare is a no-op. See MassTransit.
The test should await the product's own queueProtoDestination.Queue("queue:invoice.paid") awaits the queue with its dead-letter bindings. See Queue destinations.
The environment sets the connection stringUseConfigured() comes first, so the run skips the container and talks to that broker.

What it does not prove​

  • The await proves arrival, not delivery guarantees. One matching message on this test's tap says nothing about duplicates, ordering or broker durability.
  • Match on something this test owns. Parallel tests pay invoices too; a predicate on the invoice id awaits this test's event, not the first invoice.paid that happens to arrive.
  • Declare before you await, and make sure the destination exists. A destination first bound in AwaitAsync misses events published before the call. The adapter creates nothing on its own. Declare suite-owned destinations with Declare. See Suite-owned topology.
  • Where there is no broker, skip. [RequiresCapability(ProtoCapabilityKinds.Broker)] skips the test in an environment without one instead of passing against the in-memory double. See Skip conditions.