/ GUNRPG.Tests / CombatSessionUpdateHubTests.cs
CombatSessionUpdateHubTests.cs
  1  using GUNRPG.Application.Sessions;
  2  
  3  namespace GUNRPG.Tests;
  4  
  5  /// <summary>
  6  /// Tests for <see cref="CombatSessionUpdateHub"/>: verifying real-time push notifications
  7  /// for combat session state changes so connected clients (SSE subscribers) receive events
  8  /// as they are published without polling.
  9  /// </summary>
 10  public class CombatSessionUpdateHubTests
 11  {
 12      [Fact]
 13      public async Task Publish_WithSubscriber_SubscriberReceivesNotification()
 14      {
 15          var hub = new CombatSessionUpdateHub();
 16          var sessionId = Guid.NewGuid();
 17  
 18          using var cts = new CancellationTokenSource();
 19          var received = new List<Guid>();
 20          var subscriberReady = new TaskCompletionSource();
 21  
 22          var subscribeTask = StartSubscriberAsync(hub, sessionId, received, subscriberReady, cts);
 23  
 24          await subscriberReady.Task.WaitAsync(TimeSpan.FromSeconds(2));
 25  
 26          hub.Publish(sessionId);
 27  
 28          await subscribeTask.WaitAsync(TimeSpan.FromSeconds(2));
 29  
 30          Assert.Single(received);
 31          Assert.Equal(sessionId, received[0]);
 32      }
 33  
 34      [Fact]
 35      public async Task Publish_NoSubscribers_DoesNotThrow()
 36      {
 37          var hub = new CombatSessionUpdateHub();
 38          var sessionId = Guid.NewGuid();
 39  
 40          hub.Publish(sessionId);
 41          await Task.CompletedTask;
 42      }
 43  
 44      [Fact]
 45      public async Task Publish_MultipleSubscribers_AllReceiveNotification()
 46      {
 47          var hub = new CombatSessionUpdateHub();
 48          var sessionId = Guid.NewGuid();
 49  
 50          using var cts1 = new CancellationTokenSource();
 51          using var cts2 = new CancellationTokenSource();
 52          var received1 = new List<Guid>();
 53          var received2 = new List<Guid>();
 54          var ready1 = new TaskCompletionSource();
 55          var ready2 = new TaskCompletionSource();
 56  
 57          var sub1 = StartSubscriberAsync(hub, sessionId, received1, ready1, cts1);
 58          var sub2 = StartSubscriberAsync(hub, sessionId, received2, ready2, cts2);
 59  
 60          await Task.WhenAll(
 61              ready1.Task.WaitAsync(TimeSpan.FromSeconds(2)),
 62              ready2.Task.WaitAsync(TimeSpan.FromSeconds(2)));
 63  
 64          hub.Publish(sessionId);
 65  
 66          await Task.WhenAll(
 67              sub1.WaitAsync(TimeSpan.FromSeconds(2)),
 68              sub2.WaitAsync(TimeSpan.FromSeconds(2)));
 69  
 70          Assert.Single(received1);
 71          Assert.Single(received2);
 72      }
 73  
 74      [Fact]
 75      public async Task Publish_DifferentSession_SubscriberDoesNotReceive()
 76      {
 77          var hub = new CombatSessionUpdateHub();
 78          var sessionA = Guid.NewGuid();
 79          var sessionB = Guid.NewGuid();
 80  
 81          using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
 82          var received = new List<Guid>();
 83          var ready = new TaskCompletionSource();
 84  
 85          var subscribeTask = StartSubscriberAsync(hub, sessionA, received, ready, cts);
 86  
 87          await ready.Task.WaitAsync(TimeSpan.FromSeconds(2));
 88  
 89          hub.Publish(sessionB); // publish for B, not A
 90          await subscribeTask;   // cancels via timeout CTS
 91  
 92          Assert.Empty(received);
 93      }
 94  
 95      [Fact]
 96      public async Task Subscribe_Cancelled_EndsCleanly()
 97      {
 98          var hub = new CombatSessionUpdateHub();
 99          var sessionId = Guid.NewGuid();
100  
101          using var cts = new CancellationTokenSource();
102          var ready = new TaskCompletionSource();
103  
104          var subscribeTask = StartSubscriberAsync(hub, sessionId, new List<Guid>(), ready, cts);
105  
106          await ready.Task.WaitAsync(TimeSpan.FromSeconds(2));
107          cts.Cancel();
108  
109          await subscribeTask.WaitAsync(TimeSpan.FromSeconds(2));
110      }
111  
112      [Fact]
113      public async Task Publish_MultipleEvents_AllDeliveredInOrder()
114      {
115          var hub = new CombatSessionUpdateHub();
116          var sessionId = Guid.NewGuid();
117  
118          using var cts = new CancellationTokenSource();
119          var received = new List<Guid>();
120          var ready = new TaskCompletionSource();
121  
122          var subscribeTask = Task.Run(async () =>
123          {
124              try
125              {
126                  await using var enumerator = hub.SubscribeAsync(sessionId, cts.Token)
127                      .GetAsyncEnumerator(cts.Token);
128  
129                  var pending = enumerator.MoveNextAsync();
130                  ready.TrySetResult();
131  
132                  while (await pending)
133                  {
134                      received.Add(enumerator.Current);
135                      if (received.Count >= 2) cts.Cancel();
136                      pending = enumerator.MoveNextAsync();
137                  }
138              }
139              catch (OperationCanceledException) { }
140          });
141  
142          await ready.Task.WaitAsync(TimeSpan.FromSeconds(2));
143          hub.Publish(sessionId);
144          hub.Publish(sessionId);
145  
146          await subscribeTask.WaitAsync(TimeSpan.FromSeconds(2));
147  
148          Assert.Equal(2, received.Count);
149      }
150  
151      /// <summary>
152      /// Starts a background subscriber task that collects received session IDs into
153      /// <paramref name="received"/> and signals <paramref name="subscriberReady"/> once
154      /// the channel subscription is registered. Cancels <paramref name="cts"/> after the
155      /// first event is received, then stops.
156      /// </summary>
157      private static Task StartSubscriberAsync(
158          CombatSessionUpdateHub hub,
159          Guid sessionId,
160          List<Guid> received,
161          TaskCompletionSource subscriberReady,
162          CancellationTokenSource cts)
163      {
164          return Task.Run(async () =>
165          {
166              try
167              {
168                  await using var enumerator = hub.SubscribeAsync(sessionId, cts.Token)
169                      .GetAsyncEnumerator(cts.Token);
170  
171                  var pending = enumerator.MoveNextAsync();
172                  subscriberReady.TrySetResult();
173  
174                  while (await pending)
175                  {
176                      received.Add(enumerator.Current);
177                      cts.Cancel();
178                      pending = enumerator.MoveNextAsync();
179                  }
180              }
181              catch (OperationCanceledException) { }
182          });
183      }
184  }