using System.Net; using System.Net.Http.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using StackExchange.Redis; [Collection("Integration")] public class SimulationRunnerTests : IAsyncLifetime { private readonly ApiFixture _fixture; private ISimulationRunner _runner = null!; private TestSimulationClientFactory _clientFactory = null!; public SimulationRunnerTests(ApiFixture fixture) => _fixture = fixture; public async Task InitializeAsync() { using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var redis = scope.ServiceProvider.GetRequiredService(); await DbResetHelper.ResetAsync(db); await DataSeeder.SeedThresholdsOnlyAsync(db, redis); _runner = _fixture.Services.GetRequiredService(); _clientFactory = _fixture.SimulationClientFactory; _clientFactory.HangOnCreate = false; _clientFactory.FailOnCreate = false; foreach (var run in _runner.ListRuns()) _runner.Cancel(run.RunId); } public Task DisposeAsync() { _clientFactory.HangOnCreate = false; _clientFactory.FailOnCreate = false; return Task.CompletedTask; } [Fact] public async Task Start_UnknownScenario_Returns422() { var act = () => _runner.StartAsync("does-not-exist", 60, "tester", CancellationToken.None); var ex = await act.Should().ThrowAsync(); ex.Which.ErrorCode.Should().Be("SIMULATION_SCENARIO_UNKNOWN"); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); (await db.SimulationRuns.CountAsync()).Should().Be(0); } [Fact] public async Task Start_SpeedAboveMax_Returns422() { var act = () => _runner.StartAsync("minimal-sim-01", 601, "tester", CancellationToken.None); var ex = await act.Should().ThrowAsync(); ex.Which.ErrorCode.Should().Be("SIMULATION_SPEED_INVALID"); } [Fact] public async Task Start_AtConcurrencyLimit_Returns409() { _clientFactory.HangOnCreate = true; await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); var act = () => _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); var ex = await act.Should().ThrowAsync(); ex.Which.ErrorCode.Should().Be("SIMULATION_CONCURRENCY_LIMIT"); foreach (var run in _runner.ListRuns()) _runner.Cancel(run.RunId); await WaitForAsync(() => _runner.ListRuns().All(r => r.Status is SimulationRunStatus.Cancelled or SimulationRunStatus.Failed or SimulationRunStatus.Completed)); } [Fact] public async Task Start_CreatesPatientMarkedSimulated() { var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed); completed.PatientId.Should().NotBeNull(); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var patient = await db.Patients.SingleAsync(p => p.Id == completed.PatientId); patient.IsSimulated.Should().BeTrue(); } [Fact] public async Task Run_ProgressAdvances() { var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); await WaitForAsync(() => { var current = _runner.GetRun(state.RunId); return current is not null && current.LastOffsetMinutes > 0; }); var mid = _runner.GetRun(state.RunId)!; mid.LastOffsetMinutes.Should().BeGreaterThan(0); var completed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed); completed.ProgressPercent.Should().Be(100); completed.ObservationsSent.Should().Be(4); } [Fact] public async Task Stop_CancelsRun_StatusCancelled() { _clientFactory.HangOnCreate = true; var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); _runner.Cancel(state.RunId).Should().BeTrue(); var cancelled = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled); cancelled.Status.Should().Be(SimulationRunStatus.Cancelled); using var scope = _fixture.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); (await db.Observations.CountAsync()).Should().Be(0); } [Fact] public async Task Stop_AlreadyCompleted_IsNoOpSuccess() { var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); await WaitForRunAsync(state.RunId, SimulationRunStatus.Completed); _runner.Cancel(state.RunId).Should().BeTrue(); var after = _runner.GetRun(state.RunId)!; after.Status.Should().Be(SimulationRunStatus.Completed); } [Fact] public async Task Run_Failure_RecordsFailureReason() { _clientFactory.FailOnCreate = true; var state = await _runner.StartAsync("minimal-sim-01", 600, "tester", CancellationToken.None); var failed = await WaitForRunAsync(state.RunId, SimulationRunStatus.Failed); failed.FailureReason.Should().NotBeNullOrWhiteSpace(); failed.FailureReason.Should().Contain("Login failed"); } [Fact] public async Task Shutdown_CancelsActiveRuns() { _clientFactory.HangOnCreate = true; var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); var hosted = (IHostedService)_fixture.Services.GetRequiredService(); await hosted.StopAsync(CancellationToken.None); var after = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled); after.Status.Should().Be(SimulationRunStatus.Cancelled); } private async Task WaitForRunAsync( Guid runId, SimulationRunStatus expected, TimeSpan? timeout = null) { try { await WaitForAsync(() => _runner.GetRun(runId)?.Status == expected, timeout); } catch (TimeoutException) { var actual = _runner.GetRun(runId); throw new TimeoutException( $"Expected run {runId} status {expected}, but was {actual?.Status}. " + $"FailureReason={actual?.FailureReason}"); } return _runner.GetRun(runId)!; } private static async Task WaitForAsync(Func condition, TimeSpan? timeout = null) { var deadline = DateTimeOffset.UtcNow + (timeout ?? TimeSpan.FromSeconds(15)); while (DateTimeOffset.UtcNow < deadline) { if (condition()) return; await Task.Delay(25); } throw new TimeoutException("Condition was not met within the timeout."); } }