feature: HL7 FHIR R4 Integration
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for FHIR R4 read/search endpoints (Phase 11 Step 7).
|
||||
/// </summary>
|
||||
[Collection("Database")]
|
||||
public class FhirIntegrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly ApiFixture _fixture;
|
||||
private HttpClient _client = null!;
|
||||
private HttpClient _anonymousClient = null!;
|
||||
|
||||
public FhirIntegrationTests(ApiFixture fixture) => _fixture = fixture;
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
using var scope = _fixture.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await DbResetHelper.ResetAsync(db);
|
||||
await DataSeeder.SeedAsync(db);
|
||||
await FhirClinicalSeedHelper.SeedAsync(db);
|
||||
|
||||
_client = await AuthHelper.LoginAsync(_fixture, "admin1");
|
||||
_anonymousClient = _fixture.CreateClient();
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => Task.CompletedTask;
|
||||
|
||||
[Fact]
|
||||
public async Task Metadata_ReturnsCapabilityStatementWithSupportedResources()
|
||||
{
|
||||
var response = await GetFhirAsync("/fhir/metadata", authenticated: false);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
var resourceTypes = body.GetProperty("rest")[0]
|
||||
.GetProperty("resource")
|
||||
.EnumerateArray()
|
||||
.Select(r => r.GetProperty("type").GetString())
|
||||
.ToList();
|
||||
|
||||
resourceTypes.Should().Contain(new[] { "Patient", "Encounter", "Observation" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPatient_ReturnsFhirPatientWithMrnNameAndGender()
|
||||
{
|
||||
var response = await GetFhirAsync($"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("resourceType").GetString().Should().Be("Patient");
|
||||
body.GetProperty("id").GetString()
|
||||
.Should().Be(FhirClinicalSeedHelper.Patient1Id.ToString());
|
||||
|
||||
var identifier = body.GetProperty("identifier")[0];
|
||||
identifier.GetProperty("value").GetString().Should().Be("VCR-000001");
|
||||
|
||||
body.GetProperty("name")[0].GetProperty("text").GetString()
|
||||
.Should().Be("MARIA SANTOS");
|
||||
body.GetProperty("gender").GetString().Should().Be("female");
|
||||
body.GetProperty("birthDate").GetString().Should().Be("1978-03-15");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchPatients_ByName_ReturnsMatchingBundle()
|
||||
{
|
||||
var response = await GetFhirAsync("/fhir/Patient?name=Santos");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("type").GetString().Should().Be("searchset");
|
||||
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
|
||||
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
|
||||
body.GetProperty("entry")[0].GetProperty("resource")
|
||||
.GetProperty("name")[0].GetProperty("text").GetString()
|
||||
.Should().Contain("SANTOS");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchPatients_ByIdentifier_ReturnsPatientByMrn()
|
||||
{
|
||||
var response = await GetFhirAsync("/fhir/Patient?identifier=VCR-000001");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("entry").GetArrayLength().Should().Be(1);
|
||||
body.GetProperty("entry")[0].GetProperty("resource")
|
||||
.GetProperty("identifier")[0].GetProperty("value").GetString()
|
||||
.Should().Be("VCR-000001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadEncounter_ReturnsFhirEncounterWithStatusAndPatientReference()
|
||||
{
|
||||
var response = await GetFhirAsync($"/fhir/Encounter/{FhirClinicalSeedHelper.Encounter1Id}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("resourceType").GetString().Should().Be("Encounter");
|
||||
body.GetProperty("status").GetString().Should().Be("in-progress");
|
||||
body.GetProperty("subject").GetProperty("reference").GetString()
|
||||
.Should().Be($"Patient/{FhirClinicalSeedHelper.Patient1Id}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchEncounters_ByPatient_ReturnsMatchingEncounters()
|
||||
{
|
||||
var patientId = FhirClinicalSeedHelper.Patient1Id;
|
||||
var response = await GetFhirAsync($"/fhir/Encounter?patient={patientId}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
|
||||
body.GetProperty("entry").EnumerateArray().Should().AllSatisfy(entry =>
|
||||
{
|
||||
entry.GetProperty("resource").GetProperty("subject")
|
||||
.GetProperty("reference").GetString()
|
||||
.Should().Be($"Patient/{patientId}");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadObservation_ReturnsFhirObservationWithLoincCodeAndUcumUnit()
|
||||
{
|
||||
var response = await GetFhirAsync($"/fhir/Observation/{FhirClinicalSeedHelper.HeartRateObsId}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("resourceType").GetString().Should().Be("Observation");
|
||||
|
||||
var coding = body.GetProperty("code").GetProperty("coding")[0];
|
||||
coding.GetProperty("system").GetString().Should().Be("http://loinc.org");
|
||||
coding.GetProperty("code").GetString().Should().Be("8867-4");
|
||||
if (coding.TryGetProperty("display", out var display))
|
||||
display.GetString().Should().Be("Heart rate");
|
||||
|
||||
var value = body.GetProperty("valueQuantity");
|
||||
value.GetProperty("value").GetDecimal().Should().Be(88m);
|
||||
value.GetProperty("unit").GetString().Should().Be("bpm");
|
||||
value.GetProperty("code").GetString().Should().Be("/min");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchObservations_ByCategoryVitalSigns_ReturnsVitalSignObservationsOnly()
|
||||
{
|
||||
var patientId = FhirClinicalSeedHelper.Patient1Id;
|
||||
var response = await GetFhirAsync(
|
||||
$"/fhir/Observation?patient={patientId}&category=vital-signs");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
|
||||
|
||||
foreach (var entry in body.GetProperty("entry").EnumerateArray())
|
||||
{
|
||||
var category = entry.GetProperty("resource").GetProperty("category")[0]
|
||||
.GetProperty("coding")[0].GetProperty("code").GetString();
|
||||
category.Should().Be("vital-signs");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchObservations_ByLoincCode_ResolvesToHeartRate()
|
||||
{
|
||||
var response = await GetFhirAsync("/fhir/Observation?code=8867-4");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
|
||||
|
||||
var coding = body.GetProperty("entry")[0].GetProperty("resource")
|
||||
.GetProperty("code").GetProperty("coding")[0];
|
||||
coding.GetProperty("code").GetString().Should().Be("8867-4");
|
||||
|
||||
var value = body.GetProperty("entry")[0].GetProperty("resource")
|
||||
.GetProperty("valueQuantity").GetProperty("value").GetDecimal();
|
||||
value.Should().Be(88m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchObservations_ByDateGreaterOrEqual_FiltersByRecordedAt()
|
||||
{
|
||||
var patientId = FhirClinicalSeedHelper.Patient1Id;
|
||||
var response = await GetFhirAsync(
|
||||
$"/fhir/Observation?patient={patientId}&date=ge2026-06-20");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("total").GetInt32().Should().BeGreaterThan(0);
|
||||
body.GetProperty("entry").GetArrayLength().Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PatientEverything_ReturnsCompletePatientBundle()
|
||||
{
|
||||
var response = await GetFhirAsync(
|
||||
$"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}/$everything");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("type").GetString().Should().Be("searchset");
|
||||
body.GetProperty("total").GetInt32().Should().BeGreaterThan(1);
|
||||
|
||||
var resourceTypes = body.GetProperty("entry").EnumerateArray()
|
||||
.Select(e => e.GetProperty("resource").GetProperty("resourceType").GetString())
|
||||
.ToList();
|
||||
|
||||
resourceTypes.Should().Contain("Patient");
|
||||
resourceTypes.Should().Contain("Encounter");
|
||||
resourceTypes.Should().Contain("Observation");
|
||||
|
||||
var includeModes = body.GetProperty("entry").EnumerateArray()
|
||||
.Where(e => e.GetProperty("resource").GetProperty("resourceType").GetString() != "Patient")
|
||||
.Select(e => e.GetProperty("search").GetProperty("mode").GetString());
|
||||
|
||||
includeModes.Should().AllBe("include");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPatient_NotFound_Returns404OperationOutcome()
|
||||
{
|
||||
var missingId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
var response = await GetFhirAsync($"/fhir/Patient/{missingId}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("resourceType").GetString().Should().Be("OperationOutcome");
|
||||
|
||||
var issue = body.GetProperty("issue")[0];
|
||||
issue.GetProperty("severity").GetString().Should().Be("error");
|
||||
issue.GetProperty("code").GetString().Should().Be("not-found");
|
||||
issue.GetProperty("diagnostics").GetString()
|
||||
.Should().Contain($"Patient/{missingId}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadPatient_ReturnsApplicationFhirJsonContentType()
|
||||
{
|
||||
var response = await GetFhirAsync($"/fhir/Patient/{FhirClinicalSeedHelper.Patient1Id}");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType!.MediaType.Should().Be("application/fhir+json");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchPatients_PaginationLinks_AreCorrect()
|
||||
{
|
||||
var response = await GetFhirAsync("/fhir/Patient?_count=1&_offset=0");
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var body = await ParseJsonAsync(response);
|
||||
body.GetProperty("total").GetInt32().Should().BeGreaterThanOrEqualTo(2);
|
||||
body.GetProperty("entry").GetArrayLength().Should().Be(1);
|
||||
|
||||
var links = body.GetProperty("link").EnumerateArray()
|
||||
.ToDictionary(l => l.GetProperty("relation").GetString()!, l => l.GetProperty("url").GetString());
|
||||
|
||||
links.Should().ContainKey("self");
|
||||
links["self"].Should().Contain("_count=1");
|
||||
links["self"].Should().Contain("_offset=0");
|
||||
|
||||
links.Should().ContainKey("next");
|
||||
links["next"].Should().Contain("_count=1");
|
||||
links["next"].Should().Contain("_offset=1");
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> GetFhirAsync(string path, bool authenticated = true)
|
||||
{
|
||||
var client = authenticated ? _client : _anonymousClient;
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, path);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/fhir+json"));
|
||||
return await client.SendAsync(request);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> ParseJsonAsync(HttpResponseMessage response)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
["ConnectionStrings:DefaultConnection"] =
|
||||
"Host=localhost;Port=5437;Database=vigilcare_records_test;Username=postgres;Password=password",
|
||||
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true"
|
||||
["Redis:ConnectionString"] = "localhost:6383,defaultDatabase=1,allowAdmin=true",
|
||||
["Fhir:BaseUrl"] = "http://localhost/fhir"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds promoted clinical records aligned with Phase 9 demo batches for FHIR tests.
|
||||
/// DataSeeder creates digitization batches; this helper populates the clinical tables
|
||||
/// that FhirService reads.
|
||||
/// </summary>
|
||||
public static class FhirClinicalSeedHelper
|
||||
{
|
||||
public static readonly Guid Patient1Id = Guid.Parse("b1000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid Patient2Id = Guid.Parse("b1000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid Encounter1Id = Guid.Parse("d1000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid HeartRateObsId = Guid.Parse("e1000000-0000-0000-0000-000000000001");
|
||||
public static readonly Guid WbcObsId = Guid.Parse("e1000000-0000-0000-0000-000000000002");
|
||||
public static readonly Guid Batch1Id = Guid.Parse("c1000000-0000-0000-0000-000000000001");
|
||||
|
||||
public static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
if (await db.Patients.AnyAsync())
|
||||
return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var recordedAt = now.AddDays(-5);
|
||||
|
||||
db.Patients.AddRange(
|
||||
new Patient
|
||||
{
|
||||
Id = Patient1Id,
|
||||
Mrn = "VCR-000001",
|
||||
FullName = "MARIA SANTOS",
|
||||
DateOfBirth = new DateOnly(1978, 3, 15),
|
||||
Sex = "female",
|
||||
BloodType = BloodType.APos,
|
||||
EmergencyContact = "Juan Santos - 555-0101",
|
||||
NoKnownAllergies = false,
|
||||
AllergiesJson = "[\"Penicillin\", \"Sulfa drugs\"]",
|
||||
CreatedAt = now.AddDays(-3),
|
||||
UpdatedAt = now.AddHours(-12),
|
||||
},
|
||||
new Patient
|
||||
{
|
||||
Id = Patient2Id,
|
||||
Mrn = "VCR-000002",
|
||||
FullName = "KENJI NAKAMURA",
|
||||
DateOfBirth = new DateOnly(1952, 11, 8),
|
||||
Sex = "male",
|
||||
BloodType = BloodType.ONeg,
|
||||
EmergencyContact = "Yuki Nakamura - 555-0202",
|
||||
NoKnownAllergies = true,
|
||||
CreatedAt = now.AddDays(-1),
|
||||
UpdatedAt = now.AddDays(-1),
|
||||
});
|
||||
|
||||
db.Encounters.Add(new Encounter
|
||||
{
|
||||
Id = Encounter1Id,
|
||||
PatientId = Patient1Id,
|
||||
AdmissionDate = recordedAt,
|
||||
Department = Department.InternalMedicine,
|
||||
RoomBed = "2A-04",
|
||||
AdmissionReason = "Pneumonia with elevated WBC",
|
||||
Status = "active",
|
||||
SourceBatchId = Batch1Id,
|
||||
CreatedAt = now.AddDays(-3),
|
||||
UpdatedAt = now.AddHours(-12),
|
||||
});
|
||||
|
||||
db.Observations.AddRange(
|
||||
new Observation
|
||||
{
|
||||
Id = HeartRateObsId,
|
||||
EncounterId = Encounter1Id,
|
||||
PatientId = Patient1Id,
|
||||
ObservationCode = "HEART_RATE",
|
||||
Value = 88m,
|
||||
Unit = "bpm",
|
||||
RecordedAt = recordedAt,
|
||||
Source = "digitization_backfill",
|
||||
SourceBatchId = Batch1Id,
|
||||
CreatedAt = now.AddHours(-12),
|
||||
},
|
||||
new Observation
|
||||
{
|
||||
Id = WbcObsId,
|
||||
EncounterId = Encounter1Id,
|
||||
PatientId = Patient1Id,
|
||||
ObservationCode = "WBC_K_UL",
|
||||
Value = 14.2m,
|
||||
Unit = "K/uL",
|
||||
RecordedAt = recordedAt,
|
||||
Source = "digitization_backfill",
|
||||
SourceBatchId = Batch1Id,
|
||||
CreatedAt = now.AddHours(-12),
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user