Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions config/presets/inferno_crd_client_suite_v221.json.erb

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions config/presets/inferno_crd_server_suite_v221.json.erb

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
"type": "absolute"
}
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@
"display": "Coverage Information"
}
},
"suggestions": [
{
"label": "Inferno placeholder suggestion",
"uuid": "5abc166b-c50f-4df4-bdb1-2b0b62cb8e55"
}
],
"selectionBehavior": "any",
"links": [
{
"label": "Inferno",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,56 @@ def validate_card_against_logical_model(card, response_index, request_body, card
conforms_to_logical_model?({ 'cards' => [card] }, logical_model_url(profile_name),
add_messages_to_runnable: false, validator_response_details: validation_issues)

validate_summary_length(card, label)
validate_suggestion_uuid_presence(card, label) unless ['v201', '2.0.1'].include? ig_semver
validate_no_smart_suggestions(card, label) if card_type == CardsIdentification::LAUNCH_SMART_APP_RESPONSE_TYPE

error_prefix = "#{label} (#{card_type || 'uncategorized'}): "
filtered_issues = manually_check_card_specific_errors(card, validation_issues, card_type,
request_body, error_prefix, ig_semver)
add_messages_not_excluded(filtered_issues, error_prefix)
end

def validate_summary_length(card, label)
return unless card['summary'].is_a? String

summary_length = card['summary'].length

return if summary_length < 140

add_message(
'error',
"#{label} summary length of #{summary_length} characters is longer than " \
'the maximum allowed of 139 characters.'
)
end

def validate_suggestion_uuid_presence(card, label)
return unless card['suggestions'].presence.is_a? Array
return unless card['suggestions'].all?(Hash)

card['suggestions'].each_with_index do |suggestion, index|
next if suggestion['uuid'].present?

add_message(
'error',
"#{label} suggestion #{index + 1} does not contain a `uuid`"
)
end
end

def validate_no_smart_suggestions(card, label)
suggestion_count = card['suggestions']&.length || 0

return if suggestion_count.zero?

add_message(
'error',
"#{label} CDSHooksResponse.cards.suggestions: max allowed = 0, but found #{suggestion_count} " \
'(from http://hl7.org/fhir/us/davinci-crd/StructureDefinition/CRDHooksResponse-launchSMART|2.2.1)'
)
end

def validate_system_action_against_logical_model(action, response_index, request_body, action_index, ig_semver)
label = logical_model_entity_label(response_index, action_index, 'systemAction')
unless action.is_a?(Hash)
Expand All @@ -104,12 +148,109 @@ def validate_system_action_against_logical_model(action, response_index, request
conforms_to_logical_model?({ 'systemActions' => [action] }, logical_model_url(profile_name),
add_messages_to_runnable: false, validator_response_details: validation_issues)

validation_issues
.reject! do |issue|
issue.message.match?(%r{The extension definition http://hl7\.org/fhir/us/davinci-crd/StructureDefinition/CDSHookServiceResponseExtensionIfNoneExist|2\.2\.1 defines the contexts of use as.*CDSHooksResponse.systemActions.extension\z}) # rubocop:disable Layout/LineLength
end

if action_type == CardsIdentification::COVERAGE_INFORMATION_RESPONSE_TYPE
check_multiple_coverage_info_extension_conformance(action)
end
error_prefix = "#{label} (#{action_type || 'uncategorized'}): "
filtered_issues = manually_check_action_specific_errors(action, validation_issues, action_type,
request_body, error_prefix, ig_semver)
add_messages_not_excluded(filtered_issues, error_prefix)
end

def check_multiple_coverage_info_extension_conformance(action)
resource = FHIR.from_contents(action['resource'].to_json)

grouped_coverage_info = extract_and_group_coverage_info(resource)
multiple_extensions_conformance_check(grouped_coverage_info, resource)
end

def extract_and_group_coverage_info(resource)
resource.extension.each_with_object({}) do |extension, grouped_extensions|
next unless extension.url == COVERAGE_INFO_EXT_URL

coverage_key = find_extension_value(extension, 'coverage', 'valueReference', 'reference')
grouped_extensions[coverage_key] ||= []
grouped_extensions[coverage_key] << extension
end
end

# For the same coverage, ensure coverage-assertion-ids and satisfied-pa-ids are the same.
# For different coverages, ensure coverage-assertion-ids and satisfied-pa-ids are distinct.
def multiple_extensions_conformance_check(grouped_coverage_info, resource)
resource_ref = "#{resource.resourceType}/#{resource.id}"
assertion_ids_across_coverages = Set.new
pa_ids_across_coverages = Set.new

grouped_coverage_info.each do |coverage, extensions|
coverage_assertion_ids = collect_extensions_id(extensions, 'coverage-assertion-id', 'valueString').uniq
satisfied_pa_ids = collect_extensions_id(extensions, 'satisfied-pa-id', 'valueString').uniq.compact
if coverage_assertion_ids.length != 1
add_message(
'error',
same_coverage_conformance_error_msg(resource_ref, coverage, 'coverage-assertion-ids')
)
end

if satisfied_pa_ids.length > 1
add_message(
'error',
same_coverage_conformance_error_msg(resource_ref, coverage, 'satisfied-pa-ids')
)
end

assertion_id = coverage_assertion_ids.first
if assertion_ids_across_coverages.include?(assertion_id)
add_message(
'error',
different_coverage_conformance_error_msg(resource_ref, 'coverage-assertion-ids')
)
end

assertion_ids_across_coverages.add(assertion_id)
pa_id = satisfied_pa_ids.first
next unless pa_id

if pa_ids_across_coverages.include?(pa_id)
add_message(
'error',
different_coverage_conformance_error_msg(resource_ref, 'satisfied-pa-ids')
)
end

pa_ids_across_coverages.add(pa_id)
end
end

def collect_extensions_id(extensions, url, *properties)
extensions.map do |extension|
find_extension_value(extension, url, *properties)
end
end

def find_extension_value(extension, url, *properties)
found_extension = extension.extension.find { |ext| ext.url == url }
return nil unless found_extension

properties.reduce(found_extension) do |current, prop|
return current unless current.respond_to?(prop)

current.send(prop)
end
end

def same_coverage_conformance_error_msg(resource_ref, coverage, id_name)
"#{resource_ref}: extension has multiple repetitions of coverage `#{coverage}` with different #{id_name}."
end

def different_coverage_conformance_error_msg(resource_ref, id_name)
"#{resource_ref}: extensions referencing differing coverage SHALL have distinct #{id_name}."
end

def add_messages_not_excluded(issues, error_prefix)
issues.each do |issue|
next if issue.filtered || logical_model_extension_issue?(issue)
Expand All @@ -133,14 +274,16 @@ def logical_model_extension_issue?(issue)
def manually_check_card_specific_errors(card, validation_issues, card_type, request_body, error_prefix,
ig_semver)
case card_type
when DaVinciCRDTestKit::CardsIdentification::FORM_COMPLETION_RESPONSE_TYPE
when CardsIdentification::FORM_COMPLETION_RESPONSE_TYPE
manually_check_form_completion_errors(card, validation_issues, error_prefix)
when DaVinciCRDTestKit::CardsIdentification::PROPOSE_ALTERNATIVE_REQUEST_RESPONSE_TYPE
when CardsIdentification::PROPOSE_ALTERNATIVE_REQUEST_RESPONSE_TYPE
manually_check_propose_alternative_errors(card, validation_issues, request_body,
error_prefix, ig_semver)
when DaVinciCRDTestKit::CardsIdentification::ADDITIONAL_ORDERS_RESPONSE_TYPE
when CardsIdentification::ADDITIONAL_ORDERS_RESPONSE_TYPE
manually_check_additional_orders_errors(card, validation_issues, request_body,
error_prefix, ig_semver)
when CardsIdentification::LAUNCH_SMART_APP_RESPONSE_TYPE
manually_check_launch_smart_app_errors(validation_issues)
else
validation_issues
end
Expand Down Expand Up @@ -194,13 +337,31 @@ def check_questionnaire_actions(card, error_message, error_prefix)
add_message('error', "#{message_prefix}Questionnaire must have an id.")
end

def manually_check_launch_smart_app_errors(validation_issues)
validation_issues.reject do |issue|
issue.message.match?(/CDSHooksResponse.cards.suggestions: minimum required = 1, but only found 0/)
end
end

def manually_check_propose_alternative_errors(card, validation_issues, request_body,
error_prefix, ig_semver)
no_resource_issues = manually_check_action_resources_for_order_profile_conformance(card,
validation_issues,
request_body,
error_prefix,
ig_semver)

no_resource_issues.each do |issue|
next unless issue.message.match?(/Constraint failed: crd-respar-1/)

new_message =
"#{issue.message}. In some cases [additional create " \
'actions](https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#propose-alternate-request-response-type) ' \
'are permitted. Manually check the suggestion to verify whether this is an actual error.'
issue.instance_variable_set(:@severity, 'warning')
issue.instance_variable_set(:@message, new_message)
end

no_resource_issues.reject do |issue|
issue.message.match?(/but is fixed to 'create' in the profile/)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,70 +39,6 @@ class CoverageInformationSystemActionValidationTest < Inferno::Test
input :coverage_info
input :mock_ehr_bundle, optional: true

def find_extension_value(extension, url, *properties)
found_extension = extension.extension.find { |ext| ext.url == url }
return nil unless found_extension

properties.reduce(found_extension) do |current, prop|
return current unless current.respond_to?(prop)

current.send(prop)
end
end

def extract_and_group_coverage_info(resource)
resource.extension.each_with_object({}) do |extension, grouped_extensions|
next unless extension.url == COVERAGE_INFO_EXT_URL

coverage_key = find_extension_value(extension, 'coverage', 'valueReference', 'reference')
grouped_extensions[coverage_key] ||= []
grouped_extensions[coverage_key] << extension
end
end

# For the same coverage, ensure coverage-assertion-ids and satisfied-pa-ids are the same.
# For different coverages, ensure coverage-assertion-ids and satisfied-pa-ids are distinct.
def multiple_extensions_conformance_check(grouped_coverage_info, resource)
resource_ref = "#{resource.resourceType}/#{resource.id}"
assertion_ids_across_coverages = Set.new
pa_ids_across_coverages = Set.new

grouped_coverage_info.each do |coverage, extensions|
coverage_assertion_ids = collect_extensions_id(extensions, 'coverage-assertion-id', 'valueString').uniq
satisfied_pa_ids = collect_extensions_id(extensions, 'satisfied-pa-id', 'valueString').uniq.compact
assert coverage_assertion_ids.length == 1,
same_coverage_conformance_error_msg(resource_ref, coverage, 'coverage-assertion-ids')

assert satisfied_pa_ids.length <= 1,
same_coverage_conformance_error_msg(resource_ref, coverage, 'satisfied-pa-ids')

assertion_id = coverage_assertion_ids.first
assert !assertion_ids_across_coverages.include?(assertion_id),
different_coverage_conformance_error_msg(resource_ref, 'coverage-assertion-ids')
assertion_ids_across_coverages.add(assertion_id)
pa_id = satisfied_pa_ids.first
next unless pa_id

assert !pa_ids_across_coverages.include?(pa_id),
different_coverage_conformance_error_msg(resource_ref, 'satisfied-pa-ids')
pa_ids_across_coverages.add(pa_id)
end
end

def collect_extensions_id(extensions, url, *properties)
extensions.map do |extension|
find_extension_value(extension, url, *properties)
end
end

def same_coverage_conformance_error_msg(resource_ref, coverage, id_name)
"#{resource_ref}: extension has multiple repetitions of coverage `#{coverage}` with different #{id_name}."
end

def different_coverage_conformance_error_msg(resource_ref, id_name)
"#{resource_ref}: extensions referencing differing coverage SHALL have distinct #{id_name}."
end

def verify_only_coverage_info_changed(action)
request = matching_request_for_action(action)
source_resource = find_action_source_resource(action, request)
Expand Down Expand Up @@ -131,8 +67,6 @@ def coverage_info_system_action_check(coverage_info_system_action)
profile_url = structure_definition_map('v221')[resource.resourceType]
resource_is_valid?(resource:, profile_url:)

grouped_coverage_info = extract_and_group_coverage_info(resource)
multiple_extensions_conformance_check(grouped_coverage_info, resource)
Comment thread
tstrass marked this conversation as resolved.
verify_only_coverage_info_changed(coverage_info_system_action)
end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,11 @@ class ServiceResponseValidationTest < Inferno::Test
title 'Service responses contain valid cards and systemActions'
id :crd_v221_service_response_validation
description %(
As per the [CDS Hooks spec section on CDS Service
Response](https://cds-hooks.hl7.org/2.0/#cds-service-response), a
successful server's response to a service request must be a JSON object
containing a `cards` array and optionally a `systemActions` array.
This test validates the responses against the [CRD v2.2.1 logical
models](https://hl7.org/fhir/us/davinci-crd/2.2.1/en/artifacts.html#4).

Each card must contain the following required fields: `summary`, `indicator`, and `source`.
The required fields must have a valid data structure.
This test implements [corrections to errors in the logical
models](https://github.com/inferno-framework/davinci-crd-test-kit/wiki/Logical-Model-Validation-Changes).
)

verifies_requirements 'hl7.fhir.us.davinci-crd_2.2.1@resp-4',
Expand Down Expand Up @@ -77,12 +75,18 @@ def perform_system_actions_validation(system_actions, response_index)

successful_requests.each_with_index do |request, index|
service_response = JSON.parse(request.response_body)

perform_cards_validation(service_response['cards'], service_response['systemActions'].present?, 'v221', index)

perform_system_actions_validation(service_response['systemActions'], index)

# perform_response_logical_model_validation(service_response['cards'], service_response['systemActions'],
# index)
perform_response_logical_model_validation(
service_response['cards'],
service_response['systemActions'],
request.request_body,
index,
'2.2.1'
)
rescue JSON::ParserError
add_message('error', "Invalid JSON: server response #{index + 1} is not valid JSON.")
end
Expand Down
Loading
Loading