|
| 1 | +using Microsoft.Data.Sqlite; |
| 2 | +using Microsoft.EntityFrameworkCore; |
| 3 | +using Microsoft.Extensions.DependencyInjection; |
| 4 | +using Microsoft.Extensions.Options; |
| 5 | +using NSubstitute; |
| 6 | +using RustPlusBot.Abstractions.Connections; |
| 7 | +using RustPlusBot.Abstractions.Credentials; |
| 8 | +using RustPlusBot.Abstractions.Events; |
| 9 | +using RustPlusBot.Abstractions.Time; |
| 10 | +using RustPlusBot.Discord.Notifications; |
| 11 | +using RustPlusBot.Domain.Credentials; |
| 12 | +using RustPlusBot.Domain.Servers; |
| 13 | +using RustPlusBot.Features.Connections.Listening; |
| 14 | +using RustPlusBot.Features.Connections.Supervisor; |
| 15 | +using RustPlusBot.Features.Connections.Tests.Fakes; |
| 16 | +using RustPlusBot.Persistence; |
| 17 | +using RustPlusBot.Persistence.Alarms; |
| 18 | +using RustPlusBot.Persistence.Connections; |
| 19 | +using RustPlusBot.Persistence.Servers; |
| 20 | +using RustPlusBot.Persistence.StorageMonitors; |
| 21 | +using RustPlusBot.Persistence.Switches; |
| 22 | + |
| 23 | +namespace RustPlusBot.Features.Connections.Tests; |
| 24 | + |
| 25 | +public sealed class SwitchPrimingTests |
| 26 | +{ |
| 27 | + private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMemoryEventBus Bus) CreateHarness( |
| 28 | + FakeRustSocketSource source) |
| 29 | + { |
| 30 | + var protector = Substitute.For<ICredentialProtector>(); |
| 31 | + protector.Unprotect(Arg.Any<string>()).Returns(c => c.Arg<string>()); |
| 32 | + var dm = Substitute.For<IUserDmSender>(); |
| 33 | + var clock = Substitute.For<IClock>(); |
| 34 | + clock.UtcNow.Returns(DateTimeOffset.UnixEpoch); |
| 35 | + var bus = new InMemoryEventBus(); |
| 36 | + |
| 37 | + var services = new ServiceCollection(); |
| 38 | + services.AddLogging(); |
| 39 | + services.AddSingleton(clock); |
| 40 | + services.AddSingleton(protector); |
| 41 | + services.AddSingleton(dm); |
| 42 | + services.AddSingleton<IEventBus>(bus); |
| 43 | + |
| 44 | + var cs = $"DataSource=switchpriming-{Guid.NewGuid():N};Mode=Memory;Cache=Shared"; |
| 45 | + var keepAlive = new SqliteConnection(cs); |
| 46 | + keepAlive.Open(); |
| 47 | + using (var seed = new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(cs).Options)) |
| 48 | + { |
| 49 | + seed.Database.Migrate(); |
| 50 | + } |
| 51 | + |
| 52 | + services.AddSingleton(keepAlive); |
| 53 | + services.AddScoped(_ => new BotDbContext(new DbContextOptionsBuilder<BotDbContext>().UseSqlite(cs).Options)); |
| 54 | + services.AddScoped<IConnectionStore, ConnectionStore>(); |
| 55 | + services.AddScoped<IServerService, ServerService>(); |
| 56 | + services.AddScoped<ISwitchStore, SwitchStore>(); |
| 57 | + services.AddScoped<IAlarmStore, AlarmStore>(); |
| 58 | + services.AddScoped<IStorageMonitorStore, StorageMonitorStore>(); |
| 59 | + services.AddSingleton<IRustSocketSource>(source); |
| 60 | + services.AddSingleton(Options.Create(new ConnectionOptions |
| 61 | + { |
| 62 | + ConnectTimeout = TimeSpan.FromSeconds(1), |
| 63 | + InitialRetryDelay = TimeSpan.FromMilliseconds(5), |
| 64 | + MaxRetryDelay = TimeSpan.FromMilliseconds(20), |
| 65 | + HeartbeatInterval = TimeSpan.FromMilliseconds(20), |
| 66 | + HeartbeatTimeout = TimeSpan.FromMilliseconds(200), |
| 67 | + })); |
| 68 | + services.AddSingleton<ConnectionSecurity>(); |
| 69 | + services.AddSingleton<ConnectionSupervisor>(); |
| 70 | + |
| 71 | + var provider = services.BuildServiceProvider(); |
| 72 | + return (provider, provider.GetRequiredService<ConnectionSupervisor>(), bus); |
| 73 | + } |
| 74 | + |
| 75 | + private static async Task<Guid> SeedServerWithActiveAndSwitchAsync(ServiceProvider provider, ulong entityId) |
| 76 | + { |
| 77 | + using var scope = provider.CreateScope(); |
| 78 | + var ctx = scope.ServiceProvider.GetRequiredService<BotDbContext>(); |
| 79 | + var server = new RustServer |
| 80 | + { |
| 81 | + GuildId = 10UL, Name = "S", Ip = "1.1.1.1", Port = 28015 |
| 82 | + }; |
| 83 | + ctx.RustServers.Add(server); |
| 84 | + ctx.PlayerCredentials.Add(new PlayerCredential |
| 85 | + { |
| 86 | + GuildId = 10UL, |
| 87 | + RustServerId = server.Id, |
| 88 | + OwnerUserId = 1UL, |
| 89 | + SteamId = 555UL, |
| 90 | + ProtectedPlayerToken = "123", |
| 91 | + Status = CredentialStatus.Active, |
| 92 | + }); |
| 93 | + await ctx.SaveChangesAsync(); |
| 94 | + var store = scope.ServiceProvider.GetRequiredService<ISwitchStore>(); |
| 95 | + await store.AddAsync(10UL, server.Id, entityId, $"Switch {entityId}", 1UL); |
| 96 | + return server.Id; |
| 97 | + } |
| 98 | + |
| 99 | + private static async Task WaitUntilAsync(Func<bool> condition, CancellationToken ct) |
| 100 | + { |
| 101 | + while (!condition()) |
| 102 | + { |
| 103 | + ct.ThrowIfCancellationRequested(); |
| 104 | + await Task.Delay(10, ct); |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + [Fact] |
| 109 | + public async Task Prime_RemovedSwitch_PublishesRemovedReachability_AndNoActiveState() |
| 110 | + { |
| 111 | + // Arrange: a managed switch exists; the fake connection returns a Removed reading for it. |
| 112 | + var source = new FakeRustSocketSource(); |
| 113 | + var (provider, supervisor, bus) = CreateHarness(source); |
| 114 | + await using var disposeProvider = provider; |
| 115 | + var serverId = await SeedServerWithActiveAndSwitchAsync(provider, entityId: 42UL); |
| 116 | + |
| 117 | + // Configure the fake's reachability override so GetSmartDeviceInfoAsync returns Removed for entity 42. |
| 118 | + // This must be staged before EnsureConnectionAsync so it's in place when the prime loop runs. |
| 119 | + // FakeConnection is created inside Create(), so we stage via the source's pre-connect hook, but |
| 120 | + // DeviceReachabilityOverrides is on FakeConnection — we set it after the first Create() via |
| 121 | + // LastConnection. However, Create() fires during EnsureConnectionAsync (inside the background task), |
| 122 | + // so we can't access LastConnection before EnsureConnectionAsync completes. Instead we use |
| 123 | + // a connect-outcome hook: stage Connected so the connection is created; then wait for HasLiveSocket. |
| 124 | + // |
| 125 | + // To avoid the race, we subscribe to DeviceReachabilityChangedEvent before connecting and |
| 126 | + // set the override on the source's pending-storage approach is not available for switches. |
| 127 | + // The brief says to use DeviceReachabilityOverrides — set it before EnsureConnectionAsync |
| 128 | + // by using a custom FakeRustSocketSource subclass or by seeding it via a staging dictionary. |
| 129 | + // |
| 130 | + // The cleanest race-free approach: subscribe first, then EnsureConnectionAsync, then wait |
| 131 | + // for HasLiveSocket, then check published events — but priming fires BEFORE HasLiveSocket |
| 132 | + // check is possible via polling. We use the same approach as AlarmPrimingTests: subscribe |
| 133 | + // to the reachability event (which fires only after the new prime code), wait for it, then |
| 134 | + // assert absence of SmartDeviceTriggeredEvent{IsActive:false}. |
| 135 | + // |
| 136 | + // To stage the override before prime: add a pre-stage dictionary on FakeRustSocketSource |
| 137 | + // analogous to _pendingStorageContents (which transfers to FakeConnection at Create time). |
| 138 | + // However the brief says the fake already has DeviceReachabilityOverrides and to "use it". |
| 139 | + // Per the brief Task 4 note: "Read that fake to learn its exact API." |
| 140 | + // FakeRustSocketSource already has _pendingStorageContents transferred at Create time; |
| 141 | + // FakeConnection.DeviceReachabilityOverrides exists but has no pre-stage path in the source. |
| 142 | + // We add a minimal PendingDeviceReachabilityOverrides staging dict to FakeRustSocketSource |
| 143 | + // (transferred at Create time, like _pendingStorageContents) — documented in the report. |
| 144 | + source.StageDeviceReachability(42UL, DeviceReachability.Removed); |
| 145 | + |
| 146 | + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); |
| 147 | + |
| 148 | + // Capture all published events on two channels. |
| 149 | + var reachabilityEvents = new System.Collections.Concurrent.ConcurrentQueue<DeviceReachabilityChangedEvent>(); |
| 150 | + var triggeredEvents = new System.Collections.Concurrent.ConcurrentQueue<SmartDeviceTriggeredEvent>(); |
| 151 | + |
| 152 | + var reachSub = bus.SubscribeAsync<DeviceReachabilityChangedEvent>(cts.Token); |
| 153 | + var trigSub = bus.SubscribeAsync<SmartDeviceTriggeredEvent>(cts.Token); |
| 154 | + |
| 155 | + _ = Task.Run(async () => |
| 156 | + { |
| 157 | + await foreach (var e in reachSub) |
| 158 | + { |
| 159 | + reachabilityEvents.Enqueue(e); |
| 160 | + } |
| 161 | + }, CancellationToken.None); |
| 162 | + |
| 163 | + _ = Task.Run(async () => |
| 164 | + { |
| 165 | + await foreach (var e in trigSub) |
| 166 | + { |
| 167 | + triggeredEvents.Enqueue(e); |
| 168 | + } |
| 169 | + }, CancellationToken.None); |
| 170 | + |
| 171 | + // Act: drive the connect/prime path. |
| 172 | + await supervisor.EnsureConnectionAsync(10UL, serverId, cts.Token); |
| 173 | + |
| 174 | + // Wait for the reachability event for entity 42 to be published (definite signal that prime completed). |
| 175 | + await WaitUntilAsync(() => reachabilityEvents.Any(e => e.EntityId == 42UL), cts.Token); |
| 176 | + |
| 177 | + // Give a moment for any spurious SmartDeviceTriggeredEvent to appear if incorrectly published. |
| 178 | + await Task.Delay(TimeSpan.FromMilliseconds(100), cts.Token); |
| 179 | + |
| 180 | + // Assert: DeviceReachabilityChangedEvent{EntityId=42, Reachability=Removed} was published. |
| 181 | + Assert.Contains(reachabilityEvents, e => |
| 182 | + e is { EntityId: 42UL, Reachability: DeviceReachability.Removed }); |
| 183 | + |
| 184 | + // Assert: SmartDeviceTriggeredEvent{EntityId=42, IsActive=false} was NOT published. |
| 185 | + Assert.DoesNotContain(triggeredEvents, e => |
| 186 | + e is { EntityId: 42UL, IsActive: false }); |
| 187 | + |
| 188 | + await supervisor.StopAllAsync(); |
| 189 | + } |
| 190 | +} |
0 commit comments