feature: FHIR R4 Inbound Facade
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Hl7.Fhir.Model;
|
||||
using Hl7.Fhir.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
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();
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
}
|
||||
|
||||
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" } }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user