Skip to content

Commit b3db811

Browse files
LEGLINK-871: manifest.ndjson Device and List resources are missing meta.profile (#1833)
Add missing profiles in submission Co-authored-by: John Britton <johnbritton@users.noreply.github.com>
1 parent 6e3bbc1 commit b3db811

5 files changed

Lines changed: 357 additions & 15 deletions

File tree

DotNet/Automation.Link/Validation/ReportAbsManifestValidator.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ public class ReportAbsManifestValidator
1313
private const int MaxErrors = 200;
1414
private const string ApplicablePeriodExtensionUrl = "http://www.cdc.gov/nhsn/fhirportal/dqm/ig/StructureDefinition/link-patient-list-applicable-period-extension";
1515

16+
// NHSN DQM IG profiles the Report service stamps on the manifest resources
17+
// (Report.ReportConstants.BundleSettings). Asserted here so a regression that drops
18+
// meta.profile fails the automation suite rather than the downstream IG validator.
19+
private const string DeviceProfileUrl = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/nhsn-submitting-device";
20+
private const string PatientListProfileUrl = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/poi-list";
21+
1622
/// <summary>
1723
/// Controls expected derived OperationOutcome writes per failed-validation patient.
1824
/// </summary>
@@ -323,6 +329,12 @@ private void ValidateManifest(
323329
if (deviceResources.Count != 1) AddError(errors, $"Manifest should contain exactly one Device resource. Actual={deviceResources.Count}");
324330
if (listResources.Count != 1) AddError(errors, $"Manifest should contain exactly one List resource. Actual={listResources.Count}");
325331

332+
foreach (var device in deviceResources)
333+
ValidateMetaProfile(device, "Device", DeviceProfileUrl, errors);
334+
335+
foreach (var list in listResources)
336+
ValidateMetaProfile(list, "List", PatientListProfileUrl, errors);
337+
326338
var patientList = listResources.FirstOrDefault();
327339
if (patientList.ValueKind == JsonValueKind.Undefined)
328340
{
@@ -791,6 +803,51 @@ private List<JsonElement> ParseNdjson(string ndjson, string fileName, List<strin
791803
return resources;
792804
}
793805

806+
/// <summary>
807+
/// Asserts that a manifest resource declares the NHSN DQM IG profile it is meant to conform to.
808+
/// Downstream IG validation cannot resolve the resource without it.
809+
/// </summary>
810+
private static void ValidateMetaProfile(
811+
JsonElement resource,
812+
string resourceType,
813+
string expectedProfileUrl,
814+
List<string> errors)
815+
{
816+
var profiles = GetMetaProfiles(resource);
817+
818+
if (profiles.Count == 0)
819+
{
820+
AddError(errors, $"Manifest {resourceType} is missing meta.profile. Expected '{expectedProfileUrl}'.");
821+
return;
822+
}
823+
824+
if (!profiles.Contains(expectedProfileUrl, StringComparer.Ordinal))
825+
AddError(errors, $"Manifest {resourceType} meta.profile mismatch. Expected '{expectedProfileUrl}', actual [{string.Join(", ", profiles)}].");
826+
}
827+
828+
private static List<string> GetMetaProfiles(JsonElement resource)
829+
{
830+
var profiles = new List<string>();
831+
832+
if (!resource.TryGetProperty("meta", out var meta) || meta.ValueKind != JsonValueKind.Object)
833+
return profiles;
834+
835+
if (!meta.TryGetProperty("profile", out var profileArr) || profileArr.ValueKind != JsonValueKind.Array)
836+
return profiles;
837+
838+
foreach (var profile in profileArr.EnumerateArray())
839+
{
840+
if (profile.ValueKind == JsonValueKind.String)
841+
{
842+
var value = profile.GetString();
843+
if (!string.IsNullOrWhiteSpace(value))
844+
profiles.Add(value);
845+
}
846+
}
847+
848+
return profiles;
849+
}
850+
794851
private static bool IsType(JsonElement resource, string type) =>
795852
string.Equals(GetString(resource, "resourceType"), type, StringComparison.OrdinalIgnoreCase);
796853

DotNet/Report/Application/Settings/ReportConstants.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public static class BundleSettings
1515
public const string BundlingUrlBase = "https://www.cdc.gov/nhsn/nhsn-measures";
1616
public const string BundlingFullUrlFormat = BundlingUrlBase + "/{0}";
1717
public const string CdcOrgIdSystem = "https://www.cdc.gov/nhsn/OrgID";
18-
public const string CensusProfileUrl = "https://www.cdc.gov/nhsn/nhsn-measures/StructureDefinition/poi-list";
18+
public const string CensusProfileUrl = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/poi-list";
1919
public const string DataAbsentReasonExtensionUrl = "http://hl7.org/fhir/StructureDefinition/data-absent-reason";
2020
public const string DataAbsentReasonUnknownCode = "unknown";
2121
public const string IdentifierSystem = "urn:ietf:rfc:3986";
@@ -25,6 +25,7 @@ public static class BundleSettings
2525
public const string OrganizationTypeSystem = "http://terminology.hl7.org/CodeSystem/organization-type";
2626
public const string ReportBundleProfileUrl = "https://www.cdc.gov/nhsn/nhsn-measures/StructureDefinition/nhsn-measurereport-bundle";
2727
public const string SubjectListMeasureReportProfile = "http://www.cdc.gov/nhsn/fhirportal/dqm/ig/StructureDefinition/subjectlist-measurereport";
28+
public const string SubmittingDeviceProfile = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/nhsn-submitting-device";
2829
public const string SubmittingOrganizationProfile = "https://www.cdc.gov/nhsn/nhsn-measures/StructureDefinition/nhsn-submitting-organization";
2930
}
3031

DotNet/Report/KafkaProducers/ReportManifestProducer.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,10 @@ public virtual async Task<bool> Produce(ReportScheduleModel schedule, string cor
158158

159159
private Device CreateDevice()
160160
{
161-
var device = new Device();
161+
var device = new Device
162+
{
163+
Meta = new Meta { Profile = [ReportConstants.BundleSettings.SubmittingDeviceProfile] }
164+
};
162165
device.DeviceName.Add(new Device.DeviceNameComponent()
163166
{
164167
Name = "NHSNLink"
@@ -180,6 +183,7 @@ private Device CreateDevice()
180183
private List CreatePatientList(List<string> patientIds, DateTime startDate, DateTime endDate)
181184
{
182185
var admittedPatients = new List();
186+
admittedPatients.Meta = new Meta { Profile = [ReportConstants.BundleSettings.CensusProfileUrl] };
183187
admittedPatients.Status = List.ListStatus.Current;
184188
admittedPatients.Mode = ListMode.Snapshot;
185189
admittedPatients.Extension.Add(new Extension()

DotNet/ServiceTests/UnitTests/Automation/ReportAbsManifestValidatorTests.cs

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public class ReportAbsManifestValidatorTests
1616
{
1717
private const string ExpectedStart = "2025-01-01T00:00:00Z";
1818
private const string ExpectedEnd = "2025-01-31T23:59:59Z";
19+
private const string DeviceProfile = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/nhsn-submitting-device";
20+
private const string PatientListProfile = "http://hl7.org/fhir/us/nhsn-dqm/StructureDefinition/poi-list";
1921

2022
[Fact]
2123
public async Task ValidateAllAsync_WithMockExternalAbsFromGeneratedData_Passes()
@@ -269,12 +271,67 @@ public async Task ValidateAllAsync_WithUnexpectedMeasureId_Fails()
269271
Assert.Contains("VALIDATION failed", ex.Message, StringComparison.OrdinalIgnoreCase);
270272
}
271273

274+
[Fact]
275+
public async Task ValidateAllAsync_WhenDeviceIsMissingProfile_Fails()
276+
{
277+
var output = new BufferingAutomationOutput();
278+
var (patientIds, bundles) = FhirBundleGenerator.Generate(output, patientCount: 1, totalResourcesPerPatient: 120);
279+
var patientId = patientIds.Single();
280+
281+
var mockExternalAbsResources = BuildMockExternalAbsResources(
282+
patientId, bundles, "MEASURE-UT-4", ExpectedStart, ExpectedEnd, deviceProfile: null);
283+
284+
var validator = new ReportAbsManifestValidator(output, CreatePipelineDataReader());
285+
286+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
287+
validator.ValidateAllAsync(
288+
mockExternalAbsResources,
289+
new[] { patientId },
290+
"MEASURE-UT-4",
291+
ExpectedStart,
292+
ExpectedEnd));
293+
294+
Assert.Contains(output.Lines, line =>
295+
line.Contains("Manifest Device is missing meta.profile", StringComparison.OrdinalIgnoreCase));
296+
}
297+
298+
[Fact]
299+
public async Task ValidateAllAsync_WhenPatientListProfileIsWrong_Fails()
300+
{
301+
var output = new BufferingAutomationOutput();
302+
var (patientIds, bundles) = FhirBundleGenerator.Generate(output, patientCount: 1, totalResourcesPerPatient: 120);
303+
var patientId = patientIds.Single();
304+
305+
var mockExternalAbsResources = BuildMockExternalAbsResources(
306+
patientId,
307+
bundles,
308+
"MEASURE-UT-5",
309+
ExpectedStart,
310+
ExpectedEnd,
311+
patientListProfile: "https://www.cdc.gov/nhsn/nhsn-measures/StructureDefinition/poi-list");
312+
313+
var validator = new ReportAbsManifestValidator(output, CreatePipelineDataReader());
314+
315+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
316+
validator.ValidateAllAsync(
317+
mockExternalAbsResources,
318+
new[] { patientId },
319+
"MEASURE-UT-5",
320+
ExpectedStart,
321+
ExpectedEnd));
322+
323+
Assert.Contains(output.Lines, line =>
324+
line.Contains("Manifest List meta.profile mismatch", StringComparison.OrdinalIgnoreCase));
325+
}
326+
272327
private static Dictionary<string, object> BuildMockExternalAbsResources(
273328
string patientId,
274329
IReadOnlyList<(string Name, string Json)> bundles,
275330
string measureId,
276331
string start,
277-
string end)
332+
string end,
333+
string? deviceProfile = DeviceProfile,
334+
string? patientListProfile = PatientListProfile)
278335
{
279336
var generatedPatientJson = ExtractGeneratedPatientJson(patientId, bundles);
280337

@@ -290,18 +347,18 @@ private static Dictionary<string, object> BuildMockExternalAbsResources(
290347
resourceType = "Organization",
291348
id = orgId
292349
}),
293-
JsonSerializer.Serialize(new
350+
SerializeWithOptionalProfile(deviceProfile, new Dictionary<string, object>
294351
{
295-
resourceType = "Device",
296-
id = deviceId
352+
["resourceType"] = "Device",
353+
["id"] = deviceId
297354
}),
298-
JsonSerializer.Serialize(new
355+
SerializeWithOptionalProfile(patientListProfile, new Dictionary<string, object>
299356
{
300-
resourceType = "List",
301-
id = "PatientList-UT",
302-
status = "current",
303-
mode = "snapshot",
304-
extension = new object[]
357+
["resourceType"] = "List",
358+
["id"] = "PatientList-UT",
359+
["status"] = "current",
360+
["mode"] = "snapshot",
361+
["extension"] = new object[]
305362
{
306363
new
307364
{
@@ -313,7 +370,7 @@ private static Dictionary<string, object> BuildMockExternalAbsResources(
313370
}
314371
}
315372
},
316-
entry = new object[]
373+
["entry"] = new object[]
317374
{
318375
new { item = new { reference = $"Patient/{patientId}" } }
319376
}
@@ -373,11 +430,12 @@ private static Dictionary<string, object> BuildAbsResourcesWithExtraOperationOut
373430
var manifestNdjson = string.Join("\n", new[]
374431
{
375432
JsonSerializer.Serialize(new { resourceType = "Organization", id = "Org-UT" }),
376-
JsonSerializer.Serialize(new { resourceType = "Device", id = "Device-UT" }),
433+
JsonSerializer.Serialize(new { resourceType = "Device", id = "Device-UT", meta = new { profile = new[] { DeviceProfile } } }),
377434
JsonSerializer.Serialize(new
378435
{
379436
resourceType = "List",
380437
id = "PatientList-UT",
438+
meta = new { profile = new[] { PatientListProfile } },
381439
status = "current",
382440
mode = "snapshot",
383441
extension = new object[]
@@ -437,6 +495,18 @@ private static Dictionary<string, object> BuildAbsResourcesWithExtraOperationOut
437495
};
438496
}
439497

498+
/// <summary>
499+
/// Serializes a manifest resource, adding meta.profile only when a profile is supplied so
500+
/// tests can model a resource that is missing it entirely.
501+
/// </summary>
502+
private static string SerializeWithOptionalProfile(string? profile, Dictionary<string, object> resource)
503+
{
504+
if (profile != null)
505+
resource["meta"] = new { profile = new[] { profile } };
506+
507+
return JsonSerializer.Serialize(resource);
508+
}
509+
440510
private static string ExtractGeneratedPatientJson(string patientId, IReadOnlyList<(string Name, string Json)> bundles)
441511
{
442512
foreach (var (_, bundleJson) in bundles)
@@ -474,11 +544,12 @@ private static Dictionary<string, object> BuildAbsResourcesForSingleReportableMe
474544
var manifestNdjson = string.Join("\n", new[]
475545
{
476546
JsonSerializer.Serialize(new { resourceType = "Organization", id = "Org-UT" }),
477-
JsonSerializer.Serialize(new { resourceType = "Device", id = "Device-UT" }),
547+
JsonSerializer.Serialize(new { resourceType = "Device", id = "Device-UT", meta = new { profile = new[] { DeviceProfile } } }),
478548
JsonSerializer.Serialize(new
479549
{
480550
resourceType = "List",
481551
id = "PatientList-UT",
552+
meta = new { profile = new[] { PatientListProfile } },
482553
status = "current",
483554
mode = "snapshot",
484555
extension = new object[]

0 commit comments

Comments
 (0)