/ 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 }