616 lines
25 KiB
C#
616 lines
25 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using FluentAssertions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StackExchange.Redis;
|
|
|
|
/// <summary>
|
|
/// Tests for gap-analysis fixes: MRN sequence, sepsis bundle idempotency,
|
|
/// trend alert exact match, order→bundle transaction, FHIR bundle rollback,
|
|
/// patient update endpoint, new list endpoints, and new validators.
|
|
/// </summary>
|
|
[Collection("Integration")]
|
|
public class GapAnalysisFixTests : IAsyncLifetime
|
|
{
|
|
private readonly ApiFixture _fixture;
|
|
private readonly HttpClient _client;
|
|
|
|
public GapAnalysisFixTests(ApiFixture fixture)
|
|
{
|
|
_fixture = fixture;
|
|
_client = fixture.CreateClient();
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
await DbResetHelper.ResetAsync(db);
|
|
|
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
|
await DataSeeder.SeedThresholdsOnlyAsync(db, redis);
|
|
}
|
|
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P0 — MRN generation uses sequence (no duplicates on concurrent registration)
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task MrnGeneration_SequentialRegistrations_ProduceUniqueMrns()
|
|
{
|
|
var resp1 = await _client.PostAsJsonAsync("/api/v1/patients",
|
|
new RegisterPatientRequest("Alice", "One", new DateOnly(1990, 1, 1), "F"));
|
|
var resp2 = await _client.PostAsJsonAsync("/api/v1/patients",
|
|
new RegisterPatientRequest("Bob", "Two", new DateOnly(1991, 2, 2), "M"));
|
|
|
|
resp1.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
resp2.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
var p1 = (await resp1.Content.ReadFromJsonAsync<JsonElement>())
|
|
.GetProperty("data").GetProperty("mrn").GetString();
|
|
var p2 = (await resp2.Content.ReadFromJsonAsync<JsonElement>())
|
|
.GetProperty("data").GetProperty("mrn").GetString();
|
|
|
|
p1.Should().StartWith("MRN-");
|
|
p2.Should().StartWith("MRN-");
|
|
p1.Should().NotBe(p2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MrnGeneration_FormatMatchesConfiguredPattern()
|
|
{
|
|
var resp = await _client.PostAsJsonAsync("/api/v1/patients",
|
|
new RegisterPatientRequest("Format", "Test", new DateOnly(1985, 5, 5), "F"));
|
|
resp.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
|
|
var mrn = (await resp.Content.ReadFromJsonAsync<JsonElement>())
|
|
.GetProperty("data").GetProperty("mrn").GetString()!;
|
|
|
|
mrn.Should().MatchRegex(@"^MRN-\d{6}$");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P0 — Trend alert uses exact observation_code match
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task TrendAlert_StoresObservationCode()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var detector = scope.ServiceProvider.GetRequiredService<TrendDetector>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-TREND-OC", FirstName = "OC", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1970, 1, 1), Gender = "M",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. OC", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
await db.SaveChangesAsync();
|
|
|
|
var redis = scope.ServiceProvider.GetRequiredService<IConnectionMultiplexer>();
|
|
foreach (var key in TrendCalculator.AllHistoryKeys(encounter.Id))
|
|
await redis.GetDatabase().KeyDeleteAsync(key);
|
|
|
|
var baseTime = new DateTimeOffset(2026, 6, 20, 10, 0, 0, TimeSpan.Zero);
|
|
await detector.ProcessObservationAsync(
|
|
encounter.Id, patient.Id, "HEART_RATE", 72m, baseTime);
|
|
var result = await detector.ProcessObservationAsync(
|
|
encounter.Id, patient.Id, "HEART_RATE", 95m, baseTime.AddMinutes(10));
|
|
|
|
result.Outcome.Should().Be(TrendOutcome.RapidDeterioration);
|
|
|
|
var alert = await db.ClinicalAlerts.SingleAsync(a =>
|
|
a.EncounterId == encounter.Id && a.AlertType == AlertType.RapidDeterioration);
|
|
alert.ObservationCode.Should().Be("HEART_RATE");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P1 — Order→Bundle spanning transaction
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task OrderResult_UpdatesBundleElement_InSameTransaction()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
|
|
var bundleService = scope.ServiceProvider.GetRequiredService<ISepsisBundleService>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-TX-001", FirstName = "Tx", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1960, 1, 1), Gender = "M",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. Tx", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId, EncounterId = encounter.Id, PatientId = patient.Id,
|
|
AlertType = AlertType.SofaSepsis, Severity = AlertSeverity.Critical,
|
|
Details = "SOFA delta +2", Status = AlertStatus.Open,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
await handler.OnSepsisAlertCreatedAsync(
|
|
encounter.Id, alertId, AlertType.SofaSepsis, CancellationToken.None);
|
|
|
|
var element = await db.SepsisBundleElements
|
|
.Include(e => e.Order)
|
|
.FirstAsync(e => e.ElementType == SepsisBundleElementType.SerumLactate);
|
|
|
|
var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();
|
|
var order = element.Order!;
|
|
order.Status = OrderStatus.InProgress;
|
|
await db.SaveChangesAsync();
|
|
|
|
await orderService.RecordResultAsync(order.Id,
|
|
new RecordOrderResultRequest("Lactate 1.5 mmol/L"));
|
|
|
|
var updatedOrder = await db.Orders.AsNoTracking().FirstAsync(o => o.Id == order.Id);
|
|
var updatedElement = await db.SepsisBundleElements.AsNoTracking()
|
|
.FirstAsync(e => e.Id == element.Id);
|
|
|
|
updatedOrder.Status.Should().Be(OrderStatus.Resulted);
|
|
updatedElement.Status.Should().Be(SepsisBundleElementStatus.Completed);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P4 — Patient update endpoint (PATCH)
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task PatientUpdate_PartialFields_UpdatesOnlyProvided()
|
|
{
|
|
var createResp = await _client.PostAsJsonAsync("/api/v1/patients",
|
|
new RegisterPatientRequest("Original", "Name", new DateOnly(1990, 1, 1), "F"));
|
|
createResp.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
var patientId = (await createResp.Content.ReadFromJsonAsync<JsonElement>())
|
|
.GetProperty("data").GetProperty("id").GetGuid();
|
|
|
|
var patchResp = await _client.PatchAsJsonAsync($"/api/v1/patients/{patientId}",
|
|
new { lastName = "Updated", allergies = "Penicillin" });
|
|
patchResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
|
|
var getResp = await _client.GetFromJsonAsync<JsonElement>($"/api/v1/patients/{patientId}");
|
|
var data = getResp.GetProperty("data");
|
|
data.GetProperty("firstName").GetString().Should().Be("Original");
|
|
data.GetProperty("lastName").GetString().Should().Be("Updated");
|
|
data.GetProperty("allergies").GetString().Should().Be("Penicillin");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PatientUpdate_NonExistent_Returns404()
|
|
{
|
|
var resp = await _client.PatchAsJsonAsync($"/api/v1/patients/{Guid.NewGuid()}",
|
|
new { firstName = "Ghost" });
|
|
resp.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PatientUpdate_CreatesAuditLog()
|
|
{
|
|
var createResp = await _client.PostAsJsonAsync("/api/v1/patients",
|
|
new RegisterPatientRequest("Audit", "Before", new DateOnly(1985, 3, 3), "M"));
|
|
var patientId = (await createResp.Content.ReadFromJsonAsync<JsonElement>())
|
|
.GetProperty("data").GetProperty("id").GetGuid();
|
|
|
|
await _client.PatchAsJsonAsync($"/api/v1/patients/{patientId}",
|
|
new { lastName = "After" });
|
|
|
|
var auditResp = await _client.GetFromJsonAsync<JsonElement>(
|
|
$"/api/v1/audit-logs?entityType=Patient&entityId={patientId}");
|
|
var logs = auditResp.GetProperty("data").GetProperty("items");
|
|
logs.EnumerateArray().Should().Contain(log =>
|
|
log.GetProperty("action").GetString() == "PATIENT_UPDATED");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P4 — Sepsis bundle list endpoint
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task SepsisBundleList_ReturnsPagedResults()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
var handler = scope.ServiceProvider.GetRequiredService<SepsisAlertHandler>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-LIST-001", FirstName = "List", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1970, 1, 1), Gender = "F",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. List", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
|
|
var alertId = Guid.NewGuid();
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = alertId, EncounterId = encounter.Id, PatientId = patient.Id,
|
|
AlertType = AlertType.SofaSepsis, Severity = AlertSeverity.Critical,
|
|
Details = "SOFA delta +2", Status = AlertStatus.Open,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
await handler.OnSepsisAlertCreatedAsync(
|
|
encounter.Id, alertId, AlertType.SofaSepsis, CancellationToken.None);
|
|
|
|
var resp = await _client.GetFromJsonAsync<JsonElement>(
|
|
"/api/v1/sepsis-bundles?status=IN_PROGRESS&page=1&pageSize=10");
|
|
|
|
var data = resp.GetProperty("data");
|
|
data.GetProperty("totalCount").GetInt32().Should().BeGreaterThanOrEqualTo(1);
|
|
data.GetProperty("items").EnumerateArray().Should().Contain(b =>
|
|
b.GetProperty("complianceStatus").GetString() == "IN_PROGRESS");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P1 — encounter timeline endpoint
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task EncounterTimeline_ReturnsMergedEventTypes()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-TL-001", FirstName = "TL", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1980, 1, 1), Gender = "M",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var admittedAt = DateTimeOffset.UtcNow.AddHours(-6);
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. TL", AdmittedAt = admittedAt,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
|
|
db.Observations.Add(new Observation
|
|
{
|
|
Id = Guid.NewGuid(), EncounterId = encounter.Id,
|
|
ObservationCode = "TEMP_C", Value = 38.8m, Unit = "C",
|
|
Source = ObservationSource.Manual, RecordedAt = admittedAt.AddHours(1),
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
db.ClinicalAlerts.Add(new ClinicalAlert
|
|
{
|
|
Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id,
|
|
AlertType = AlertType.QsofaScreen, Severity = AlertSeverity.Warning,
|
|
Details = "qSOFA screen", Status = AlertStatus.Open,
|
|
TriggeredAt = admittedAt.AddHours(2)
|
|
});
|
|
db.Orders.Add(new Order
|
|
{
|
|
Id = Guid.NewGuid(), EncounterId = encounter.Id,
|
|
OrderType = OrderType.Lab, Description = "Blood cultures",
|
|
OrderedBy = "Dr. TL", Status = OrderStatus.Pending,
|
|
OrderedAt = admittedAt.AddHours(3)
|
|
});
|
|
db.MedicationAdministrations.Add(new MedicationAdministration
|
|
{
|
|
Id = Guid.NewGuid(), EncounterId = encounter.Id,
|
|
DrugName = "Ceftriaxone", Dose = 1m, DoseUnit = "g", Route = "IV",
|
|
AdministeredAt = admittedAt.AddHours(4), AdministeredBy = "RN TL"
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var resp = await _client.GetFromJsonAsync<JsonElement>(
|
|
$"/api/v1/encounters/{encounter.Id}/timeline");
|
|
|
|
var items = resp.GetProperty("data").GetProperty("events");
|
|
var types = items.EnumerateArray().Select(e => e.GetProperty("type").GetString()).ToList();
|
|
types.Should().Contain("status");
|
|
types.Should().Contain("observation");
|
|
types.Should().Contain("alert");
|
|
types.Should().Contain("order");
|
|
types.Should().Contain("medication");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P4 — qSOFA history endpoint
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task QsofaHistory_ReturnsEvaluationRecords()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-QH-001", FirstName = "QH", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1975, 1, 1), Gender = "F",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. QH", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EncounterId = encounter.Id,
|
|
PatientId = patient.Id,
|
|
ActiveCriteria = 1,
|
|
RespRate = 24m,
|
|
EvaluatedAt = now.AddMinutes(-30),
|
|
CreatedAt = now,
|
|
});
|
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
EncounterId = encounter.Id,
|
|
PatientId = patient.Id,
|
|
ActiveCriteria = 2,
|
|
RespRate = 24m,
|
|
SystolicBp = 95m,
|
|
ScreenAlertFired = true,
|
|
EvaluatedAt = now.AddMinutes(-10),
|
|
CreatedAt = now,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var resp = await _client.GetFromJsonAsync<JsonElement>(
|
|
$"/api/v1/encounters/{encounter.Id}/qsofa/history?limit=10");
|
|
|
|
var data = resp.GetProperty("data");
|
|
var items = data.GetProperty("items");
|
|
items.GetArrayLength().Should().Be(2);
|
|
|
|
var latest = items[0];
|
|
latest.GetProperty("activeCriteria").GetInt32().Should().Be(2);
|
|
latest.GetProperty("screenAlertFired").GetBoolean().Should().BeTrue();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P4 — Reconciliation alerts endpoint
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ReconciliationAlerts_ListWithFilter()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-RECON-001", FirstName = "Recon", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1980, 1, 1), Gender = "F",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. Recon", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
db.ReconciliationAlerts.Add(new ReconciliationAlert
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
CheckType = ReconciliationCheckType.UnacknowledgedCriticalAlert,
|
|
EncounterId = encounter.Id,
|
|
PatientId = patient.Id,
|
|
Details = "Test reconciliation alert",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var resp = await _client.GetFromJsonAsync<JsonElement>(
|
|
"/api/v1/reconciliation-alerts?resolved=false&page=1&pageSize=10");
|
|
|
|
var data = resp.GetProperty("data");
|
|
data.GetProperty("totalCount").GetInt32().Should().BeGreaterThanOrEqualTo(1);
|
|
data.GetProperty("items").EnumerateArray().Should().Contain(a =>
|
|
a.GetProperty("details").GetString()!.Contains("Test reconciliation alert"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReconciliationAlerts_FilterByCheckType()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-RECON-002", FirstName = "Recon2", LastName = "Test",
|
|
DateOfBirth = new DateOnly(1980, 1, 1), Gender = "M",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. Recon2", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
db.ReconciliationAlerts.Add(new ReconciliationAlert
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
CheckType = ReconciliationCheckType.PendingOrderNoResult,
|
|
EncounterId = encounter.Id,
|
|
Details = "Pending order check",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
var resp = await _client.GetFromJsonAsync<JsonElement>(
|
|
"/api/v1/reconciliation-alerts?checkType=PENDING_ORDER_NO_RESULT");
|
|
var items = resp.GetProperty("data").GetProperty("items");
|
|
items.GetArrayLength().Should().BeGreaterThanOrEqualTo(1);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P2 — New validators return 400 for invalid input
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task TransitionStatusValidator_LongDiagnosis_Returns400()
|
|
{
|
|
var patientId = await CreatePatientAsync();
|
|
var encounterId = await CreateEncounterAsync(patientId);
|
|
|
|
var resp = await _client.PatchAsJsonAsync(
|
|
$"/api/v1/encounters/{encounterId}/status",
|
|
new { status = "Discharged", dischargeDiagnosis = new string('x', 501) });
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdatePatientValidator_LongFirstName_Returns400()
|
|
{
|
|
var patientId = await CreatePatientAsync();
|
|
|
|
var resp = await _client.PatchAsJsonAsync(
|
|
$"/api/v1/patients/{patientId}",
|
|
new { firstName = new string('x', 101) });
|
|
|
|
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P2 — Outbox retry columns exist
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task OutboxEvent_HasRetryColumns()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var ev = new OutboxEvent
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Topic = "test.retry",
|
|
Payload = "{}",
|
|
PartitionKey = "test",
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
RetryCount = 3,
|
|
LastError = "Connection refused",
|
|
FailedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.OutboxEvents.Add(ev);
|
|
await db.SaveChangesAsync();
|
|
|
|
var loaded = await db.OutboxEvents.AsNoTracking().FirstAsync(e => e.Id == ev.Id);
|
|
loaded.RetryCount.Should().Be(3);
|
|
loaded.LastError.Should().Be("Connection refused");
|
|
loaded.FailedAt.Should().NotBeNull();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// P0 — ClinicalAlert.ObservationCode column persists
|
|
// -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task ClinicalAlert_ObservationCode_Persists()
|
|
{
|
|
using var scope = _fixture.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
|
|
var patient = new Patient
|
|
{
|
|
Id = Guid.NewGuid(), Mrn = "MRN-OC-001", FirstName = "OC", LastName = "Persist",
|
|
DateOfBirth = new DateOnly(1980, 1, 1), Gender = "F",
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
var encounter = new Encounter
|
|
{
|
|
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
|
|
Status = EncounterStatus.Active, Department = Department.Icu,
|
|
AttendingPhysician = "Dr. OC", AdmittedAt = DateTimeOffset.UtcNow,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.Patients.Add(patient);
|
|
db.Encounters.Add(encounter);
|
|
|
|
var alert = new ClinicalAlert
|
|
{
|
|
Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id,
|
|
AlertType = AlertType.RapidDeterioration, Severity = AlertSeverity.Warning,
|
|
Details = "Test", ObservationCode = "SPO2", Status = AlertStatus.Open,
|
|
TriggeredAt = DateTimeOffset.UtcNow
|
|
};
|
|
db.ClinicalAlerts.Add(alert);
|
|
await db.SaveChangesAsync();
|
|
|
|
var loaded = await db.ClinicalAlerts.AsNoTracking().FirstAsync(a => a.Id == alert.Id);
|
|
loaded.ObservationCode.Should().Be("SPO2");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private async Task<Guid> CreatePatientAsync()
|
|
{
|
|
var resp = await _client.PostAsJsonAsync("/api/v1/patients",
|
|
new RegisterPatientRequest("Gap", "Test", new DateOnly(1990, 1, 1), "F"));
|
|
resp.EnsureSuccessStatusCode();
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
return body.GetProperty("data").GetProperty("id").GetGuid();
|
|
}
|
|
|
|
private async Task<Guid> CreateEncounterAsync(Guid patientId)
|
|
{
|
|
var resp = await _client.PostAsJsonAsync(
|
|
$"/api/v1/patients/{patientId}/encounters",
|
|
new OpenEncounterRequest(EncounterType.Inpatient, Department.Icu, "Dr. Gap"));
|
|
resp.EnsureSuccessStatusCode();
|
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
|
return body.GetProperty("data").GetProperty("id").GetGuid();
|
|
}
|
|
}
|