feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user