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