Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSaga.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Wolverine.Grpc.Tests.SagaOverGrpc;

/// <summary>
/// A <em>header-identified</em> saga: <see cref="StartCountingRequest"/> carries no id member,
/// so Wolverine resolves the saga id from the inbound envelope's <c>saga-id</c> header. Over a
/// gRPC service hop that header is never populated — neither the client nor server propagation
/// interceptor carries <c>saga-id</c>, and <c>Executor.InvokeAsync&lt;T&gt;</c> does not seed it
/// onto the invoked envelope — so this saga reproduces the <c>IndeterminateSagaStateIdException</c>
/// gap (GH-3385). <c>StartOrHandle</c> 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.
/// </summary>
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 };
}
}
19 changes: 19 additions & 0 deletions src/Wolverine.Grpc.Tests/SagaOverGrpc/CountingSagaGrpcService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using ProtoBuf.Grpc;
using Wolverine.Grpc;

namespace Wolverine.Grpc.Tests.SagaOverGrpc;

/// <summary>
/// Code-first gRPC service that forwards <see cref="StartCountingRequest"/> to the Wolverine bus
/// exactly the way the docs show — <c>Bus.InvokeAsync&lt;TResponse&gt;(request, ct)</c>. The saga
/// on the other side is an ordinary Wolverine handler; nothing here is saga-aware.
/// </summary>
public class CountingSagaGrpcService : WolverineGrpcServiceBase, ICountingSagaService
{
public CountingSagaGrpcService(IMessageBus bus) : base(bus)
{
}

public Task<StartCountingReply> Start(StartCountingRequest request, CallContext context = default)
=> Bus.InvokeAsync<StartCountingReply>(request, context.CancellationToken);
}
39 changes: 39 additions & 0 deletions src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using ProtoBuf;
using ProtoBuf.Grpc;
using System.ServiceModel;

namespace Wolverine.Grpc.Tests.SagaOverGrpc;

/// <summary>
/// Code-first gRPC contract whose server implementation forwards a saga message to the
/// Wolverine bus. Used to characterize what happens when a <em>header-identified</em> saga
/// (one whose id is resolved from the envelope <c>saga-id</c> header, not the message body)
/// is driven over a gRPC service hop — see <see cref="CountingSaga"/> for why this message
/// deliberately carries no id member.
/// </summary>
[ServiceContract]
public interface ICountingSagaService
{
Task<StartCountingReply> Start(StartCountingRequest request, CallContext context = default);
}

/// <summary>
/// Saga start message with <b>no</b> id-like member — no <c>Id</c>, <c>SagaId</c>,
/// <c>CountingSagaId</c>, nor <c>[SagaIdentity]</c>. This forces
/// <c>SagaChain.DetermineSagaIdMember</c> to return null, which routes identity resolution onto
/// the envelope <c>saga-id</c> header (<c>PullSagaIdFromEnvelopeFrame</c>) instead of the
/// message body.
/// </summary>
[ProtoContract]
public class StartCountingRequest
{
[ProtoMember(1)]
public string? Label { get; set; }
}

[ProtoContract]
public class StartCountingReply
{
[ProtoMember(1)]
public string? SagaId { get; set; }
}
62 changes: 62 additions & 0 deletions src/Wolverine.Grpc.Tests/SagaOverGrpc/SagaOverGrpcFixture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ProtoBuf.Grpc.Client;
using ProtoBuf.Grpc.Server;
using Xunit;

namespace Wolverine.Grpc.Tests.SagaOverGrpc;

/// <summary>
/// A standalone in-process host (not the shared <see cref="Client.WolverineGrpcClientFixture"/>)
/// that wires only <see cref="CountingSaga"/> as a handler and maps
/// <see cref="CountingSagaGrpcService"/>. Uses Wolverine's default in-memory saga persistence,
/// so no database is required to exercise the saga path.
/// </summary>
public class SagaOverGrpcFixture : IAsyncLifetime
{
private WebApplication? _app;
public GrpcChannel? Channel { get; private set; }

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));
});

builder.Services.AddCodeFirstGrpc();
builder.Services.AddWolverineGrpc();

_app = builder.Build();
_app.UseRouting();
_app.MapGrpcService<CountingSagaGrpcService>();

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<ICountingSagaService>();
}
46 changes: 46 additions & 0 deletions src/Wolverine.Grpc.Tests/SagaOverGrpc/saga_over_grpc_tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Grpc.Core;
using Shouldly;
using Xunit;

namespace Wolverine.Grpc.Tests.SagaOverGrpc;

/// <summary>
/// Characterization tests for driving a Wolverine saga over a gRPC service hop.
/// <para>
/// The <b>message-identified</b> saga case (id on the request DTO) already works today and is
/// covered elsewhere by the ordinary saga compliance suites — the gRPC shim just forwards to
/// <c>InvokeAsync</c> and Wolverine reads the id out of the message body.
/// </para>
/// <para>
/// This test pins the <b>header-identified</b> gap: a saga whose id comes from the envelope
/// <c>saga-id</c> header can't resolve its id over gRPC, because nothing carries <c>saga-id</c>
/// across the hop. The failure currently surfaces as an opaque <see cref="StatusCode.Internal"/>
/// (because <c>IndeterminateSagaStateIdException</c> is a bare <see cref="Exception"/> and
/// <c>WolverineGrpcExceptionMapper</c> falls through to its default). When the scoped diagnostic
/// lands, flip the assertions below to the actionable status/message.
/// </para>
/// </summary>
public class saga_over_grpc_tests : IClassFixture<SagaOverGrpcFixture>
{
private readonly SagaOverGrpcFixture _fixture;

public saga_over_grpc_tests(SagaOverGrpcFixture fixture)
{
_fixture = fixture;
}

[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<RpcException>(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");
}
}
Loading