422 lines
16 KiB
C#
422 lines
16 KiB
C#
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<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);
|
|
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<AppDbContext>();
|
|
|
|
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<JsonElement>();
|
|
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<AppDbContext>();
|
|
|
|
(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<AppDbContext>();
|
|
|
|
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<AppDbContext>();
|
|
(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<JsonElement>();
|
|
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<AppDbContext>();
|
|
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<AppDbContext>();
|
|
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<string, string?>
|
|
{
|
|
["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<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.");
|
|
}
|
|
}
|