/ GUNRPG.Tests / WebOfflineSupportTests.cs
WebOfflineSupportTests.cs
  1  using GUNRPG.Application.Backend;
  2  using GUNRPG.ClientModels;
  3  using GUNRPG.WebClient.Helpers;
  4  using GUNRPG.WebClient.Services;
  5  using Microsoft.JSInterop;
  6  using System.Net;
  7  using System.Net.Http.Json;
  8  using System.Text;
  9  using System.Text.Json;
 10  
 11  namespace GUNRPG.Tests;
 12  
 13  public sealed class WebOfflineSupportTests
 14  {
 15      private static readonly Guid SyncOperatorId = Guid.Parse("11111111-1111-1111-1111-111111111111");
 16  
 17      [Fact]
 18      public async Task BrowserOfflineStore_SaveInfiledOperatorAsync_DeactivatesPreviousActiveSnapshot()
 19      {
 20          var js = new FakeBrowserJsRuntime();
 21          var store = new BrowserOfflineStore(js);
 22  
 23          await store.SaveInfiledOperatorAsync(CreateOperator(Guid.Parse("11111111-1111-1111-1111-111111111111"), "Alpha"));
 24          await store.SaveInfiledOperatorAsync(CreateOperator(Guid.Parse("22222222-2222-2222-2222-222222222222"), "Bravo"));
 25  
 26          var first = await store.GetInfiledOperatorAsync(Guid.Parse("11111111-1111-1111-1111-111111111111"));
 27          var active = await store.GetActiveInfiledOperatorAsync();
 28  
 29          Assert.NotNull(first);
 30          Assert.NotNull(active);
 31          Assert.Equal("Alpha", first.Name);
 32          Assert.Equal("Bravo", active.Name);
 33          Assert.Equal(Guid.Parse("22222222-2222-2222-2222-222222222222"), active.Id);
 34      }
 35  
 36      [Fact]
 37      public async Task BrowserOfflineStore_SaveMissionResultAsync_RejectsBrokenHashChain()
 38      {
 39          var js = new FakeBrowserJsRuntime();
 40          var store = new BrowserOfflineStore(js);
 41  
 42          await store.SaveMissionResultAsync(new OfflineMissionEnvelope
 43          {
 44              OperatorId = "op-1",
 45              SequenceNumber = 1,
 46              RandomSeed = 1,
 47              InitialOperatorStateHash = "h0",
 48              ResultOperatorStateHash = "h1",
 49              ExecutedUtc = DateTime.UtcNow,
 50              FullBattleLog = []
 51          });
 52  
 53          await Assert.ThrowsAsync<InvalidOperationException>(() => store.SaveMissionResultAsync(new OfflineMissionEnvelope
 54          {
 55              OperatorId = "op-1",
 56              SequenceNumber = 2,
 57              RandomSeed = 2,
 58              InitialOperatorStateHash = "wrong",
 59              ResultOperatorStateHash = "h2",
 60              ExecutedUtc = DateTime.UtcNow,
 61              FullBattleLog = []
 62          }));
 63      }
 64  
 65      [Fact]
 66      public async Task AuthService_TryRestoreAsync_UsesStoredAccessTokenWhenOffline()
 67      {
 68          var js = new FakeBrowserJsRuntime();
 69          await js.InvokeVoidAsync("tokenStorage.storeAccessToken", "cached-access");
 70          await js.InvokeVoidAsync("tokenStorage.storeRefreshToken", "cached-refresh");
 71  
 72          using var http = new HttpClient(new FailingHandler());
 73          var nodeService = new NodeConnectionService(js);
 74          await nodeService.SetBaseUrlAsync("https://node.example.com");
 75          var auth = new AuthService(js, http, nodeService);
 76  
 77          var restored = await auth.TryRestoreAsync();
 78  
 79          Assert.True(restored);
 80          Assert.True(auth.IsAuthenticated);
 81          Assert.Equal("cached-access", auth.GetAccessToken());
 82      }
 83  
 84      [Fact]
 85      public async Task AuthService_RefreshTokenAsync_ReturnsFalseWhenRefreshCannotRun()
 86      {
 87          var js = new FakeBrowserJsRuntime();
 88          await js.InvokeVoidAsync("tokenStorage.storeAccessToken", "cached-access");
 89  
 90          using var http = new HttpClient(new FailingHandler());
 91          var nodeService = new NodeConnectionService(js);
 92          var auth = new AuthService(js, http, nodeService);
 93          await auth.TryRestoreAsync();
 94  
 95          var refreshed = await auth.RefreshTokenAsync();
 96          var sseToken = await auth.GetSseAccessTokenAsync(forceRefresh: true);
 97  
 98          Assert.False(refreshed);
 99          Assert.Null(sseToken);
100          Assert.Equal("cached-access", auth.GetAccessToken());
101      }
102  
103      [Fact]
104      public async Task OfflineSyncService_SyncAsync_CoalescesConcurrentRequestsPerOperator()
105      {
106          var js = new FakeBrowserJsRuntime();
107          var store = new BrowserOfflineStore(js);
108          await store.SaveMissionResultAsync(new OfflineMissionEnvelope
109          {
110              Id = "env-1",
111              OperatorId = SyncOperatorId.ToString(),
112              SequenceNumber = 1,
113              RandomSeed = 1,
114              InitialOperatorStateHash = "h0",
115              ResultOperatorStateHash = "h1",
116              ExecutedUtc = DateTime.UtcNow,
117              Synced = true,
118              FullBattleLog = []
119          });
120          await store.SaveMissionResultAsync(new OfflineMissionEnvelope
121          {
122              Id = "env-2",
123              OperatorId = SyncOperatorId.ToString(),
124              SequenceNumber = 2,
125              RandomSeed = 2,
126              InitialOperatorStateHash = "h1",
127              ResultOperatorStateHash = "h2",
128              ExecutedUtc = DateTime.UtcNow,
129              FullBattleLog = []
130          });
131  
132          var handler = new DelayedSyncHandler();
133          using var http = new HttpClient(handler);
134          var nodeService = new NodeConnectionService(js);
135          await nodeService.SetBaseUrlAsync("https://node.example.com");
136          var auth = new AuthService(js, http, nodeService);
137          var api = new ApiClient(http, nodeService, auth);
138          var sync = new OfflineSyncService(api, store);
139  
140          var first = sync.SyncAsync(SyncOperatorId);
141          var second = sync.SyncAsync(SyncOperatorId);
142          var results = await Task.WhenAll(first, second);
143  
144          Assert.Equal(1, handler.OfflineSyncPostCount);
145          Assert.Empty(await store.GetAllUnsyncedResultsAsync());
146          Assert.Equal(1, results[0].EnvelopesSynced);
147          Assert.Equal(0, results[1].EnvelopesSynced);
148      }
149  
150      [Fact]
151      public async Task OperatorService_StartCombatSessionAsync_UsesApiWhenOnlineWithoutOfflineSnapshot()
152      {
153          var js = new FakeBrowserJsRuntime();
154          var handler = new StartCombatHandler();
155          using var http = new HttpClient(handler);
156          var nodeService = new NodeConnectionService(js);
157          await nodeService.SetBaseUrlAsync("https://node.example.com");
158          var auth = new AuthService(js, http, nodeService);
159          var api = new ApiClient(http, nodeService, auth);
160          var offlineStore = new BrowserOfflineStore(js);
161          var combatStore = new BrowserCombatSessionStore(js);
162          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
163          var offlineSync = new OfflineSyncService(api, offlineStore);
164          var connection = new ConnectionStateService(js);
165          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
166          var operatorId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
167  
168          var (sessionId, error) = await service.StartCombatSessionAsync(operatorId);
169  
170          Assert.Null(error);
171          Assert.Equal(handler.SessionId, sessionId);
172          Assert.Equal(1, handler.StartCombatCount);
173          Assert.Equal($"/operators/{operatorId}/infil/combat", handler.LastRequestPath);
174      }
175  
176      [Fact]
177      public async Task OperatorService_StartCombatSessionAsync_WhenOnlineUpdatesLocalSnapshotWithActiveSession()
178      {
179          var js = new FakeBrowserJsRuntime();
180          var handler = new StartCombatHandler();
181          using var http = new HttpClient(handler);
182          var nodeService = new NodeConnectionService(js);
183          await nodeService.SetBaseUrlAsync("https://node.example.com");
184          var auth = new AuthService(js, http, nodeService);
185          var api = new ApiClient(http, nodeService, auth);
186          var offlineStore = new BrowserOfflineStore(js);
187          var combatStore = new BrowserCombatSessionStore(js);
188          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
189          var offlineSync = new OfflineSyncService(api, offlineStore);
190          var connection = new ConnectionStateService(js);
191          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
192          var operatorId = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
193  
194          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Bravo"));
195  
196          var (sessionId, error) = await service.StartCombatSessionAsync(operatorId);
197          var updatedOperator = await offlineStore.GetInfiledOperatorAsync(operatorId);
198  
199          Assert.Null(error);
200          Assert.Equal(handler.SessionId, sessionId);
201          Assert.NotNull(updatedOperator);
202          Assert.Equal(handler.SessionId, updatedOperator.ActiveCombatSessionId);
203      }
204  
205      [Fact]
206      public async Task OperatorService_StartCombatSessionAsync_UsesApiWhenOnlineAndStorageThrows()
207      {
208          var js = new FakeBrowserJsRuntime(throwOnGunRpgStorage: true);
209          var handler = new StartCombatHandler();
210          using var http = new HttpClient(handler);
211          var nodeService = new NodeConnectionService(js);
212          await nodeService.SetBaseUrlAsync("https://node.example.com");
213          var auth = new AuthService(js, http, nodeService);
214          var api = new ApiClient(http, nodeService, auth);
215          var offlineStore = new BrowserOfflineStore(js);
216          var combatStore = new BrowserCombatSessionStore(js);
217          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
218          var offlineSync = new OfflineSyncService(api, offlineStore);
219          var connection = new ConnectionStateService(js);
220          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
221          var operatorId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
222  
223          var (sessionId, error) = await service.StartCombatSessionAsync(operatorId);
224  
225          Assert.Null(error);
226          Assert.Equal(handler.SessionId, sessionId);
227          Assert.Equal(1, handler.StartCombatCount);
228          Assert.Equal($"/operators/{operatorId}/infil/combat", handler.LastRequestPath);
229      }
230  
231      [Fact]
232      public async Task OperatorService_GetAsync_WhenInfilTimerExpiredAndOnline_FetchesFromServerAndPurgesStaleSnapshot()
233      {
234          var operatorId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
235          var js = new FakeBrowserJsRuntime();
236          var handler = new GetBaseOperatorHandler(operatorId);
237          using var http = new HttpClient(handler);
238          var nodeService = new NodeConnectionService(js);
239          await nodeService.SetBaseUrlAsync("https://node.example.com");
240          var auth = new AuthService(js, http, nodeService);
241          var api = new ApiClient(http, nodeService, auth);
242          var offlineStore = new BrowserOfflineStore(js);
243          var combatStore = new BrowserCombatSessionStore(js);
244          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
245          var offlineSync = new OfflineSyncService(api, offlineStore);
246          var connection = new ConnectionStateService(js); // IsOnline defaults to true
247          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
248  
249          // Save a stale infil snapshot whose timer lapsed 31 minutes ago.
250          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Delta",
251              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-31)));
252  
253          var (data, error) = await service.GetAsync(operatorId);
254          var remaining = await offlineStore.GetInfiledOperatorAsync(operatorId);
255  
256          Assert.Null(error);
257          Assert.NotNull(data);
258          Assert.Equal("Base", data.CurrentMode);    // Server state returned, not stale cache
259          Assert.Equal(1, handler.GetOperatorCount); // Server was queried
260          Assert.Null(remaining);                    // Stale snapshot was purged
261      }
262  
263      [Fact]
264      public async Task OperatorService_GetAsync_WhenInfilTimerStillActiveAndOnline_PrefersServerStateAndPurgesStaleSnapshot()
265      {
266          var operatorId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
267          var js = new FakeBrowserJsRuntime();
268          var handler = new GetBaseOperatorHandler(operatorId);
269          using var http = new HttpClient(handler);
270          var nodeService = new NodeConnectionService(js);
271          await nodeService.SetBaseUrlAsync("https://node.example.com");
272          var auth = new AuthService(js, http, nodeService);
273          var api = new ApiClient(http, nodeService, auth);
274          var offlineStore = new BrowserOfflineStore(js);
275          var combatStore = new BrowserCombatSessionStore(js);
276          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
277          var offlineSync = new OfflineSyncService(api, offlineStore);
278          var connection = new ConnectionStateService(js); // IsOnline defaults to true
279          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
280  
281          // Save an active infil snapshot (timer started 5 minutes ago).
282          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Echo",
283              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-5)));
284  
285          var (data, error) = await service.GetAsync(operatorId);
286          var remaining = await offlineStore.GetInfiledOperatorAsync(operatorId);
287  
288          Assert.Null(error);
289          Assert.NotNull(data);
290          Assert.Equal("Base", data.CurrentMode);       // Authoritative server state returned
291          Assert.Equal(1, handler.GetOperatorCount);    // Server queried even while timer is active
292          Assert.Null(remaining);                       // Stale local snapshot was purged
293      }
294  
295      [Fact]
296      public async Task OperatorService_GetAsync_WhenOnlineFetchFails_FallsBackToLocalSnapshot()
297      {
298          var operatorId = Guid.Parse("abababab-abab-abab-abab-abababababab");
299          var js = new FakeBrowserJsRuntime();
300          using var http = new HttpClient(new FailingHandler());
301          var nodeService = new NodeConnectionService(js);
302          await nodeService.SetBaseUrlAsync("https://node.example.com");
303          var auth = new AuthService(js, http, nodeService);
304          var api = new ApiClient(http, nodeService, auth);
305          var offlineStore = new BrowserOfflineStore(js);
306          var combatStore = new BrowserCombatSessionStore(js);
307          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
308          var offlineSync = new OfflineSyncService(api, offlineStore);
309          var connection = new ConnectionStateService(js); // IsOnline defaults to true
310          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
311  
312          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Fallback",
313              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-5)));
314  
315          var (data, error) = await service.GetAsync(operatorId);
316  
317          Assert.Null(error);
318          Assert.NotNull(data);
319          Assert.Equal("Infil", data.CurrentMode);
320          Assert.Equal(operatorId, data.Id);
321      }
322  
323      [Fact]
324      public async Task OperatorService_GetAsync_WhenOnlineServerReturnsError_FallsBackToLocalSnapshot()
325      {
326          var operatorId = Guid.Parse("cdcdcdcd-cdcd-cdcd-cdcd-cdcdcdcdcdcd");
327          var js = new FakeBrowserJsRuntime();
328          var handler = new ErrorOperatorHandler(HttpStatusCode.ServiceUnavailable);
329          using var http = new HttpClient(handler);
330          var nodeService = new NodeConnectionService(js);
331          await nodeService.SetBaseUrlAsync("https://node.example.com");
332          var auth = new AuthService(js, http, nodeService);
333          var api = new ApiClient(http, nodeService, auth);
334          var offlineStore = new BrowserOfflineStore(js);
335          var combatStore = new BrowserCombatSessionStore(js);
336          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
337          var offlineSync = new OfflineSyncService(api, offlineStore);
338          var connection = new ConnectionStateService(js); // IsOnline defaults to true
339          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
340  
341          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Fallback Error",
342              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-5)));
343  
344          var (data, error) = await service.GetAsync(operatorId);
345  
346          Assert.Null(error);
347          Assert.NotNull(data);
348          Assert.Equal("Infil", data.CurrentMode);
349          Assert.Equal(operatorId, data.Id);
350          Assert.Equal(1, handler.GetOperatorCount);
351      }
352  
353      [Fact]
354      public async Task OperatorService_GetAsync_WhenServerReturnsInfil_RefreshesLocalSnapshot()
355      {
356          var operatorId = Guid.Parse("dededede-dede-dede-dede-dededededede");
357          var js = new FakeBrowserJsRuntime();
358          var handler = new GetInfilOperatorHandler(operatorId, "Server Echo");
359          using var http = new HttpClient(handler);
360          var nodeService = new NodeConnectionService(js);
361          await nodeService.SetBaseUrlAsync("https://node.example.com");
362          var auth = new AuthService(js, http, nodeService);
363          var api = new ApiClient(http, nodeService, auth);
364          var offlineStore = new BrowserOfflineStore(js);
365          var combatStore = new BrowserCombatSessionStore(js);
366          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
367          var offlineSync = new OfflineSyncService(api, offlineStore);
368          var connection = new ConnectionStateService(js); // IsOnline defaults to true
369          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
370  
371          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Local Echo",
372              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-5)));
373  
374          var (data, error) = await service.GetAsync(operatorId);
375          var refreshed = await offlineStore.GetInfiledOperatorAsync(operatorId);
376  
377          Assert.Null(error);
378          Assert.NotNull(data);
379          Assert.Equal("Server Echo", data.Name);
380          Assert.NotNull(refreshed);
381          Assert.Equal("Server Echo", refreshed.Name);
382          Assert.Equal(1, handler.GetOperatorCount);
383      }
384  
385      [Fact]
386      public async Task OperatorService_GetAsync_WhenInfilTimerExpiredAndOffline_ReturnsStaleLocalSnapshot()
387      {
388          var operatorId = Guid.Parse("ffffffff-ffff-ffff-ffff-ffffffffffff");
389          var js = new FakeBrowserJsRuntime();
390          var handler = new CountingGetHandler();
391          using var http = new HttpClient(handler);
392          var nodeService = new NodeConnectionService(js);
393          await nodeService.SetBaseUrlAsync("https://node.example.com");
394          var auth = new AuthService(js, http, nodeService);
395          var api = new ApiClient(http, nodeService, auth);
396          var offlineStore = new BrowserOfflineStore(js);
397          var combatStore = new BrowserCombatSessionStore(js);
398          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
399          var offlineSync = new OfflineSyncService(api, offlineStore);
400          var connection = new ConnectionStateService(js);
401          await connection.OnConnectionChanged(false); // Simulate offline
402          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
403  
404          // Save a stale infil snapshot whose timer lapsed 31 minutes ago.
405          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Foxtrot",
406              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-31)));
407  
408          var (data, error) = await service.GetAsync(operatorId);
409  
410          Assert.Null(error);
411          Assert.NotNull(data);
412          Assert.Equal("Infil", data.CurrentMode);    // Local snapshot returned (offline fallback)
413          Assert.Equal(0, handler.GetOperatorCount);  // Server NOT queried while offline
414      }
415  
416      [Fact]
417      public async Task OperatorService_CompleteInfilAsync_WhenOnlineWithSnapshotAndNoPendingOfflineExfil_CallsServerApi()
418      {
419          // Regression test: after an online combat victory, the player clicks Exfil on the infil
420          // screen. An infil snapshot exists (saved at infil start) but there is no queued offline
421          // exfil. The old code returned early from SyncAndFinalizeAsync without calling the server,
422          // leaving the operator in Infil mode and causing OperatorDetails to redirect back to infil.
423          var operatorId = Guid.Parse("a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1");
424          var js = new FakeBrowserJsRuntime();
425          var handler = new InfilCompleteHandler();
426          using var http = new HttpClient(handler);
427          var nodeService = new NodeConnectionService(js);
428          await nodeService.SetBaseUrlAsync("https://node.example.com");
429          var auth = new AuthService(js, http, nodeService);
430          var api = new ApiClient(http, nodeService, auth);
431          var offlineStore = new BrowserOfflineStore(js);
432          var combatStore = new BrowserCombatSessionStore(js);
433          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
434          var offlineSync = new OfflineSyncService(api, offlineStore);
435          var connection = new ConnectionStateService(js); // IsOnline defaults to true
436          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
437  
438          // Simulate an infil snapshot saved at infil start (always present for online infil).
439          // No pending offline combat envelopes and no queued offline exfil — this is the online path.
440          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Ghost",
441              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-5)));
442  
443          var error = await service.CompleteInfilAsync(operatorId);
444  
445          Assert.Null(error);
446          Assert.Equal(1, handler.InfilCompleteCount); // Server API must be called
447      }
448  
449      [Fact]
450      public async Task OperatorService_ClearActiveCombatSessionAsync_ClearsActiveCombatSessionIdFromLocalSnapshot()
451      {
452          // Regression: after an online session concludes, the local snapshot still holds the
453          // ActiveCombatSessionId. If the user goes offline before navigating away, MissionInfil
454          // would treat the stale ID as an active session and redirect in an infinite loop.
455          // ClearActiveCombatSessionAsync must clear the ID so HasActiveCombat returns false offline.
456          var operatorId = Guid.Parse("b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2");
457          var sessionId = Guid.Parse("c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3");
458          var js = new FakeBrowserJsRuntime();
459          var handler = new FailingHandler();
460          using var http = new HttpClient(handler);
461          var nodeService = new NodeConnectionService(js);
462          await nodeService.SetBaseUrlAsync("https://node.example.com");
463          var auth = new AuthService(js, http, nodeService);
464          var api = new ApiClient(http, nodeService, auth);
465          var offlineStore = new BrowserOfflineStore(js);
466          var combatStore = new BrowserCombatSessionStore(js);
467          var offlineGameplay = new OfflineGameplayService(combatStore, offlineStore);
468          var offlineSync = new OfflineSyncService(api, offlineStore);
469          var connection = new ConnectionStateService(js);
470          var service = new OperatorService(api, offlineStore, offlineGameplay, offlineSync, connection);
471  
472          // Save a snapshot with an active combat session ID (as happens when combat starts online)
473          await offlineStore.SaveInfiledOperatorAsync(CreateOperator(operatorId, "Hawk",
474              infilStartTime: DateTimeOffset.UtcNow.AddMinutes(-5)));
475          await offlineStore.UpdateActiveCombatSessionIdAsync(operatorId, sessionId);
476  
477          // Verify the session ID is present before clearing
478          var before = await offlineStore.GetInfiledOperatorAsync(operatorId);
479          Assert.Equal(sessionId, before?.ActiveCombatSessionId);
480  
481          await service.ClearActiveCombatSessionAsync(operatorId);
482  
483          // After clearing, HasActiveCombat must return false so MissionInfil won't redirect offline
484          var after = await offlineStore.GetInfiledOperatorAsync(operatorId);
485          Assert.NotNull(after);
486          Assert.Null(after.ActiveCombatSessionId);
487          Assert.False(OperatorNavigationHelper.HasActiveCombat(after));
488      }
489  
490      private static OperatorState CreateOperator(Guid id, string name, DateTimeOffset? infilStartTime = null) => new()
491      {
492          Id = id,
493          Name = name,
494          CurrentMode = "Infil",
495          CurrentHealth = 100,
496          MaxHealth = 100,
497          EquippedWeaponName = "Rifle",
498          UnlockedPerks = [],
499          InfilStartTime = infilStartTime
500      };
501  
502      private sealed class FailingHandler : HttpMessageHandler
503      {
504          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
505              throw new HttpRequestException("offline");
506      }
507  
508      private sealed class DelayedSyncHandler : HttpMessageHandler
509      {
510          public int OfflineSyncPostCount { get; private set; }
511  
512          protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
513          {
514              if (request.Method == HttpMethod.Post && request.RequestUri?.AbsolutePath == "/operators/offline/sync")
515              {
516                  OfflineSyncPostCount++;
517                  await Task.Delay(50, cancellationToken);
518                  return new HttpResponseMessage(HttpStatusCode.OK)
519                  {
520                      Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
521                  };
522              }
523  
524              if (request.Method == HttpMethod.Get && request.RequestUri?.AbsolutePath.StartsWith("/operators/", StringComparison.Ordinal) == true)
525              {
526                  var op = new OperatorState
527                  {
528                      Id = SyncOperatorId,
529                      Name = "Offline Op",
530                      CurrentMode = "Infil",
531                      CurrentHealth = 100,
532                      MaxHealth = 100,
533                      EquippedWeaponName = "Rifle",
534                      UnlockedPerks = []
535                  };
536                  return new HttpResponseMessage(HttpStatusCode.OK)
537                  {
538                      Content = new StringContent(JsonSerializer.Serialize(op), Encoding.UTF8, "application/json")
539                  };
540              }
541  
542              return new HttpResponseMessage(HttpStatusCode.OK)
543              {
544                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
545              };
546          }
547      }
548  
549      private sealed class StartCombatHandler : HttpMessageHandler
550      {
551          public Guid SessionId { get; } = Guid.Parse("33333333-3333-3333-3333-333333333333");
552          public int StartCombatCount { get; private set; }
553          public string? LastRequestPath { get; private set; }
554  
555          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
556          {
557              LastRequestPath = request.RequestUri?.AbsolutePath;
558  
559              if (request.Method == HttpMethod.Post &&
560                  request.RequestUri?.AbsolutePath.EndsWith("/infil/combat", StringComparison.Ordinal) == true)
561              {
562                  StartCombatCount++;
563                  return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
564                  {
565                      Content = JsonContent.Create(SessionId)
566                  });
567              }
568  
569              return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
570              {
571                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
572              });
573          }
574      }
575  
576      private sealed class GetBaseOperatorHandler : HttpMessageHandler
577      {
578          private readonly Guid _operatorId;
579          public int GetOperatorCount { get; private set; }
580  
581          public GetBaseOperatorHandler(Guid operatorId) => _operatorId = operatorId;
582  
583          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
584          {
585              if (request.Method == HttpMethod.Get &&
586                  request.RequestUri?.AbsolutePath == $"/operators/{_operatorId}")
587              {
588                  GetOperatorCount++;
589                  var op = new OperatorState
590                  {
591                      Id = _operatorId,
592                      Name = "Delta",
593                      CurrentMode = "Base",
594                      CurrentHealth = 100,
595                      MaxHealth = 100,
596                      EquippedWeaponName = string.Empty,
597                      UnlockedPerks = []
598                  };
599                  return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
600                  {
601                      Content = JsonContent.Create(op)
602                  });
603              }
604  
605              return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
606              {
607                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
608              });
609          }
610      }
611  
612      private sealed class GetInfilOperatorHandler : HttpMessageHandler
613      {
614          private readonly Guid _operatorId;
615          private readonly string _name;
616          public int GetOperatorCount { get; private set; }
617  
618          public GetInfilOperatorHandler(Guid operatorId, string name)
619          {
620              _operatorId = operatorId;
621              _name = name;
622          }
623  
624          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
625          {
626              if (request.Method == HttpMethod.Get &&
627                  request.RequestUri?.AbsolutePath == $"/operators/{_operatorId}")
628              {
629                  GetOperatorCount++;
630                  var op = CreateOperator(_operatorId, _name, DateTimeOffset.UtcNow.AddMinutes(-4));
631                  return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
632                  {
633                      Content = JsonContent.Create(op)
634                  });
635              }
636  
637              return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
638              {
639                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
640              });
641          }
642      }
643  
644      private sealed class ErrorOperatorHandler : HttpMessageHandler
645      {
646          private readonly HttpStatusCode _statusCode;
647          public int GetOperatorCount { get; private set; }
648  
649          public ErrorOperatorHandler(HttpStatusCode statusCode) => _statusCode = statusCode;
650  
651          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
652          {
653              if (request.Method == HttpMethod.Get &&
654                  request.RequestUri?.AbsolutePath.StartsWith("/operators/", StringComparison.Ordinal) == true)
655              {
656                  GetOperatorCount++;
657                  return Task.FromResult(new HttpResponseMessage(_statusCode)
658                  {
659                      Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
660                  });
661              }
662  
663              return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
664              {
665                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
666              });
667          }
668      }
669  
670      private sealed class CountingGetHandler : HttpMessageHandler
671      {
672          public int GetOperatorCount { get; private set; }
673  
674          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
675          {
676              if (request.Method == HttpMethod.Get &&
677                  request.RequestUri?.AbsolutePath.StartsWith("/operators/", StringComparison.Ordinal) == true &&
678                  !request.RequestUri.AbsolutePath.EndsWith("/operators/", StringComparison.Ordinal))
679              {
680                  GetOperatorCount++;
681              }
682  
683              return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
684              {
685                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
686              });
687          }
688      }
689  
690      private sealed class InfilCompleteHandler : HttpMessageHandler
691      {
692          public int InfilCompleteCount { get; private set; }
693  
694          protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
695          {
696              if (request.Method == HttpMethod.Post &&
697                  request.RequestUri?.AbsolutePath.EndsWith("/infil/complete", StringComparison.Ordinal) == true)
698              {
699                  InfilCompleteCount++;
700                  return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
701                  {
702                      Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
703                  });
704              }
705  
706              return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
707              {
708                  Content = new StringContent(string.Empty, Encoding.UTF8, "application/json")
709              });
710          }
711      }
712  
713      private sealed class FakeBrowserJsRuntime : IJSRuntime
714      {
715          private readonly bool _throwOnGunRpgStorage;
716          private readonly Dictionary<string, object?> _localStorage = new(StringComparer.Ordinal);
717          private readonly Dictionary<string, object?> _tokens = new(StringComparer.Ordinal);
718          private readonly Dictionary<string, BrowserOfflineStore.BrowserMetadataRecord> _metadata = new(StringComparer.Ordinal);
719          private readonly Dictionary<string, BrowserOfflineStore.BrowserInfiledOperatorRecord> _infiledOperators = new(StringComparer.Ordinal);
720          private readonly Dictionary<string, OfflineMissionEnvelope> _missionResults = new(StringComparer.Ordinal);
721  
722          public FakeBrowserJsRuntime(bool throwOnGunRpgStorage = false)
723          {
724              _throwOnGunRpgStorage = throwOnGunRpgStorage;
725          }
726  
727          public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args) => InvokeAsync<TValue>(identifier, CancellationToken.None, args);
728  
729          public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
730          {
731              if (_throwOnGunRpgStorage && identifier.StartsWith("gunRpgStorage.", StringComparison.Ordinal))
732                  throw new JSException("Storage unavailable.");
733  
734              object? value = identifier switch
735              {
736                  "localStorage.getItem" => GetDictionaryValue(_localStorage, Convert.ToString(args?[0])),
737                  "localStorage.setItem" => SetDictionaryValue(_localStorage, Convert.ToString(args?[0]), args?[1]),
738                  "localStorage.removeItem" => RemoveDictionaryValue(_localStorage, Convert.ToString(args?[0])),
739                  "tokenStorage.storeAccessToken" => SetDictionaryValue(_tokens, "accessToken", args?[0]),
740                  "tokenStorage.getAccessToken" => GetDictionaryValue(_tokens, "accessToken"),
741                  "tokenStorage.removeAccessToken" => RemoveDictionaryValue(_tokens, "accessToken"),
742                  "tokenStorage.storeRefreshToken" => SetDictionaryValue(_tokens, "refreshToken", args?[0]),
743                  "tokenStorage.getRefreshToken" => GetDictionaryValue(_tokens, "refreshToken"),
744                  "tokenStorage.removeRefreshToken" => RemoveDictionaryValue(_tokens, "refreshToken"),
745                  "tokenStorage.clearTokens" => ClearTokens(),
746                  "gunRpgStorage.saveInfiledOperator" => SaveInfiledOperator((BrowserOfflineStore.BrowserInfiledOperatorRecord)args![0]!),
747                  "gunRpgStorage.getInfiledOperator" => GetDictionaryValue(_infiledOperators, Convert.ToString(args?[0])),
748                  "gunRpgStorage.getActiveInfiledOperator" => _infiledOperators.Values.FirstOrDefault(x => x.IsActive),
749                  "gunRpgStorage.hasActiveInfiledOperator" => _infiledOperators.Values.Any(x => x.IsActive),
750                  "gunRpgStorage.updateInfiledOperator" => SaveOrUpdateInfiledOperator((BrowserOfflineStore.BrowserInfiledOperatorRecord)args![0]!),
751                  "gunRpgStorage.removeInfiledOperator" => RemoveDictionaryValue(_infiledOperators, Convert.ToString(args?[0])),
752                  "gunRpgStorage.saveOfflineMissionResult" => SaveMissionResult((OfflineMissionEnvelope)args![0]!),
753                  "gunRpgStorage.getOfflineMissionResult" => GetDictionaryValue(_missionResults, Convert.ToString(args?[0])),
754                  "gunRpgStorage.getAllOfflineMissionResults" => _missionResults.Values.OrderBy(x => x.SequenceNumber).ToList(),
755                  "gunRpgStorage.putValue" => PutMetadata(Convert.ToString(args?[1]), (BrowserOfflineStore.BrowserMetadataRecord)args![2]!),
756                  "gunRpgStorage.getValue" => GetMetadata(Convert.ToString(args?[1])),
757                  "gunRpgStorage.deleteValue" => DeleteMetadata(Convert.ToString(args?[1])),
758                  "gunRpgStorage.getAllValues" => _metadata.Values.ToList(),
759                  _ => throw new NotSupportedException(identifier)
760              };
761  
762              return new ValueTask<TValue>((TValue?)value ?? default!);
763          }
764  
765          private static object? SetDictionaryValue(IDictionary<string, object?> dictionary, string? key, object? value)
766          {
767              if (!string.IsNullOrEmpty(key))
768                  dictionary[key] = value;
769              return null;
770          }
771  
772          private static object? RemoveDictionaryValue<TValue>(IDictionary<string, TValue> dictionary, string? key)
773          {
774              if (!string.IsNullOrEmpty(key))
775                  dictionary.Remove(key);
776              return null;
777          }
778  
779          private static object? GetDictionaryValue<TValue>(IReadOnlyDictionary<string, TValue> dictionary, string? key)
780          {
781              return !string.IsNullOrEmpty(key) && dictionary.TryGetValue(key, out var value) ? value : default;
782          }
783  
784          private object? ClearTokens()
785          {
786              _tokens.Clear();
787              return null;
788          }
789  
790          private object? SaveInfiledOperator(BrowserOfflineStore.BrowserInfiledOperatorRecord record)
791          {
792              foreach (var existing in _infiledOperators.Values)
793                  existing.IsActive = false;
794              _infiledOperators[record.Id] = record;
795              return null;
796          }
797  
798          private object? SaveOrUpdateInfiledOperator(BrowserOfflineStore.BrowserInfiledOperatorRecord record)
799          {
800              _infiledOperators[record.Id] = record;
801              return null;
802          }
803  
804          private object? SaveMissionResult(OfflineMissionEnvelope envelope)
805          {
806              _missionResults[envelope.Id] = envelope;
807              return null;
808          }
809  
810          private object? PutMetadata(string? key, BrowserOfflineStore.BrowserMetadataRecord record)
811          {
812              if (!string.IsNullOrEmpty(key))
813                  _metadata[key] = record;
814              return null;
815          }
816  
817          private BrowserOfflineStore.BrowserMetadataRecord? GetMetadata(string? key) =>
818              !string.IsNullOrEmpty(key) && _metadata.TryGetValue(key, out var record) ? record : null;
819  
820          private object? DeleteMetadata(string? key)
821          {
822              if (!string.IsNullOrEmpty(key))
823                  _metadata.Remove(key);
824              return null;
825          }
826      }
827  }