diff --git a/lib/davinci_crd_test_kit/requirements/generated/crd_server_v221_requirements_coverage.csv b/lib/davinci_crd_test_kit/requirements/generated/crd_server_v221_requirements_coverage.csv index 0ff60314..3fcacf75 100644 --- a/lib/davinci_crd_test_kit/requirements/generated/crd_server_v221_requirements_coverage.csv +++ b/lib/davinci_crd_test_kit/requirements/generated/crd_server_v221_requirements_coverage.csv @@ -109,7 +109,7 @@ hl7.fhir.us.davinci-crd_2.2.1,resp-32,https://hl7.org/fhir/us/davinci-crd/2.2.1/ hl7.fhir.us.davinci-crd_2.2.1,resp-33,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-33,"Where multiple repetitions apply to the same coverage, they **MAY** have the same coverage-assertion-ids and satisfied-pa-ids (if present).",MAY,CRD Server,true,,,"","" hl7.fhir.us.davinci-crd_2.2.1,resp-35,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-35,"However, CRD servers **SHALL NOT** send a `systemAction` to update the order unless something is new or changed.",SHALL NOT,CRD Server,false,,,"","" hl7.fhir.us.davinci-crd_2.2.1,resp-36,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-36,CRD servers **SHOULD** take into account the previous decision in deciding how much processing is necessary before returning a response.,SHOULD,CRD Server,false,,,"","" -hl7.fhir.us.davinci-crd_2.2.1,resp-37,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-37,"When returning a `systemAction` to update a resource with the ""Coverage Information"" response type, the resource content **SHALL NOT** make changes to any data elements other than adding or modifying coverage-information extensions.",SHALL NOT,CRD Server,true,,,"","" +hl7.fhir.us.davinci-crd_2.2.1,resp-37,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-37,"When returning a `systemAction` to update a resource with the ""Coverage Information"" response type, the resource content **SHALL NOT** make changes to any data elements other than adding or modifying coverage-information extensions.",SHALL NOT,CRD Server,true,,,"3.2.3.07, 3.3.3.07, 3.4.3.07","crd_server_v221-crd_v221_server_hooks-crd_v221_server_encounter_start-Group03-crd_v221_coverage_info_system_action_validation, crd_server_v221-crd_v221_server_hooks-crd_v221_server_encounter_discharge-Group03-crd_v221_coverage_info_system_action_validation, crd_server_v221-crd_v221_server_hooks-crd_v221_server_order_select-Group03-crd_v221_coverage_info_system_action_validation" hl7.fhir.us.davinci-crd_2.2.1,resp-38,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-38,"If a *coverage-information* extension indicates the need to collect additional information (via 'doc-needed'), the extension **SHOULD** include a reference to the questionnaire(s) to be completed.",SHOULD,CRD Server,true,,,"","" hl7.fhir.us.davinci-crd_2.2.1,resp-39,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-39,CRD Servers **SHOULD** access information via the FHIR Rest API as part of CRD and only fall back to data capture via DTR when computable access is not possible.,SHOULD,CRD Server,false,,,"","" hl7.fhir.us.davinci-crd_2.2.1,resp-42,https://hl7.org/fhir/us/davinci-crd/2.2.1/en/cards.html#ci-c-resp-42,The CRD server **SHOULD** either prompt for the additional needed information using DTR or return a coverage-information extension indicating that the patient is not covered with a reason indicating the issue (e.g. the member could not be found/resolved).,SHOULD,CRD Server,false,,,"","" diff --git a/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/coverage_information_system_action_validation_test.rb b/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/coverage_information_system_action_validation_test.rb index e70fb23e..a30c1442 100644 --- a/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/coverage_information_system_action_validation_test.rb +++ b/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/coverage_information_system_action_validation_test.rb @@ -1,11 +1,17 @@ require_relative '../../server_hook_request_validation' require_relative '../../server_test_helper' +require_relative '../../server_hook_helper' +require_relative 'hook_request_resource_resolution' module DaVinciCRDTestKit module V221 class CoverageInformationSystemActionValidationTest < Inferno::Test include DaVinciCRDTestKit::ServerHookRequestValidation include DaVinciCRDTestKit::ServerTestHelper + include DaVinciCRDTestKit::ServerHookHelper + include HookRequestResourceResolution + + COVERAGE_INFO_EXT_URL = 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information'.freeze title 'All Coverage Information system actions received are valid' id :crd_v221_coverage_info_system_action_validation @@ -14,6 +20,8 @@ class CoverageInformationSystemActionValidationTest < Inferno::Test system actions received. It verifies the following for each action: - The action type is `update`. - The resource within the action conforms its respective FHIR profile. + - The resource does not change any data elements other than adding or modifying + the `coverage-information` extension. Additionally, the test examines the `coverage-info` extensions within the resource to ensure that: - Entries referencing differing coverage have distinct `coverage-assertion-ids` and `satisfied-pa-ids` @@ -23,11 +31,13 @@ class CoverageInformationSystemActionValidationTest < Inferno::Test ) verifies_requirements 'hl7.fhir.us.davinci-crd_2.2.1@resp-32', + 'hl7.fhir.us.davinci-crd_2.2.1@resp-37', 'hl7.fhir.us.davinci-crd_2.2.1@resp-47', 'hl7.fhir.us.davinci-crd_2.2.1@resp-48', 'hl7.fhir.us.davinci-crd_2.2.1@resp-52' 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 } @@ -42,7 +52,7 @@ def find_extension_value(extension, url, *properties) def extract_and_group_coverage_info(resource) resource.extension.each_with_object({}) do |extension, grouped_extensions| - next unless extension.url == 'http://hl7.org/fhir/us/davinci-crd/StructureDefinition/ext-coverage-information' + next unless extension.url == COVERAGE_INFO_EXT_URL coverage_key = find_extension_value(extension, 'coverage', 'valueReference', 'reference') grouped_extensions[coverage_key] ||= [] @@ -93,6 +103,52 @@ def different_coverage_conformance_error_msg(resource_ref, id_name) "#{resource_ref}: extensions referencing differing coverage SHALL have distinct #{id_name}." end + def normalize_value(value) + case value + when Hash + value.transform_values { |child| normalize_value(child) } + when Array + value.map { |child| normalize_value(child) }.sort_by(&:to_json) + else + value + end + end + + def strip_coverage_info_extensions(resource_hash) + normalized_hash = resource_hash.deep_dup + return normalized_hash unless normalized_hash['extension'].is_a?(Array) + + normalized_hash['extension'] = normalized_hash['extension'].reject do |extension| + extension['url'] == COVERAGE_INFO_EXT_URL + end + normalized_hash.delete('extension') if normalized_hash['extension'].empty? + normalized_hash + end + + def verify_only_coverage_info_changed(action) + request = matching_request_for_action(action) + source_resource = find_action_source_resource(action, request) + updated_resource_hash = action['resource'] + resource_ref = "#{updated_resource_hash['resourceType']}/#{updated_resource_hash['id']}" + unless source_resource + messages << { + type: 'warning', + message: 'Inferno could not resolve the original source resource for Coverage Information systemAction ' \ + "targeting #{resource_ref}, so it could not verify that only coverage-information extensions " \ + 'were changed.' + } + return + end + + source_resource_hash = source_resource.to_hash + + source_without_coverage_info = normalize_value(strip_coverage_info_extensions(source_resource_hash)) + updated_without_coverage_info = normalize_value(strip_coverage_info_extensions(updated_resource_hash)) + + assert source_without_coverage_info == updated_without_coverage_info, + "#{resource_ref}: resource content changed outside the coverage-information extension." + end + def coverage_info_system_action_check(coverage_info_system_action) type = coverage_info_system_action['type'] assert type, '`type` field is missing.' @@ -104,9 +160,11 @@ def coverage_info_system_action_check(coverage_info_system_action) grouped_coverage_info = extract_and_group_coverage_info(resource) multiple_extensions_conformance_check(grouped_coverage_info, resource) + verify_only_coverage_info_changed(coverage_info_system_action) end run do + load_tagged_requests(tested_hook_name) parsed_coverage_info = parse_json(coverage_info) error_messages = [] parsed_coverage_info.each do |action| diff --git a/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/hook_request_resource_resolution.rb b/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/hook_request_resource_resolution.rb new file mode 100644 index 00000000..ee710989 --- /dev/null +++ b/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/hook_request_resource_resolution.rb @@ -0,0 +1,137 @@ +module DaVinciCRDTestKit + module V221 + module HookRequestResourceResolution + def mock_ehr_bundle_resource + @mock_ehr_bundle_resource ||= JSON.parse(mock_ehr_bundle) if mock_ehr_bundle.present? + rescue JSON::ParserError + nil + end + + def matching_request_for_action(action) + requests.find do |request| + response = JSON.parse(request.response_body) + request.status == 200 && Array(response['systemActions']).any? { |candidate| candidate == action } + rescue JSON::ParserError + false + end + end + + # Resolve the original resource being updated by a systemAction. + # - appointment-book: look in context.appointments; if the action targets a ServiceRequest, + # follow the Appointment basedOn reference and resolve that from prefetch or mock EHR data + # - order-sign/order-select: look in context.draftOrders + # - other hooks: resolve from prefetch or mock EHR data because context may carry only ids/references + def find_action_source_resource(action, request) + action_resource = action['resource'] + return unless action_resource.is_a?(Hash) + + target_type = action_resource['resourceType'] + target_id = action_resource['id'] + request_body = parse_request_body(request) + return unless target_type.present? && target_id.present? && request_body + + hook_context_resource(request_body, target_type, target_id) || + fallback_source_resource(request_body, target_type, target_id) + end + + def find_appointment_book_resource(request_body, target_type, target_id) + appointments_bundle = request_body.dig('context', 'appointments') + find_resource_in_bundle(appointments_bundle, target_type, target_id) || + appointment_book_service_request(request_body, appointments_bundle, target_id) + end + + def find_draft_orders_resource(request_body, target_type, target_id) + draft_orders_bundle = request_body.dig('context', 'draftOrders') + find_resource_in_bundle(draft_orders_bundle, target_type, target_id) + end + + def fallback_source_resource(request_body, target_type, target_id) + find_resource_in_prefetch(request_body, target_type, target_id) || + find_resource_in_bundle(mock_ehr_bundle_resource, target_type, target_id) + end + + def hook_context_resource(request_body, target_type, target_id) + case tested_hook_name + when 'appointment-book' + find_appointment_book_resource(request_body, target_type, target_id) + when 'order-sign', 'order-select' + find_draft_orders_resource(request_body, target_type, target_id) + end + end + + def appointment_book_service_request(request_body, appointments_bundle_hash, target_id) + target_type = 'ServiceRequest' + appointments_bundle = parse_bundle(appointments_bundle_hash) + appointment = (appointments_bundle&.entry || []) + .filter_map(&:resource) + .find { |candidate| appointment_based_on_matches_target?(candidate, target_type, target_id) } + return unless appointment + + find_resource_in_prefetch(request_body, target_type, target_id) || + find_resource_in_bundle(mock_ehr_bundle_resource, target_type, target_id) + end + + def appointment_based_on_matches_target?(appointment, target_type, target_id) + Array(appointment.basedOn).any? do |reference| + reference_parts(reference.reference) == [target_type, target_id] + end + end + + def find_resource_in_prefetch(request_body, target_type, target_id) + Array(request_body['prefetch']&.values).each do |prefetched_value| + if prefetched_value.is_a?(Hash) && + prefetched_value['resourceType'] == target_type && + prefetched_value['id'] == target_id + return FHIR.from_contents(prefetched_value.to_json) + end + + resource = find_resource_in_bundle(prefetched_value, target_type, target_id) + return resource if resource + end + + nil + end + + def find_resource_by_reference(request_body, reference) + target_type, target_id = reference_parts(reference) + return unless target_type.present? && target_id.present? + + find_resource_in_prefetch(request_body, target_type, target_id) || + find_resource_in_bundle(mock_ehr_bundle_resource, target_type, target_id) + end + + def find_resource_in_bundle(bundle_hash, target_type, target_id) + bundle = parse_bundle(bundle_hash) + return unless bundle&.entry + + bundle.entry + .filter_map(&:resource) + .find { |resource| resource.resourceType == target_type && resource.id == target_id } + end + + def reference_parts(reference) + return if reference.blank? + + parts = reference.split('/') + return unless parts.length >= 2 + + [parts[-2], parts[-1]] + end + + def parse_bundle(bundle_hash) + bundle = FHIR.from_contents(bundle_hash.to_json) + bundle if bundle.is_a?(FHIR::Bundle) + rescue StandardError + nil + end + + def parse_request_body(request) + return unless request&.request_body.present? + + JSON.parse(request.request_body) + rescue JSON::ParserError + nil + end + end + end +end diff --git a/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/order_dispatch_coverage_information_test.rb b/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/order_dispatch_coverage_information_test.rb index e3215ac2..8949fc80 100644 --- a/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/order_dispatch_coverage_information_test.rb +++ b/lib/davinci_crd_test_kit/server/v2.2.1/verify_response/order_dispatch_coverage_information_test.rb @@ -1,58 +1,35 @@ require_relative 'all_responses_include_coverage_information_test' +require_relative 'hook_request_resource_resolution' module DaVinciCRDTestKit module V221 class OrderDispatchCoverageInformationTest < AllResponsesIncludeCoverageInformationTest + include HookRequestResourceResolution + id :crd_v221_order_dispatch_coverage_information input :invoked_hook input :mock_ehr_bundle - def resource_matches_reference?(reference, resource) - reference.resource_type == resource.resourceType && reference.reference_id == resource.id - end + def order_resources(hook_call_body) + references = hook_call_body.dig('context', 'dispatchedOrders') + resources = [] + unmatched_references = [] - def mock_ehr_bundle_resources - FHIR.from_contents(mock_ehr_bundle) - .entry - .map(&:resource) - end + Array(references).each do |reference| + resource = find_resource_by_reference(hook_call_body, reference) - def prefetch_resources(hook_call_body) - (hook_call_body['prefetch'] || {}) - .values - .compact - .map { |resource_hash| FHIR.from_contents(resource_hash.to_json) } - .flat_map do |resource| - if resource.is_a? FHIR::Bundle - resource.entry.map(&:resource) + if resource.present? + resources << resource else - resource + unmatched_references << reference end end - end - - def order_resources(hook_call_body) - references = - hook_call_body - .dig('context', 'dispatchedOrders') - .map { |reference| FHIR::Reference.new(reference:) } - - resources = - (mock_ehr_bundle_resources + prefetch_resources(hook_call_body)) - .select do |resource| - references.any? { |reference| resource_matches_reference?(reference, resource) } - end - - unmatched_references = - references.reject do |reference| - resources.any? { |resource| resource_matches_reference?(reference, resource) } - end skip_if unmatched_references.present?, 'The following `dispatchedOrders` are not included in the Mock EHR Data input or prefetch: ' \ - "#{unmatched_references.map(&:reference).join(', ')}" + "#{unmatched_references.join(', ')}" resources end diff --git a/spec/davinci_crd_test_kit/v2.2.1/coverage_information_system_action_validation_test_spec.rb b/spec/davinci_crd_test_kit/v2.2.1/coverage_information_system_action_validation_test_spec.rb new file mode 100644 index 00000000..a2344226 --- /dev/null +++ b/spec/davinci_crd_test_kit/v2.2.1/coverage_information_system_action_validation_test_spec.rb @@ -0,0 +1,171 @@ +RSpec.describe DaVinciCRDTestKit::V221::CoverageInformationSystemActionValidationTest do + let(:suite_id) { 'crd_server' } + let(:runnable) { described_class } + let(:results_repo) { Inferno::Repositories::Results.new } + let(:request_result) { repo_create(:result, test_session_id: test_session.id) } + let(:request_url) { 'http://example.com/cds-services/service' } + let(:valid_coverage_info_system_action) do + json = File.read(File.join(__dir__, '..', '..', 'fixtures', 'crd_authorization_hook_response.json')) + JSON.parse(json)['systemActions'].first + end + let(:coverage_information_extension) do + valid_coverage_info_system_action.dig('resource', 'extension', 0).deep_dup + end + let(:order_sign_request_body) do + JSON.parse(File.read(File.join(__dir__, '..', '..', 'fixtures', 'order_sign_hook_request.json'))) + end + let(:order_dispatch_request_body) do + JSON.parse(File.read(File.join(__dir__, '..', '..', 'fixtures', 'order_dispatch_hook_request.json'))) + end + let(:appointment_book_request_body) do + JSON.parse(File.read(File.join(__dir__, '..', '..', 'fixtures', 'appointment_book_hook_request.json'))) + end + let(:service_request_resource) do + JSON.parse(File.read(File.join(__dir__, '..', '..', 'fixtures', 'crd_service_request_example.json'))) + end + + before do + allow_any_instance_of(runnable).to receive(:assert_valid_resource).and_return(true) + end + + def entity_result_message + results_repo.current_results_for_test_session_and_runnables(test_session.id, [runnable]) + .first + .messages + .first + end + + def create_hook_request(hook_name:, request_body:, actions:) + repo_create( + :request, + result: request_result, + direction: 'outgoing', + url: request_url, + request_body: request_body.to_json, + response_body: { 'systemActions' => actions }.to_json, + status: 200, + tags: [hook_name] + ) + end + + def stub_hook_requests(hook_name:, request_body:, actions:) + allow_any_instance_of(runnable).to receive(:tested_hook_name).and_return(hook_name) + create_hook_request(hook_name:, request_body:, actions:) + end + + def coverage_info_action(resource) + { + 'type' => 'update', + 'description' => "Added coverage information to #{resource['resourceType']} resource.", + 'resource' => resource + } + end + + def mock_bundle_with(*resources) + { + 'resourceType' => 'Bundle', + 'type' => 'collection', + 'entry' => resources.map { |resource| { 'resource' => resource } } + } + end + + it 'passes when only the coverage-information extension changes for order-sign' do + original_resource = order_sign_request_body.dig('context', 'draftOrders', 'entry', 0, 'resource') + updated_resource = original_resource.deep_dup + updated_resource['extension'] << coverage_information_extension.deep_dup + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'order-sign', request_body: order_sign_request_body, actions: [action]) + + result = run(runnable, coverage_info: [action].to_json) + + expect(result.result).to eq('pass'), result.result_message + end + + it 'fails when a non-coverage-information field changes' do + original_resource = order_sign_request_body.dig('context', 'draftOrders', 'entry', 0, 'resource') + updated_resource = original_resource.deep_dup + updated_resource['status'] = 'active' + updated_resource['extension'] << coverage_information_extension.deep_dup + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'order-sign', request_body: order_sign_request_body, actions: [action]) + + result = run(runnable, coverage_info: [action].to_json) + + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/changed outside the coverage-information extension/) + end + + it 'fails when a non-coverage-information extension changes' do + original_resource = order_sign_request_body.dig('context', 'draftOrders', 'entry', 0, 'resource') + updated_resource = original_resource.deep_dup + updated_resource['extension'].first['valueString'] = 'updated-value' + updated_resource['extension'] << coverage_information_extension.deep_dup + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'order-sign', request_body: order_sign_request_body, actions: [action]) + + result = run(runnable, coverage_info: [action].to_json) + + expect(result.result).to eq('fail') + expect(entity_result_message.message).to match(/changed outside the coverage-information extension/) + end + + it 'passes when resolving the original order-dispatch resource from the mock EHR bundle' do + updated_resource = service_request_resource.deep_dup + updated_resource['extension'] = [coverage_information_extension.deep_dup] + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'order-dispatch', request_body: order_dispatch_request_body, actions: [action]) + + result = run( + runnable, + coverage_info: [action].to_json, + mock_ehr_bundle: mock_bundle_with(service_request_resource).to_json + ) + + expect(result.result).to eq('pass'), result.result_message + end + + it 'passes when resolving the original order-dispatch resource from prefetch' do + request_body = order_dispatch_request_body.deep_dup + request_body['prefetch'] = { 'serviceRequest' => service_request_resource.deep_dup } + updated_resource = service_request_resource.deep_dup + updated_resource['extension'] = [coverage_information_extension.deep_dup] + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'order-dispatch', request_body:, actions: [action]) + + result = run(runnable, coverage_info: [action].to_json) + + expect(result.result).to eq('pass'), result.result_message + end + + it 'passes when appointment-book updates the basedOn ServiceRequest instead of the Appointment' do + appointment_request = appointment_book_request_body.deep_dup + appointment_request.dig('context', 'appointments', 'entry', 0, 'resource')['basedOn'] = [ + { 'reference' => 'ServiceRequest/example' } + ] + updated_resource = service_request_resource.deep_dup + updated_resource['extension'] = [coverage_information_extension.deep_dup] + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'appointment-book', request_body: appointment_request, actions: [action]) + + result = run( + runnable, + coverage_info: [action].to_json, + mock_ehr_bundle: mock_bundle_with(service_request_resource).to_json + ) + + expect(result.result).to eq('pass'), result.result_message + end + + it 'warns when it cannot resolve the original source resource for comparison' do + updated_resource = service_request_resource.deep_dup + updated_resource['extension'] = [coverage_information_extension.deep_dup] + action = coverage_info_action(updated_resource) + stub_hook_requests(hook_name: 'order-dispatch', request_body: order_dispatch_request_body, actions: [action]) + + result = run(runnable, coverage_info: [action].to_json) + + expect(result.result).to eq('pass'), result.result_message + expect(entity_result_message.type).to eq('warning') + expect(entity_result_message.message).to match(/could not resolve the original source resource/i) + end +end