11using System . Text . Json ;
22using Microsoft . Extensions . DependencyInjection ;
33using Vulthil . Messaging . Abstractions . Consumers ;
4+ using Vulthil . Messaging . Queues ;
45using Vulthil . Messaging . Transport ;
56
67namespace Vulthil . Messaging . TestHarness ;
@@ -12,23 +13,53 @@ namespace Vulthil.Messaging.TestHarness;
1213/// </summary>
1314internal static class InMemoryMessageHandlers
1415{
15- /// <summary>Builds a handler for a one-way <see cref="IConsumer{TMessage}"/>.</summary>
16- public static InMemoryHandler ForConsumer < TConsumer , TMessage > ( )
16+ /// <summary>
17+ /// Builds a handler for a one-way <see cref="IConsumer{TMessage}"/>. A throwing consumer is retried in-process
18+ /// per <paramref name="retryPolicy"/> — a fresh scope per attempt, mirroring the broker transport but without
19+ /// the real back-off delays — and once the attempts are exhausted a <see cref="Fault{TMessage}"/> is published
20+ /// and the delivery completes normally, so the originating publish/send succeeds just as it would against a
21+ /// real broker.
22+ /// </summary>
23+ public static InMemoryHandler ForConsumer < TConsumer , TMessage > ( RetryPolicyDefinition ? retryPolicy )
1724 where TConsumer : class , IConsumer < TMessage >
1825 where TMessage : notnull
19- => new ( HandlerKind . Consumer , async ( scope , message , envelope , ct ) =>
26+ => new ( HandlerKind . Consumer , async ( scope , message , envelope , cancellationToken ) =>
2027 {
21- var consumer = scope . GetRequiredService < TConsumer > ( ) ;
28+ var scopeFactory = scope . GetRequiredService < IServiceScopeFactory > ( ) ;
2229 var harness = scope . GetRequiredService < TestHarness > ( ) ;
23- var context = InMemoryContext . Create ( scope , ( TMessage ) message , envelope , ct ) ;
30+ var maxRetries = Math . Max ( 0 , retryPolicy ? . MaxRetryCount ?? 0 ) ;
31+ var ignoredExceptions = retryPolicy ? . GetIgnoredExceptionTypes ( ) ;
2432
25- var pipeline = ConsumePipelineFactory . Build < TMessage > ( scope , terminal : c =>
33+ Exception ? lastError = null ;
34+ for ( var attempt = 0 ; attempt <= maxRetries ; attempt ++ )
2635 {
27- harness . RecordConsumed ( ( TMessage ) message , envelope ) ;
28- return consumer . ConsumeAsync ( c , c . CancellationToken ) ;
29- } ) ;
36+ await using var attemptScope = scopeFactory . CreateAsyncScope ( ) ;
37+ var serviceProvider = attemptScope . ServiceProvider ;
38+ var consumer = serviceProvider . GetRequiredService < TConsumer > ( ) ;
39+ var context = InMemoryContext . Create ( serviceProvider , ( TMessage ) message , envelope , cancellationToken , attempt ) ;
40+
41+ try
42+ {
43+ var pipeline = ConsumePipelineFactory . Build < TMessage > ( serviceProvider , terminal : async c =>
44+ {
45+ await consumer . ConsumeAsync ( c , c . CancellationToken ) ;
46+ harness . RecordConsumed ( ( TMessage ) message , envelope ) ;
47+ } ) ;
3048
31- await pipeline ( context ) ;
49+ await pipeline ( context ) ;
50+ return null ;
51+ }
52+ catch ( Exception ex ) when ( ex is not OperationCanceledException )
53+ {
54+ lastError = ex ;
55+ if ( ignoredExceptions is not null && ignoredExceptions . Contains ( ex . GetType ( ) ) )
56+ {
57+ break ;
58+ }
59+ }
60+ }
61+
62+ await PublishFaultAsync < TMessage > ( scope , ( TMessage ) message , envelope , lastError ! , maxRetries , cancellationToken ) ;
3263 return null ;
3364 } ) ;
3465
@@ -73,4 +104,55 @@ public static InMemoryHandler ForRequestConsumer<TConsumer, TRequest, TResponse>
73104 return InMemoryReply . BuildFault ( ex , options , envelope ) ;
74105 }
75106 } ) ;
107+
108+ /// <summary>
109+ /// Publishes a <see cref="Fault{TMessage}"/> for a terminally-failed one-way delivery, mirroring the broker
110+ /// transport: the fault is captured (so tests can assert it) and delivered in-process to any consumer bound to
111+ /// it. Best-effort — it never disrupts completing the original delivery.
112+ /// </summary>
113+ private static async Task PublishFaultAsync < TMessage > (
114+ IServiceProvider scope ,
115+ TMessage message ,
116+ MessageEnvelope envelope ,
117+ Exception error ,
118+ int retryCount ,
119+ CancellationToken cancellationToken )
120+ where TMessage : notnull
121+ {
122+ var provider = scope . GetRequiredService < IMessageConfigurationProvider > ( ) ;
123+ var transport = scope . GetRequiredService < InMemoryTransport > ( ) ;
124+ var harness = scope . GetRequiredService < TestHarness > ( ) ;
125+ var context = InMemoryContext . Create ( scope , message , envelope , cancellationToken , retryCount ) ;
126+
127+ var fault = new Fault < TMessage >
128+ {
129+ Message = message ,
130+ ExceptionMessage = error . Message ,
131+ StackTrace = error . StackTrace ,
132+ ExceptionType = error . GetType ( ) . FullName ?? "Unknown" ,
133+ FaultedAt = DateTimeOffset . UtcNow ,
134+ OriginalContext = CreateSnapshot ( context ) ,
135+ } ;
136+
137+ var faultEnvelope = OutgoingEnvelope . Build ( provider , fault , new PublishContext ( ) ) ;
138+ harness . RecordPublished ( fault , faultEnvelope ) ;
139+ await transport . DeliverAsync ( faultEnvelope , cancellationToken ) ;
140+ }
141+
142+ private static MessageContextSnapshot CreateSnapshot < TMessage > ( MessageContext < TMessage > context )
143+ where TMessage : notnull
144+ => new ( )
145+ {
146+ MessageId = context . MessageId ,
147+ RequestId = context . RequestId ,
148+ CorrelationId = context . CorrelationId ,
149+ ConversationId = context . ConversationId ,
150+ InitiatorId = context . InitiatorId ,
151+ SourceAddress = context . SourceAddress ,
152+ DestinationAddress = context . DestinationAddress ,
153+ ResponseAddress = context . ResponseAddress ,
154+ FaultAddress = context . FaultAddress ,
155+ RoutingKey = context . RoutingKey ,
156+ RetryCount = context . RetryCount ,
157+ } ;
76158}
0 commit comments