diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs new file mode 100644 index 000000000..a4753bb0d --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs @@ -0,0 +1,22 @@ +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// A header-identified saga: carries no id member, +/// so Wolverine resolves the saga id from the inbound envelope's saga-id header. Over a +/// gRPC service hop that header is never populated — neither the client nor server propagation +/// interceptor carries saga-id, and Executor.InvokeAsync<T> does not seed it +/// onto the invoked envelope — so this saga reproduces the IndeterminateSagaStateIdException +/// gap (GH-3385). StartOrHandle takes the "maybe-existing" code path, which pulls the id +/// from the envelope on the very first call, so a single RPC is enough to reproduce. +/// +public class CountingSaga : Saga +{ + public string Id { get; set; } = string.Empty; + public int Count { get; set; } + + public StartCountingReply StartOrHandle(StartCountingRequest request) + { + Count++; + return new StartCountingReply { SagaId = Id }; + } +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs new file mode 100644 index 000000000..112d25c38 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs @@ -0,0 +1,19 @@ +using ProtoBuf.Grpc; +using Wolverine.Grpc; + +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// Code-first gRPC service that forwards to the Wolverine bus +/// exactly the way the docs show — Bus.InvokeAsync<TResponse>(request, ct). The saga +/// on the other side is an ordinary Wolverine handler; nothing here is saga-aware. +/// +public class CountingSagaGrpcService : WolverineGrpcServiceBase, ICountingSagaService +{ + public CountingSagaGrpcService(IMessageBus bus) : base(bus) + { + } + + public Task Start(StartCountingRequest request, CallContext context = default) + => Bus.InvokeAsync(request, context.CancellationToken); +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSaga.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSaga.cs new file mode 100644 index 000000000..bf2d7eaf2 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSaga.cs @@ -0,0 +1,30 @@ +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// A message-identified saga: it is started by (id from the +/// message) and continued by (id from the message). No +/// envelope saga-id header is involved, so this is the case that "works just like HTTP" +/// over a gRPC hop. Both handlers return a reply so the forwarding +/// Bus.InvokeAsync<TResponse> has a response to hand back to the RPC caller. +/// +public class ReservationSaga : Saga +{ + public string Id { get; set; } = string.Empty; + public bool Booked { get; set; } + + public ReservationBookedReply Start(StartReservationRequest start) + { + Id = start.ReservationId!; + return new ReservationBookedReply { ReservationId = start.ReservationId }; + } + + public BookReservationReply Handle(BookReservationRequest book) + { + Booked = true; + + // Done — Wolverine deletes the saga state at the end of message handling. + MarkCompleted(); + + return new BookReservationReply { Completed = true }; + } +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaContracts.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaContracts.cs new file mode 100644 index 000000000..3858e9980 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaContracts.cs @@ -0,0 +1,50 @@ +using ProtoBuf; +using ProtoBuf.Grpc; +using System.ServiceModel; + +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// Code-first gRPC contract for a message-identified saga — the id rides on the request +/// DTO ( / ), +/// exactly like the HTTP saga sample (WolverineWebApi.SagaExample). This is the case Jeremy +/// expects to "work just like HTTP", and it does — the gRPC shim forwards to the same +/// InvokeAsync pipeline. Mirrors +/// Wolverine.Http.Tests.building_a_saga_and_publishing_other_messages_from_http_endpoint. +/// +[ServiceContract] +public interface IReservationSagaService +{ + Task Start(StartReservationRequest request, CallContext context = default); + Task Book(BookReservationRequest request, CallContext context = default); +} + +[ProtoContract] +public class StartReservationRequest +{ + // Resolves to the saga id: "Reservation" is the ReservationSaga name minus the "Saga" suffix, + // so "ReservationId" is matched by SagaChain.DetermineSagaIdMember off the message body. + [ProtoMember(1)] + public string? ReservationId { get; set; } +} + +[ProtoContract] +public class ReservationBookedReply +{ + [ProtoMember(1)] + public string? ReservationId { get; set; } +} + +[ProtoContract] +public class BookReservationRequest +{ + [ProtoMember(1)] + public string? Id { get; set; } +} + +[ProtoContract] +public class BookReservationReply +{ + [ProtoMember(1)] + public bool Completed { get; set; } +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaGrpcService.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaGrpcService.cs new file mode 100644 index 000000000..69221ebdd --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/ReservationSagaGrpcService.cs @@ -0,0 +1,23 @@ +using ProtoBuf.Grpc; +using Wolverine.Grpc; + +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// Code-first gRPC service that forwards both saga messages to the Wolverine bus with the +/// canonical Bus.InvokeAsync<TResponse> shim — nothing here is saga-aware. Proves a +/// message-identified saga can be started and continued over gRPC exactly like the HTTP endpoint +/// equivalent. +/// +public class ReservationSagaGrpcService : WolverineGrpcServiceBase, IReservationSagaService +{ + public ReservationSagaGrpcService(IMessageBus bus) : base(bus) + { + } + + public Task Start(StartReservationRequest request, CallContext context = default) + => Bus.InvokeAsync(request, context.CancellationToken); + + public Task Book(BookReservationRequest request, CallContext context = default) + => Bus.InvokeAsync(request, context.CancellationToken); +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs new file mode 100644 index 000000000..3d4fb4edd --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs @@ -0,0 +1,39 @@ +using ProtoBuf; +using ProtoBuf.Grpc; +using System.ServiceModel; + +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// Code-first gRPC contract whose server implementation forwards a saga message to the +/// Wolverine bus. Used to characterize what happens when a header-identified saga +/// (one whose id is resolved from the envelope saga-id header, not the message body) +/// is driven over a gRPC service hop — see for why this message +/// deliberately carries no id member. +/// +[ServiceContract] +public interface ICountingSagaService +{ + Task Start(StartCountingRequest request, CallContext context = default); +} + +/// +/// Saga start message with no id-like member — no Id, SagaId, +/// CountingSagaId, nor [SagaIdentity]. This forces +/// SagaChain.DetermineSagaIdMember to return null, which routes identity resolution onto +/// the envelope saga-id header (PullSagaIdFromEnvelopeFrame) instead of the +/// message body. +/// +[ProtoContract] +public class StartCountingRequest +{ + [ProtoMember(1)] + public string? Label { get; set; } +} + +[ProtoContract] +public class StartCountingReply +{ + [ProtoMember(1)] + public string? SagaId { get; set; } +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs new file mode 100644 index 000000000..ab117f9b2 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs @@ -0,0 +1,73 @@ +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using ProtoBuf.Grpc.Client; +using ProtoBuf.Grpc.Server; +using Xunit; + +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// A standalone in-process host (not the shared ) +/// that wires the saga handlers used by these tests and maps their gRPC services: +/// (header-identified, reproduces the GH-3385 gap) and +/// (message-identified, the HTTP-parity happy path). Uses +/// Wolverine's default in-memory saga persistence, so no database is required. +/// +public class SagaOverGrpcFixture : IAsyncLifetime +{ + private WebApplication? _app; + public GrpcChannel? Channel { get; private set; } + + /// + /// The server host's service provider — used by tests to resolve + /// InMemorySagaPersistor and assert saga persistence directly, the in-memory + /// equivalent of the HTTP saga test's Marten LoadAsync. + /// + public IServiceProvider Services => _app!.Services; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseTestServer(); + + builder.Host.UseWolverine(opts => + { + opts.ApplicationAssembly = typeof(SagaOverGrpcFixture).Assembly; + opts.Discovery.DisableConventionalDiscovery(); + opts.Discovery.IncludeType(typeof(CountingSaga)); + opts.Discovery.IncludeType(typeof(ReservationSaga)); + }); + + builder.Services.AddCodeFirstGrpc(); + builder.Services.AddWolverineGrpc(); + + _app = builder.Build(); + _app.UseRouting(); + _app.MapGrpcService(); + _app.MapGrpcService(); + + await _app.StartAsync(); + + var handler = _app.GetTestServer().CreateHandler(); + Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = handler + }); + } + + public async Task DisposeAsync() + { + Channel?.Dispose(); + if (_app != null) + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + } + + public ICountingSagaService CreateClient() => Channel!.CreateGrpcService(); + + public IReservationSagaService CreateReservationClient() => Channel!.CreateGrpcService(); +} diff --git a/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs b/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs new file mode 100644 index 000000000..ea21a3bb4 --- /dev/null +++ b/src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs @@ -0,0 +1,72 @@ +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Wolverine.Persistence.Sagas; +using Xunit; + +namespace Wolverine.Grpc.Tests.SagaOverGrpc; + +/// +/// Tests for driving a Wolverine saga over a gRPC service hop, covering both identity models. +/// +/// Message-identified sagas (id on the request DTO) work over gRPC just like they do +/// over HTTP — the gRPC shim forwards to the same InvokeAsync pipeline that a +/// [WolverinePost] endpoint uses. +/// is the gRPC parallel of +/// Wolverine.Http.Tests.building_a_saga_and_publishing_other_messages_from_http_endpoint. +/// +/// +/// Header-identified sagas (id from the envelope saga-id header) can't resolve +/// their id over gRPC, because nothing carries saga-id across the hop — the same +/// limitation HTTP endpoints have. That gap (GH-3385) is pinned by +/// ; +/// when the scoped diagnostic lands, flip its assertions to the actionable status/message. +/// +/// +public class saga_over_grpc_tests : IClassFixture +{ + private readonly SagaOverGrpcFixture _fixture; + + public saga_over_grpc_tests(SagaOverGrpcFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task can_start_and_continue_a_message_identified_saga_over_grpc() + { + var client = _fixture.CreateReservationClient(); + var persistor = _fixture.Services.GetRequiredService(); + + // Start the saga over gRPC — same InvokeAsync path a WolverinePost endpoint would take. + var booked = await client.Start(new StartReservationRequest { ReservationId = "dinner" }); + booked.ReservationId.ShouldBe("dinner"); + + // The saga was persisted by its message-supplied id, no saga-id header required. + var saved = persistor.Load("dinner"); + saved.ShouldNotBeNull(); + saved.Booked.ShouldBeFalse(); + + // Continue the saga over gRPC — id comes off the follow-up message. + var result = await client.Book(new BookReservationRequest { Id = "dinner" }); + result.Completed.ShouldBeTrue(); + + // Handle(BookReservationRequest) marked the saga completed, so its state is deleted. + persistor.Load("dinner").ShouldBeNull(); + } + + [Fact] + public async Task starting_a_header_identified_saga_over_grpc_fails_with_opaque_status_today() + { + var client = _fixture.CreateClient(); + + var ex = await Should.ThrowAsync(async () => + await client.Start(new StartCountingRequest { Label = "first" })); + + // CHARACTERIZATION of current behavior (GH-3385). saga-id is not propagated across a gRPC + // hop, so PullSagaIdFromEnvelopeFrame throws IndeterminateSagaStateIdException, which maps to + // Internal with a message that doesn't tell the developer what to do about it. + ex.StatusCode.ShouldBe(StatusCode.Internal); + ex.Status.Detail.ShouldContain("saga state id"); + } +}