Files
voltsrage 80b009fd23
CI / backend (push) Successful in 8m52s
CI / frontend (push) Failing after 1m39s
feature: Self-Service Clinical Testing Sessions
2026-08-06 04:03:04 +08:00

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.");
}
}