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 SimulationPurgeTests : IAsyncLifetime { private readonly ApiFixture _fixture; private readonly HttpClient _client; private ISimulationRunner _runner = null!; private TestSimulationClientFactory _clientFactory = null!; public SimulationPurgeTests(ApiFixture fixture) { _fixture = fixture; _client = fixture.CreateClient(); } 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); await WaitForAsync(() => !_runner.HasActiveRuns()); _runner.ClearRegistry(); _client.DefaultRequestHeaders.Remove("X-Test-Role"); _client.DefaultRequestHeaders.Remove("X-Test-User-Id"); _client.AsNurse(); } public Task DisposeAsync() { _clientFactory.HangOnCreate = false; _clientFactory.FailOnCreate = false; return Task.CompletedTask; } [Fact] public async Task Purge_RemovesOnlySimulatedPatients() { using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var realPatient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-REAL-001", FirstName = "Real", LastName = "Patient", DateOfBirth = new DateOnly(1975, 4, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow, IsSimulated = false, }; var realEncounter = new Encounter { Id = Guid.NewGuid(), PatientId = realPatient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Real", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }; var realObs = new Observation { Id = Guid.NewGuid(), EncounterId = realEncounter.Id, ObservationCode = "HEART_RATE", Value = 70, Unit = "bpm", Source = ObservationSource.Manual, RecordedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }; var realAlert = new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = realEncounter.Id, PatientId = realPatient.Id, AlertType = AlertType.News2Warning, Severity = AlertSeverity.Warning, Details = "Real patient alert", Status = AlertStatus.Acknowledged, TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-10), AcknowledgedAt = DateTimeOffset.UtcNow.AddMinutes(-5), AcknowledgedBy = "nurse", }; var realFeedback = new AlertFeedback { Id = Guid.NewGuid(), AlertId = realAlert.Id, UserId = Guid.NewGuid(), FeedbackType = AlertFeedbackType.Useful, Comment = "Keep me", CreatedAt = DateTimeOffset.UtcNow, }; var simPatient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-SIM-001", FirstName = "Sim", LastName = "Patient", DateOfBirth = new DateOnly(1980, 1, 1), Gender = "F", CreatedAt = DateTimeOffset.UtcNow, IsSimulated = true, }; var simEncounter = new Encounter { Id = Guid.NewGuid(), PatientId = simPatient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.GeneralMedicine, AttendingPhysician = "Dr. Sim", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }; var simObs = new Observation { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, ObservationCode = "HEART_RATE", Value = 110, Unit = "bpm", Source = ObservationSource.Manual, RecordedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }; var simAlert = new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, PatientId = simPatient.Id, AlertType = AlertType.News2Emergency, Severity = AlertSeverity.Critical, Details = "Simulated alert", Status = AlertStatus.Open, TriggeredAt = DateTimeOffset.UtcNow, }; db.Patients.AddRange(realPatient, simPatient); db.Encounters.AddRange(realEncounter, simEncounter); db.Observations.AddRange(realObs, simObs); db.ClinicalAlerts.AddRange(realAlert, simAlert); db.AlertFeedbacks.Add(realFeedback); await db.SaveChangesAsync(); } var resp = await _client.DeleteAsync("/api/v1/simulation/data"); resp.StatusCode.Should().Be(HttpStatusCode.OK); var body = await resp.Content.ReadFromJsonAsync(); var data = body.GetProperty("data"); data.GetProperty("patientsDeleted").GetInt32().Should().Be(1); data.GetProperty("encountersDeleted").GetInt32().Should().Be(1); data.GetProperty("alertsDeleted").GetInt32().Should().Be(1); using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); (await db.Patients.AnyAsync(p => p.Mrn == "MRN-SIM-001")).Should().BeFalse(); var real = await db.Patients.SingleAsync(p => p.Mrn == "MRN-REAL-001"); real.IsSimulated.Should().BeFalse(); (await db.Observations.CountAsync(o => db.Encounters.Any(e => e.Id == o.EncounterId && e.PatientId == real.Id))) .Should().Be(1); (await db.ClinicalAlerts.CountAsync(a => a.PatientId == real.Id)).Should().Be(1); (await db.AlertFeedbacks.CountAsync()).Should().Be(1); (await db.Patients.CountAsync(p => p.IsSimulated)).Should().Be(0); } } [Fact] public async Task Purge_CascadesDependentRows() { Guid simPatientId; Guid simEncounterId; using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var simPatient = new Patient { Id = Guid.NewGuid(), Mrn = "MRN-SIM-CASCADE", FirstName = "Cascade", LastName = "Sim", DateOfBirth = new DateOnly(1982, 2, 2), Gender = "M", CreatedAt = DateTimeOffset.UtcNow, IsSimulated = true, }; var simEncounter = new Encounter { Id = Guid.NewGuid(), PatientId = simPatient.Id, EncounterType = EncounterType.Inpatient, Status = EncounterStatus.Active, Department = Department.Icu, AttendingPhysician = "Dr. Cascade", AdmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }; simPatientId = simPatient.Id; simEncounterId = simEncounter.Id; db.Patients.Add(simPatient); db.Encounters.Add(simEncounter); db.Observations.Add(new Observation { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, ObservationCode = "HEART_RATE", Value = 120, Unit = "bpm", Source = ObservationSource.Manual, RecordedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }); db.News2Scores.Add(new News2Score { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, PatientId = simPatient.Id, TotalScore = 5, RiskLevel = "MEDIUM", CalculatedAt = DateTimeOffset.UtcNow, }); db.GcsScores.Add(new GcsScore { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, PatientId = simPatient.Id, EyeScore = 4, VerbalScore = 5, MotorScore = 6, TotalScore = 15, Classification = "MILD", CalculatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }); db.SofaScores.Add(new SofaScore { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, PatientId = simPatient.Id, TotalScore = 2, CalculatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }); db.QsofaEvaluations.Add(new QsofaEvaluation { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, PatientId = simPatient.Id, ActiveCriteria = 1, EvaluatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, }); var alert = new ClinicalAlert { Id = Guid.NewGuid(), EncounterId = simEncounter.Id, PatientId = simPatient.Id, AlertType = AlertType.News2Warning, Severity = AlertSeverity.Warning, Details = "Cascade alert", Status = AlertStatus.Acknowledged, TriggeredAt = DateTimeOffset.UtcNow, AcknowledgedAt = DateTimeOffset.UtcNow, AcknowledgedBy = "nurse", }; db.ClinicalAlerts.Add(alert); db.AlertFeedbacks.Add(new AlertFeedback { Id = Guid.NewGuid(), AlertId = alert.Id, UserId = Guid.NewGuid(), FeedbackType = AlertFeedbackType.Useful, CreatedAt = DateTimeOffset.UtcNow, }); db.PhiAccessLogs.Add(new PhiAccessLog { Id = Guid.NewGuid(), PatientId = simPatient.Id, AccessType = PhiAccessType.View, AccessedAt = DateTimeOffset.UtcNow, UserId = Guid.NewGuid(), UserDisplayName = "nurse", ResourcePath = "/api/v1/patients/" + simPatient.Id, }); await db.SaveChangesAsync(); } var resp = await _client.DeleteAsync("/api/v1/simulation/data"); resp.StatusCode.Should().Be(HttpStatusCode.OK); using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); (await db.Patients.AnyAsync(p => p.Id == simPatientId)).Should().BeFalse(); (await db.Encounters.AnyAsync(e => e.Id == simEncounterId)).Should().BeFalse(); (await db.Observations.AnyAsync(o => o.EncounterId == simEncounterId)).Should().BeFalse(); (await db.News2Scores.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse(); (await db.GcsScores.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse(); (await db.SofaScores.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse(); (await db.QsofaEvaluations.AnyAsync(s => s.EncounterId == simEncounterId)).Should().BeFalse(); (await db.ClinicalAlerts.AnyAsync(a => a.PatientId == simPatientId)).Should().BeFalse(); (await db.AlertFeedbacks.CountAsync()).Should().Be(0); (await db.PhiAccessLogs.AnyAsync(l => l.PatientId == simPatientId)).Should().BeFalse(); } } [Fact] public async Task Purge_WithActiveRun_Returns409() { _clientFactory.HangOnCreate = true; var state = await _runner.StartAsync("minimal-sim-01", 60, "tester", CancellationToken.None); var resp = await _client.DeleteAsync("/api/v1/simulation/data"); resp.StatusCode.Should().Be(HttpStatusCode.Conflict); var body = await resp.Content.ReadFromJsonAsync(); body.GetProperty("error").GetProperty("code").GetString() .Should().Be("SIMULATION_PURGE_ACTIVE_RUNS"); _runner.Cancel(state.RunId); await WaitForAsync(() => !_runner.HasActiveRuns()); } [Fact] public async Task Purge_WritesAuditLog() { using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Patients.Add(new Patient { Id = Guid.NewGuid(), Mrn = "MRN-SIM-AUDIT", FirstName = "Sim", LastName = "Audit", DateOfBirth = new DateOnly(1990, 1, 1), Gender = "M", CreatedAt = DateTimeOffset.UtcNow, IsSimulated = true, }); await db.SaveChangesAsync(); } var resp = await _client.DeleteAsync("/api/v1/simulation/data"); resp.StatusCode.Should().Be(HttpStatusCode.OK); using (var scope = _fixture.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); var audit = await db.ClinicalAuditLogs .SingleAsync(l => l.Action == AuditAction.SimulationDataPurged); audit.NewValueJson.Should().Contain("PatientsDeleted"); } } [Fact] public async Task Purge_WhenDisabled_Returns404() { using var factory = _fixture.WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["Simulation:Enabled"] = "false", }); }); }); var client = factory.CreateClient(); client.AsNurse(); var resp = await client.DeleteAsync("/api/v1/simulation/data"); resp.StatusCode.Should().Be(HttpStatusCode.NotFound); } 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."); } }