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(); await DbResetHelper.ResetAsync(db); var redis = scope.ServiceProvider.GetRequiredService(); 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 { 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 { 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(); 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(); 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 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 { new("http://hospital.example/mrn", mrn) { Type = new CodeableConcept("http://terminology.hl7.org/CodeSystem/v2-0203", "MR") } }, Name = new List { new() { Given = new[] { "Jane" }, Family = "Doe" } }, BirthDate = "1980-01-15", Gender = AdministrativeGender.Female }; private async Task SeedPatientAndEncounterAsync() { using var scope = _fixture.Services.CreateScope(); var patients = scope.ServiceProvider.GetRequiredService(); 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(); 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 { new("http://hospital.example/visit", "VISIT-ADT-001") }, Subject = new ResourceReference { Identifier = new Identifier("http://hospital.example/mrn", "MRN-ADT-001") }, Participant = new List { new() { Type = new List { 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 { new("http://hospital.example/obs", "adt-hr-1") } }; return new Bundle { Type = Bundle.BundleType.Transaction, Entry = new List { 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" } } } }; } }