1+ using Microsoft . Extensions . DependencyInjection ;
12using Microsoft . Extensions . Options ;
23using Vulthil . xUnit ;
34
@@ -31,6 +32,59 @@ public async Task StoppingWhileWaitingForRelayGatesCompletesGracefully()
3132 Target . ExecuteTask ! . Status . ShouldBe ( TaskStatus . RanToCompletion ) ;
3233 }
3334
35+ [ Fact ]
36+ public async Task ABatchWithFewerSuccessesThanTheBatchSizeDelaysTheNextFetch ( )
37+ {
38+ // Arrange
39+ const int batchSize = 10 ;
40+ const int baseDelaySeconds = 2 ;
41+ Use < IOptions < OutboxProcessingOptions > > ( Options . Create ( new OutboxProcessingOptions
42+ {
43+ BatchSize = batchSize ,
44+ OutboxProcessingDelaySeconds = baseDelaySeconds
45+ } ) ) ;
46+ Use < IEnumerable < IOutboxRelayGate > > ( [ ] ) ;
47+ Use < IServiceScopeFactory > ( new AutoMockerServiceScopeFactory ( AutoMocker ) ) ;
48+ var store = new CountingOutboxStore ( result : batchSize - 3 ) ;
49+ Use < IOutboxStore > ( store ) ;
50+ Use ( CreateInstance < OutboxProcessor > ( ) ) ;
51+ TimeSpan ? capturedDelay = null ;
52+ GetMock < IOutboxSignal > ( )
53+ . Setup ( signal => signal . WaitAsync ( It . IsAny < TimeSpan > ( ) , It . IsAny < CancellationToken > ( ) ) )
54+ . Callback < TimeSpan , CancellationToken > ( ( delay , _ ) => capturedDelay ??= delay )
55+ . Returns ( Task . CompletedTask ) ;
56+
57+ // Act
58+ await Target . StartAsync ( CancellationToken ) ;
59+ await store . SecondCallStarted ;
60+ await Target . StopAsync ( CancellationToken ) ;
61+
62+ // Assert
63+ capturedDelay . ShouldNotBeNull ( ) ;
64+ capturedDelay ! . Value . ShouldBeGreaterThanOrEqualTo ( TimeSpan . FromSeconds ( baseDelaySeconds ) ) ;
65+ }
66+
67+ [ Fact ]
68+ public async Task AFullySuccessfulFullBatchRefetchesImmediately ( )
69+ {
70+ // Arrange
71+ const int batchSize = 10 ;
72+ Use < IOptions < OutboxProcessingOptions > > ( Options . Create ( new OutboxProcessingOptions { BatchSize = batchSize } ) ) ;
73+ Use < IEnumerable < IOutboxRelayGate > > ( [ ] ) ;
74+ Use < IServiceScopeFactory > ( new AutoMockerServiceScopeFactory ( AutoMocker ) ) ;
75+ var store = new CountingOutboxStore ( result : batchSize ) ;
76+ Use < IOutboxStore > ( store ) ;
77+ Use ( CreateInstance < OutboxProcessor > ( ) ) ;
78+
79+ // Act
80+ await Target . StartAsync ( CancellationToken ) ;
81+ await store . SecondCallStarted ;
82+ await Target . StopAsync ( CancellationToken ) ;
83+
84+ // Assert
85+ GetMock < IOutboxSignal > ( ) . Verify ( signal => signal . WaitAsync ( It . IsAny < TimeSpan > ( ) , It . IsAny < CancellationToken > ( ) ) , Times . Never ) ;
86+ }
87+
3488 private sealed class BlockingRelayGate : IOutboxRelayGate
3589 {
3690 private readonly TaskCompletionSource _entered = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
@@ -43,4 +97,44 @@ public Task WaitUntilReadyAsync(CancellationToken cancellationToken)
4397 return Task . Delay ( Timeout . Infinite , cancellationToken ) ;
4498 }
4599 }
100+
101+ private sealed class CountingOutboxStore ( int result ) : IOutboxStore
102+ {
103+ private readonly TaskCompletionSource _secondCallStarted = new ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
104+ private int _callCount ;
105+
106+ public Task SecondCallStarted => _secondCallStarted . Task ;
107+
108+ public bool IsInTransaction => false ;
109+
110+ public void AddOutboxMessage ( OutboxMessage message )
111+ {
112+ }
113+
114+ public Task < int > SaveChangesAsync ( CancellationToken cancellationToken = default ) => Task . FromResult ( 0 ) ;
115+
116+ public Task < int > ProcessBatchAsync ( Func < OutboxMessageData , CancellationToken , Task < string ? > > dispatch , CancellationToken cancellationToken )
117+ {
118+ if ( Interlocked . Increment ( ref _callCount ) == 2 )
119+ {
120+ _secondCallStarted . TrySetResult ( ) ;
121+ }
122+
123+ return Task . FromResult ( result ) ;
124+ }
125+ }
126+
127+ private sealed class AutoMockerServiceScopeFactory ( IServiceProvider serviceProvider ) : IServiceScopeFactory
128+ {
129+ public IServiceScope CreateScope ( ) => new PassthroughServiceScope ( serviceProvider ) ;
130+
131+ private sealed class PassthroughServiceScope ( IServiceProvider serviceProvider ) : IServiceScope
132+ {
133+ public IServiceProvider ServiceProvider { get ; } = serviceProvider ;
134+
135+ public void Dispose ( )
136+ {
137+ }
138+ }
139+ }
46140}
0 commit comments