feature: In-App Simulation Runner (Backend)
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 8m32s

This commit is contained in:
voltsrage
2026-08-06 01:52:53 +08:00
parent 943d41339c
commit 24f45851e9
83 changed files with 3974 additions and 120 deletions
@@ -0,0 +1,152 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
[Collection("Integration")]
public class SimulationEndpointTests : IAsyncLifetime
{
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
public SimulationEndpointTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
}
public async Task InitializeAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
_fixture.SimulationClientFactory.HangOnCreate = false;
_fixture.SimulationClientFactory.FailOnCreate = false;
_client.DefaultRequestHeaders.Remove("X-Test-Role");
_client.DefaultRequestHeaders.Remove("X-Test-User-Id");
_client.AsAdmin();
}
public Task DisposeAsync() => Task.CompletedTask;
[Fact]
public async Task Config_ReturnsEnabledFalse_WhenDisabled()
{
using var factory = _fixture.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Simulation:Enabled"] = "false",
});
});
});
var client = factory.CreateClient();
client.AsAdmin();
var resp = await client.GetAsync("/api/v1/simulation/config");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("data").GetProperty("enabled").GetBoolean().Should().BeFalse();
}
[Fact]
public async Task Scenarios_WhenDisabled_Returns404()
{
using var factory = _fixture.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Simulation:Enabled"] = "false",
});
});
});
var client = factory.CreateClient();
client.AsAdmin();
var resp = await client.GetAsync("/api/v1/simulation/scenarios");
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Scenarios_AsNurse_Returns200()
{
_client.AsNurse();
var resp = await _client.GetAsync("/api/v1/simulation/scenarios");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var items = body.GetProperty("data");
items.GetArrayLength().Should().BeGreaterThan(0);
items[0].GetProperty("id").GetString().Should().Be("minimal-sim-01");
}
[Fact]
public async Task StartRun_AsIntegrationRole_Returns403()
{
_client.AsIntegration();
var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new
{
scenarioId = "minimal-sim-01",
speed = 600
});
resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}
[Fact]
public async Task StartRun_WritesAuditLog()
{
_client.AsPhysician();
var resp = await _client.PostAsJsonAsync("/api/v1/simulation/runs", new
{
scenarioId = "minimal-sim-01",
speed = 600
});
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var runId = body.GetProperty("data").GetProperty("runId").GetGuid();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var audit = await db.ClinicalAuditLogs
.Where(a => a.Action == AuditAction.SimulationRunStarted && a.EntityId == runId)
.SingleOrDefaultAsync();
audit.Should().NotBeNull();
audit!.EntityType.Should().Be("SimulationRun");
}
[Fact]
public async Task Config_WhenEnabled_ReturnsLimits()
{
_client.AsNurse();
var resp = await _client.GetAsync("/api/v1/simulation/config");
resp.StatusCode.Should().Be(HttpStatusCode.OK);
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
var data = body.GetProperty("data");
data.GetProperty("enabled").GetBoolean().Should().BeTrue();
data.GetProperty("maxSpeed").GetDouble().Should().Be(600);
data.GetProperty("maxConcurrentRuns").GetInt32().Should().Be(2);
}
}
@@ -0,0 +1,197 @@
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<AppDbContext>();
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
await DbResetHelper.ResetAsync(db);
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
_runner = _fixture.Services.GetRequiredService<ISimulationRunner>();
_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<ValidationException>();
ex.Which.ErrorCode.Should().Be("SIMULATION_SCENARIO_UNKNOWN");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
(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<ValidationException>();
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<ConflictException>();
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<AppDbContext>();
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<AppDbContext>();
(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<SimulationRunner>();
await hosted.StopAsync(CancellationToken.None);
var after = await WaitForRunAsync(state.RunId, SimulationRunStatus.Cancelled);
after.Status.Should().Be(SimulationRunStatus.Cancelled);
}
private async Task<SimulationRunState> 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<bool> 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.");
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Mvc.Testing;
using VigilCare.Simulation;
/// <summary>
/// Loopback client for WebApplicationFactory tests. Uses X-Test-Role INTEGRATION
/// instead of JWT login (Testing auth scheme ignores Bearer tokens).
/// Uses Server.CreateHandler() to avoid TestServer re-entrancy deadlocks.
/// </summary>
public sealed class TestSimulationClientFactory : ISimulationClientFactory
{
private readonly WebApplicationFactory<Program> _factory;
public TestSimulationClientFactory(WebApplicationFactory<Program> factory) =>
_factory = factory;
/// <summary>When true, CreateAsync blocks until cancelled — for concurrency/shutdown tests.</summary>
public bool HangOnCreate { get; set; }
/// <summary>When true, CreateAsync throws — for failure-path tests.</summary>
public bool FailOnCreate { get; set; }
public async Task<VigilCareApiClient> CreateAsync(CancellationToken ct = default)
{
if (FailOnCreate)
throw new HttpRequestException("Login failed (401 Unauthorized): invalid credentials");
if (HangOnCreate)
await Task.Delay(Timeout.Infinite, ct);
var http = new HttpClient(_factory.Server.CreateHandler(), disposeHandler: true)
{
BaseAddress = _factory.Server.BaseAddress ?? new Uri("http://localhost"),
};
http.DefaultRequestHeaders.Add("X-Test-Role", "INTEGRATION");
return new VigilCareApiClient(http);
}
}