Files

204 lines
8.0 KiB
C#

using System.Net;
using System.Text;
using FluentAssertions;
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;
using Task = System.Threading.Tasks.Task;
[Collection("Integration")]
public class FhirIngestTests : IAsyncLifetime
{
private const string ApiKey = "dev-integration-key-change-in-production";
private readonly ApiFixture _fixture;
private readonly HttpClient _client;
private static readonly FhirJsonSerializer Serializer = new();
public FhirIngestTests(ApiFixture fixture)
{
_fixture = fixture;
_client = fixture.CreateClient();
_client.ClearAuth();
}
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;
[Fact]
public async Task PatientUpsert_IsIdempotent()
{
var patient = BuildPatient("MRN-HOSP-001");
var json = Serializer.SerializeToString(patient);
var resp1 = await PostFhirAsync("/fhir/R4/Patient", json);
resp1.StatusCode.Should().Be(HttpStatusCode.Created);
var resp2 = await PostFhirAsync("/fhir/R4/Patient", json);
resp2.StatusCode.Should().Be(HttpStatusCode.Created);
}
[Fact]
public async Task Observation_HeartRate_Loinc8867_MapsAndIngests()
{
var encounterId = await SeedPatientAndEncounterAsync();
var obs = new Hl7.Fhir.Model.Observation
{
Status = ObservationStatus.Final,
Category = new List<CodeableConcept>
{
new("http://terminology.hl7.org/CodeSystem/observation-category", "vital-signs")
},
Code = new CodeableConcept("http://loinc.org", "8867-4", "Heart rate"),
Subject = new ResourceReference("Patient/test"),
Encounter = new ResourceReference($"Encounter/{encounterId}"),
Effective = new FhirDateTime(DateTimeOffset.UtcNow),
Value = new Quantity(110, "/min", "http://unitsofmeasure.org"),
Identifier = new List<Identifier>
{
new("http://hospital.example/obs", "hr-001")
}
};
var resp = await PostFhirAsync("/fhir/R4/Observation", Serializer.SerializeToString(obs));
resp.StatusCode.Should().Be(HttpStatusCode.Created);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var row = db.Observations.Single(o => o.IdempotencyKey == "hr-001");
row.ObservationCode.Should().Be("HEART_RATE");
row.Value.Should().Be(110m);
}
[Fact]
public async Task Observation_UnknownLoinc_Returns422()
{
var encounterId = await SeedPatientAndEncounterAsync();
var obs = new Hl7.Fhir.Model.Observation
{
Status = ObservationStatus.Final,
Code = new CodeableConcept("http://loinc.org", "99999-9"),
Encounter = new ResourceReference($"Encounter/{encounterId}"),
Value = new Quantity(1, "1")
};
var resp = await PostFhirAsync("/fhir/R4/Observation", Serializer.SerializeToString(obs));
resp.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
(await resp.Content.ReadAsStringAsync()).Should().Contain("OperationOutcome");
}
[Fact]
public async Task TransactionBundle_PatientEncounterObservation_Succeeds()
{
var bundle = BuildAdmitBundle();
var resp = await PostFhirAsync("/fhir/R4", Serializer.SerializeToString(bundle));
resp.StatusCode.Should().Be(HttpStatusCode.OK);
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Patients.Should().Contain(p => p.Mrn == "MRN-ADT-001");
db.Encounters.Should().Contain(e => e.Status == EncounterStatus.Active);
db.Observations.Should().Contain(o => o.ObservationCode == "HEART_RATE");
}
private async Task<HttpResponseMessage> PostFhirAsync(string path, string json)
{
var req = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = new StringContent(json, Encoding.UTF8, "application/fhir+json")
};
req.Headers.Add("X-Api-Key", ApiKey);
return await _client.SendAsync(req);
}
private static Hl7.Fhir.Model.Patient BuildPatient(string mrn) => new()
{
Identifier = new List<Identifier>
{
new("http://hospital.example/mrn", mrn)
{
Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR")
}
},
Name = new List<HumanName> { new() { Given = new[] { "Jane" }, Family = "Doe" } },
BirthDate = "1980-01-15",
Gender = AdministrativeGender.Female
};
private async Task<Guid> SeedPatientAndEncounterAsync()
{
using var scope = _fixture.Services.CreateScope();
var patients = scope.ServiceProvider.GetRequiredService<IPatientService>();
var patient = await patients.RegisterOrUpdateByIdentifierAsync(new FhirPatientUpsertRequest(
"http://hospital.example/mrn", "MRN-TEST-001",
"Test", "Patient", new DateOnly(1990, 1, 1), "female"));
var encounters = scope.ServiceProvider.GetRequiredService<IEncounterService>();
var encounter = await encounters.OpenOrUpdateByIdentifierAsync(new FhirEncounterUpsertRequest(
"http://hospital.example/visit", "VISIT-001",
patient.Id, EncounterType.Inpatient, Department.Icu,
"Dr. Smith", EncounterStatus.Active));
return encounter.Id;
}
private static Bundle BuildAdmitBundle()
{
var patient = BuildPatient("MRN-ADT-001");
var encounter = new Hl7.Fhir.Model.Encounter
{
Status = Hl7.Fhir.Model.Encounter.EncounterStatus.InProgress,
Class = new Coding("http://terminology.hl7.org/CodeSystem/v3-ActCode", "IMP"),
Identifier = new List<Identifier> { new("http://hospital.example/visit", "VISIT-ADT-001") },
Subject = new ResourceReference
{
Identifier = new Identifier("http://hospital.example/mrn", "MRN-ADT-001")
},
Participant = new List<Hl7.Fhir.Model.Encounter.ParticipantComponent>
{
new()
{
Type = new List<CodeableConcept>
{
new("http://terminology.hl7.org/CodeSystem/v3-ParticipationType", "ATND")
},
Individual = new ResourceReference { Display = "Dr. Admit" }
}
}
};
var obs = new Hl7.Fhir.Model.Observation
{
Status = ObservationStatus.Final,
Code = new CodeableConcept("http://loinc.org", "8867-4"),
Encounter = new ResourceReference
{
Identifier = new Identifier("http://hospital.example/visit", "VISIT-ADT-001")
},
Value = new Quantity(88, "/min"),
Identifier = new List<Identifier> { new("http://hospital.example/obs", "adt-hr-1") }
};
return new Bundle
{
Type = Bundle.BundleType.Transaction,
Entry = new List<Bundle.EntryComponent>
{
new() { Resource = patient, Request = new Bundle.RequestComponent { Method = Bundle.HTTPVerb.POST, Url = "Patient" } },
new() { Resource = encounter, Request = new Bundle.RequestComponent { Method = Bundle.HTTPVerb.POST, Url = "Encounter" } },
new() { Resource = obs, Request = new Bundle.RequestComponent { Method = Bundle.HTTPVerb.POST, Url = "Observation" } }
}
};
}
}