From da2ec9493332ceff6b58c69d8218f853bd840178 Mon Sep 17 00:00:00 2001 From: Elsa Date: Tue, 5 Nov 2024 10:21:45 -0500 Subject: [PATCH] Add USCore 3.1.1 test group, CRD profile tests --- Gemfile.lock | 6 + davinci_dtr_test_kit.gemspec | 2 + .../date_search_validation.rb | 112 +++ .../dtr_light_ehr_suite.rb | 135 +++ .../fhir_resource_navigation.rb | 166 ++++ lib/davinci_dtr_test_kit/group_metadata.rb | 113 +++ lib/davinci_dtr_test_kit/primitive_type.rb | 5 + .../communication_request_read.rb | 29 + .../communication_request_validation.rb | 34 + .../coverage/coverage_context_search.rb | 41 + .../coverage/coverage_patient_search.rb | 59 ++ .../profiles/coverage/coverage_read.rb | 29 + .../profiles/coverage/coverage_validation.rb | 34 + .../profiles/coverage/metadata.yml | 173 ++++ .../device_request/device_request_read.rb | 29 + .../device_request_validation.rb | 34 + .../profiles/encounter/encounter_read.rb | 29 + .../encounter/encounter_validation.rb | 34 + .../medication_request_read.rb | 29 + .../medication_request_validation.rb | 34 + .../nutrition_order/nutrition_order_read.rb | 43 + .../nutrition_order_validation.rb | 34 + .../service_request/service_request_read.rb | 29 + .../service_request_validation.rb | 34 + .../profiles/task/task_read.rb | 29 + .../profiles/task/task_validation.rb | 34 + .../vision_prescription_read.rb | 29 + .../vision_prescription_validation.rb | 34 + lib/davinci_dtr_test_kit/read_test.rb | 15 + .../resource_search_param_checker.rb | 136 +++ lib/davinci_dtr_test_kit/search_test.rb | 849 ++++++++++++++++++ .../search_test_properties.rb | 56 ++ lib/davinci_dtr_test_kit/special_cases.rb | 66 ++ 33 files changed, 2515 insertions(+) create mode 100644 lib/davinci_dtr_test_kit/date_search_validation.rb create mode 100644 lib/davinci_dtr_test_kit/fhir_resource_navigation.rb create mode 100644 lib/davinci_dtr_test_kit/group_metadata.rb create mode 100644 lib/davinci_dtr_test_kit/primitive_type.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/communication_request/communication_request_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/communication_request/communication_request_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/coverage/coverage_context_search.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/coverage/coverage_patient_search.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/coverage/coverage_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/coverage/coverage_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/coverage/metadata.yml create mode 100644 lib/davinci_dtr_test_kit/profiles/device_request/device_request_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/device_request/device_request_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/encounter/encounter_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/encounter/encounter_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/medication_request/medication_request_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/medication_request/medication_request_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/nutrition_order/nutrition_order_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/nutrition_order/nutrition_order_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/service_request/service_request_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/service_request/service_request_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/task/task_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/task/task_validation.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/vision_prescription/vision_prescription_read.rb create mode 100644 lib/davinci_dtr_test_kit/profiles/vision_prescription/vision_prescription_validation.rb create mode 100644 lib/davinci_dtr_test_kit/read_test.rb create mode 100644 lib/davinci_dtr_test_kit/resource_search_param_checker.rb create mode 100644 lib/davinci_dtr_test_kit/search_test.rb create mode 100644 lib/davinci_dtr_test_kit/search_test_properties.rb create mode 100644 lib/davinci_dtr_test_kit/special_cases.rb diff --git a/Gemfile.lock b/Gemfile.lock index f909878..181feff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,8 @@ PATH inferno_core (~> 0.4.42) jwt (~> 2.6) smart_app_launch_test_kit (~> 0.4.4) + tls_test_kit (= 0.2.2) + us_core_test_kit (= 0.8.2) GEM remote: https://rubygems.org/ @@ -321,6 +323,10 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.6.0) unicode_utils (1.4.0) + us_core_test_kit (0.8.2) + inferno_core (>= 0.4.37) + smart_app_launch_test_kit (>= 0.4.0) + tls_test_kit (~> 0.2.0) webmock (3.23.1) addressable (>= 2.8.0) crack (>= 0.3.2) diff --git a/davinci_dtr_test_kit.gemspec b/davinci_dtr_test_kit.gemspec index f12307c..6dc50ab 100644 --- a/davinci_dtr_test_kit.gemspec +++ b/davinci_dtr_test_kit.gemspec @@ -12,6 +12,8 @@ Gem::Specification.new do |spec| spec.add_dependency 'inferno_core', '~> 0.4.42' spec.add_dependency 'jwt', '~> 2.6' spec.add_dependency 'smart_app_launch_test_kit', '~> 0.4.4' + spec.add_dependency 'tls_test_kit', '0.2.2' + spec.add_dependency 'us_core_test_kit', '0.8.2' spec.required_ruby_version = Gem::Requirement.new('>= 3.1.2') spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = spec.homepage diff --git a/lib/davinci_dtr_test_kit/date_search_validation.rb b/lib/davinci_dtr_test_kit/date_search_validation.rb new file mode 100644 index 0000000..def3a54 --- /dev/null +++ b/lib/davinci_dtr_test_kit/date_search_validation.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module DaVinciDTRTestKit + module DateSearchValidation + def get_fhir_datetime_range(datetime) + range = { start: DateTime.xmlschema(datetime), end: nil } + range[:end] = + if /^\d{4}$/.match?(datetime) # YYYY + range[:start].next_year - 1.seconds + elsif /^\d{4}-\d{2}$/.match?(datetime) # YYYY-MM + range[:start].next_month - 1.seconds + elsif /^\d{4}-\d{2}-\d{2}$/.match?(datetime) # YYYY-MM-DD + range[:start].next_day - 1.seconds + else # YYYY-MM-DDThh:mm:ss+zz:zz + range[:start] + end + range + end + + def get_fhir_period_range(period) + range = { start: nil, end: nil } + range[:start] = DateTime.xmlschema(period.start) unless period.start.nil? + return range if period.end.nil? + + period_end_beginning = DateTime.xmlschema(period.end) + range[:end] = + if /^\d{4}$/.match?(period.end) # YYYY + period_end_beginning.next_year - 1.seconds + elsif /^\d{4}-\d{2}$/.match?(period.end) # YYYY-MM + period_end_beginning.next_month - 1.seconds + elsif /^\d{4}-\d{2}-\d{2}$/.match?(period.end) # YYYY-MM-DD + period_end_beginning.next_day - 1.seconds + else # YYYY-MM-DDThh:mm:ss+zz:zz + period_end_beginning + end + range + end + + def fhir_date_comparer(search_range, target_range, comparator, extend_start = false, extend_end = false) + # Implicitly, a missing lower boundary is "less than" any actual date. A missing upper boundary is "greater than" any actual date. + case comparator + when 'eq' # the range of the search value fully contains the range of the target value + !target_range[:start].nil? && !target_range[:end].nil? && search_range[:start] <= target_range[:start] && search_range[:end] >= target_range[:end] + when 'ne' # the range of the search value does not fully contain the range of the target value + target_range[:start].nil? || target_range[:end].nil? || search_range[:start] > target_range[:start] || search_range[:end] < target_range[:end] + when 'gt' # the range above the search value intersects (i.e. overlaps) with the range of the target value + target_range[:end].nil? || search_range[:end] < target_range[:end] || (search_range[:end] < (target_range[:end] + 1) && extend_end) + when 'lt' # the range below the search value intersects (i.e. overlaps) with the range of the target value + target_range[:start].nil? || search_range[:start] > target_range[:start] || (search_range[:start] > (target_range[:start] - 1) && extend_start) + when 'ge' + fhir_date_comparer(search_range, target_range, 'gt', extend_start, + extend_end) || fhir_date_comparer(search_range, target_range, 'eq') + when 'le' + fhir_date_comparer(search_range, target_range, 'lt', extend_start, + extend_end) || fhir_date_comparer(search_range, target_range, 'eq') + when 'sa' # the range above the search value contains the range of the target value + !target_range[:start].nil? && search_range[:end] < target_range[:start] + when 'eb' # the range below the search value contains the range of the target value + !target_range[:end].nil? && search_range[:start] > target_range[:end] + when 'ap' # the range of the search value overlaps with the range of the target value + if target_range[:start].nil? || target_range[:end].nil? + (target_range[:start].nil? && search_range[:start] < target_range[:end]) || + (target_range[:end].nil? && search_range[:end] > target_range[:start]) + else + (search_range[:start] >= target_range[:start] && search_range[:start] <= target_range[:end]) || + (search_range[:end] >= target_range[:start] && search_range[:end] <= target_range[:end]) + end + end + end + + def validate_date_search(search_value, target_value) + if target_value.instance_of? FHIR::Period + validate_period_search(search_value, target_value) + else + validate_datetime_search(search_value, target_value) + end + end + + def validate_datetime_search(search_value, target_value) + comparator = search_value[0..1] + if ['eq', 'ge', 'gt', 'le', 'lt', 'ne', 'sa', 'eb', 'ap'].include? comparator + search_value = search_value[2..-1] + else + comparator = 'eq' + end + search_is_date = is_date?(search_value) + target_is_date = is_date?(target_value) + search_range = get_fhir_datetime_range(search_value) + target_range = get_fhir_datetime_range(target_value) + fhir_date_comparer(search_range, target_range, comparator, !search_is_date && target_is_date, + !search_is_date && target_is_date) + end + + def validate_period_search(search_value, target_value) + comparator = search_value[0..1] + if ['eq', 'ge', 'gt', 'le', 'lt', 'ne', 'sa', 'eb', 'ap'].include? comparator + search_value = search_value[2..-1] + else + comparator = 'eq' + end + search_is_date = is_date?(search_value) + search_range = get_fhir_datetime_range(search_value) + target_range = get_fhir_period_range(target_value) + fhir_date_comparer(search_range, target_range, comparator, !search_is_date && is_date?(target_value.start), + !search_is_date && is_date?(target_value.end)) + end + + def is_date?(value) + /^\d{4}(-\d{2})?(-\d{2})?$/.match?(value) # YYYY or YYYY-MM or YYYY-MM-DD + end + end +end diff --git a/lib/davinci_dtr_test_kit/dtr_light_ehr_suite.rb b/lib/davinci_dtr_test_kit/dtr_light_ehr_suite.rb index dccccda..5ee0e4f 100644 --- a/lib/davinci_dtr_test_kit/dtr_light_ehr_suite.rb +++ b/lib/davinci_dtr_test_kit/dtr_light_ehr_suite.rb @@ -1,6 +1,30 @@ +require_relative 'ext/inferno_core/runnable' +require_relative 'ext/inferno_core/record_response_route' +require_relative 'ext/inferno_core/request' +require 'us_core_test_kit' require 'tls_test_kit' require_relative 'version' require_relative 'dtr_options' +require_relative 'profiles/coverage/coverage_read' +require_relative 'profiles/coverage/coverage_validation' +require_relative 'profiles/coverage/coverage_context_search' +require_relative 'profiles/coverage/coverage_patient_search' +require_relative 'profiles/communication_request/communication_request_read' +require_relative 'profiles/communication_request/communication_request_validation' +require_relative 'profiles/device_request/device_request_read' +require_relative 'profiles/device_request/device_request_validation' +require_relative 'profiles/encounter/encounter_read' +require_relative 'profiles/encounter/encounter_validation' +require_relative 'profiles/medication_request/medication_request_read' +require_relative 'profiles/medication_request/medication_request_validation' +require_relative 'profiles/nutrition_order/nutrition_order_read' +require_relative 'profiles/nutrition_order/nutrition_order_validation' +require_relative 'profiles/service_request/service_request_read' +require_relative 'profiles/service_request/service_request_validation' +require_relative 'profiles/task/task_read' +require_relative 'profiles/task/task_validation' +require_relative 'profiles/vision_prescription/vision_prescription_read' +require_relative 'profiles/vision_prescription/vision_prescription_validation' require 'smart_app_launch/smart_stu1_suite' require 'smart_app_launch/smart_stu2_suite' @@ -35,6 +59,15 @@ class DTRLightEHRSuite < Inferno::TestSuite title: 'FHIR Endpoint', description: 'URL of the DTR FHIR server' + # Hl7 Validator Wrapper: + fhir_resource_validator do + igs('hl7.fhir.us.davinci-dtr#2.0.1', 'hl7.fhir.us.davinci-cdex#2.0.0') + + exclude_message do |message| + message.message.match?(/\A\S+: \S+: URL value '.*' does not resolve/) + end + end + group do title 'Authorization' @@ -71,5 +104,107 @@ class DTRLightEHRSuite < Inferno::TestSuite required_suite_options: DTROptions::SMART_2_REQUIREMENT, run_as_group: true end + + group do + title 'FHIR API' + + group from: :'us_core_v311-us_core_v311_fhir_api', + run_as_group: true + end + + group do + title 'DTR Light EHR Profiles' + + input :credentials, + title: 'OAuth Credentials', + type: :oauth_credentials, + optional: true + + # All FHIR requests in this suite will use this FHIR client + fhir_client do + url :url + oauth_credentials :credentials + end + + group do + title 'CRD Coverage' + + input :coverage_ids + + test from: :coverage_read + test from: :coverage_validation + test from: :coverage_context_search + test from: :coverage_patient_search + end + + group do + title 'CRD CommunicationRequest' + input :communication_request_ids + + test from: :communication_request_read + test from: :communication_request_validation + end + + group do + title 'CRD DeviceRequest' + input :device_request_ids + + test from: :device_request_read + test from: :device_request_validation + end + + group do + title 'CRD Encounter' + + input :encounter_ids + + test from: :encounter_read + test from: :encounter_validation + end + + group do + title 'CRD MedicationRequest' + + input :medication_request_ids + + test from: :medication_request_read + test from: :medication_request_validation + end + + group do + title 'CRD NutritionOrder' + + input :nutrition_order_ids + + test from: :nutrition_order_read + test from: :nutrition_order_validation + end + + group do + title 'CRD ServiceRequest' + + input :service_request_ids + + test from: :service_request_read + test from: :service_request_validation + end + + group do + title 'CDex Task' + + input :task_ids + + test from: :task_read + test from: :task_validation + end + + group do + title 'CRD VisionPrescription' + input :vision_prescription_ids + + test from: :vision_prescription_read + test from: :vision_prescription_validation + end + end end end diff --git a/lib/davinci_dtr_test_kit/fhir_resource_navigation.rb b/lib/davinci_dtr_test_kit/fhir_resource_navigation.rb new file mode 100644 index 0000000..df5454c --- /dev/null +++ b/lib/davinci_dtr_test_kit/fhir_resource_navigation.rb @@ -0,0 +1,166 @@ +require_relative 'primitive_type' + +module DaVinciDTRTestKit + module FHIRResourceNavigation + DAR_EXTENSION_URL = 'http://hl7.org/fhir/StructureDefinition/data-absent-reason'.freeze + PRIMITIVE_DATA_TYPES = FHIR::PRIMITIVES.keys + + def resolve_path(elements, path) + elements = Array.wrap(elements) + return elements if path.blank? + + paths = path.split(/(? date_comparator_value(comparator, date_element)) + + search_and_check_response(params_with_comparator) + + comparator_resources = fetch_all_bundled_resources(params: params_with_comparator).each do |resource| + check_resource_against_params(resource, params_with_comparator) if resource.resourceType == resource_type + end + end + + search_variant_test_records[:comparator_searches] << name + end + end + + def perform_reference_with_type_search(params, resource_count) + return if resource_count == 0 + return if search_variant_test_records[:reference_variants] + + new_search_params = params.merge('patient' => "Patient/#{params['patient']}") + search_and_check_response(new_search_params) + + reference_with_type_resources = + fetch_all_bundled_resources(params: new_search_params) + .select { |resource| resource.resourceType == resource_type } + + filter_conditions(reference_with_type_resources) if resource_type == 'Condition' && metadata.version == 'v5.0.1' + filter_devices(reference_with_type_resources) if resource_type == 'Device' + + new_resource_count = reference_with_type_resources.count + + assert new_resource_count == resource_count, + "Expected search by `#{params['patient']}` to to return the same results as searching " \ + "by `#{new_search_params['patient']}`, but found #{resource_count} resources with " \ + "`#{params['patient']}` and #{new_resource_count} with `#{new_search_params['patient']}`" + + search_variant_test_records[:reference_variants] = true + end + + def perform_search_with_system(params, patient_id) + return if search_variant_test_records[:token_variants] + + new_search_params = search_params_with_values(token_search_params, patient_id, include_system: true) + return if new_search_params.any? { |_name, value| value.blank? } + + search_params = params.merge(new_search_params) + search_and_check_response(search_params) + + resources_returned = + fetch_all_bundled_resources(params: search_params) + .select { |resource| resource.resourceType == resource_type } + + assert resources_returned.present?, 'No resources were returned when searching by `system|code`' + + search_variant_test_records[:token_variants] = true + end + + def perform_search_with_status( + original_params, + patient_id, + status_search_values: self.status_search_values, + resource_type: self.resource_type + ) + assert resource.is_a?(FHIR::OperationOutcome), 'Server returned a status of 400 without an OperationOutcome' + # TODO: warn about documenting status requirements + status_search_values.flat_map do |status_value| + search_params = original_params.merge("#{status_search_param_name}": status_value) + + search_and_check_response(search_params) + + entries = resource.entry.select { |entry| entry.resource.resourceType == resource_type } + + if entries.present? + original_params.merge!("#{status_search_param_name}": status_value) + break + end + end + end + + def status_search_param_name + @status_search_param_name ||= + metadata.search_definitions.keys.find { |key| key.to_s.include? 'status' } + end + + def status_search_values + default_search_values(status_search_param_name) + end + + def default_search_values(param_name) + definition = metadata.search_definitions[param_name] + return [] if definition.blank? + + definition[:multiple_or] == 'SHALL' ? [definition[:values].join(',')] : Array.wrap(definition[:values]) + end + + def perform_multiple_or_search_test + resolved_one = false + + all_search_params.each do |patient_id, params_list| + next unless params_list.present? + + search_params = params_list.first + existing_values = {} + missing_values = {} + + multiple_or_search_params.each do |param_name| + search_value = default_search_values(param_name.to_sym) + search_params = search_params.merge("#{param_name}" => search_value) + existing_values[param_name.to_sym] = + scratch_resources_for_patient(patient_id).map(¶m_name.to_sym).compact.uniq + end + + # skip patient without multiple-or values + next if existing_values.values.any?(&:empty?) + + resolved_one = true + + search_and_check_response(search_params) + + resources_returned = + fetch_all_bundled_resources(params: search_params) + .select { |resource| resource.resourceType == resource_type } + + multiple_or_search_params.each do |param_name| + missing_values[param_name.to_sym] = + existing_values[param_name.to_sym] - resources_returned.map(¶m_name.to_sym) + end + + missing_value_message = missing_values + .reject { |_param_name, missing_value| missing_value.empty? } + .map { |param_name, missing_value| "#{missing_value.join(',')} values from #{param_name}" } + .join(' and ') + + assert missing_value_message.blank?, + "Could not find #{missing_value_message} in any of the resources returned for Patient/#{patient_id}" + + break if resolved_one + end + end + + def test_medication_inclusion(base_resources, params, patient_id) + return if search_variant_test_records[:medication_inclusion] + + scratch[:medication_resources] ||= {} + scratch[:medication_resources][:all] ||= [] + scratch[:medication_resources][patient_id] ||= [] + scratch[:medication_resources][:contained] ||= [] + + base_resources_with_external_reference = + base_resources + .select { |request| request&.medicationReference&.present? } + .reject { |request| request&.medicationReference&.reference&.start_with? '#' } + + contained_medications = + base_resources + .select { |request| request&.medicationReference&.reference&.start_with? '#' } + .flat_map(&:contained) + .select { |resource| resource.resourceType == 'Medication' } + + scratch[:medication_resources][:all] += contained_medications + scratch[:medication_resources][patient_id] += contained_medications + scratch[:medication_resources][:contained] += contained_medications + + return if base_resources_with_external_reference.blank? + + search_params = params.merge(_include: "#{resource_type}:medication") + + search_and_check_response(search_params) + + medications = + fetch_all_bundled_resources(params: search_params) + .select { |resource| resource.resourceType == 'Medication' } + assert medications.present?, 'No Medications were included in the search results' + + included_medications = medications.map { |medication| "#{medication.resourceType}/#{medication.id}" } + + matched_base_resources = base_resources_with_external_reference.select do |base_resource| + included_medications.any? do |medication_reference| + is_reference_match?(base_resource.medicationReference.reference, medication_reference) + end + end + + not_matched_included_medications = included_medications.select do |medication_reference| + matched_base_resources.none? do |base_resource| + is_reference_match?(base_resource.medicationReference.reference, medication_reference) + end + end + + not_matched_included_medications_string = not_matched_included_medications.join(',') + assert not_matched_included_medications.empty?, + "No #{resource_type} references #{not_matched_included_medications_string} in the search result." + + medications.uniq!(&:id) + + scratch[:medication_resources][:all] += medications + scratch[:medication_resources][patient_id] += medications + + search_variant_test_records[:medication_inclusion] = true + end + + def is_reference_match?(reference, local_reference) + regex_pattern = %r{^(#{Regexp.escape(local_reference)}|\S+/#{Regexp.escape(local_reference)}(?:[/|]\S+)*)$} + reference.match?(regex_pattern) + end + + def all_scratch_resources + scratch_resources[:all] ||= [] + end + + def scratch_resources_for_patient(patient_id) + return all_scratch_resources if patient_id.nil? + + scratch_resources[patient_id] ||= [] + end + + def references_to_save(resource_type = nil) + reference_metadata = resource_type == 'Provenance' ? provenance_metadata : metadata + reference_metadata.delayed_references + end + + def fixed_value_search_param_name + (search_param_names - ['patient']).first + end + + def fixed_value_search_param_values + metadata.search_definitions[fixed_value_search_param_name.to_sym][:values] + end + + def fixed_value_search_params(value, patient_id) + search_param_names.each_with_object({}) do |name, params| + params[name] = patient_id_param?(name) ? patient_id : value + end + end + + def search_params_with_values(search_param_names, patient_id, include_system: false) + resources = scratch_resources_for_patient(patient_id) + + if resources.empty? + return search_param_names.each_with_object({}) do |name, params| + value = patient_id_param?(name) ? patient_id : nil + params[name] = value + end + end + + resources.each_with_object({}) do |resource, outer_params| + results_from_one_resource = search_param_names.each_with_object({}) do |name, params| + value = if patient_id_param?(name) + patient_id + else + search_param_value(name, resource, + include_system:) + end + params[name] = value + end + + outer_params.merge!(results_from_one_resource) + + # stop if all parameter values are found + return outer_params if outer_params.all? { |_key, value| value.present? } + end + end + + def patient_id_list + return [nil] unless respond_to? :patient_ids + + patient_ids.split(',').map(&:strip) + end + + def patient_search? + search_param_names.any? { |name| patient_id_param? name } + end + + def patient_id_param?(name) + name == 'patient' || (name == '_id' && resource_type == 'Patient') + end + + def search_param_paths(name) + paths = metadata.search_definitions[name.to_sym][:paths] + paths[0] = 'local_class' if paths.first == 'class' + + paths + end + + def all_search_params_present?(params) + params.all? { |_name, value| value.present? } + end + + def array_of_codes(array) + array.map { |name| "`#{name}`" }.join(', ') + end + + def unable_to_resolve_params_message + "Could not find values for all search params #{array_of_codes(search_param_names)}" + end + + def empty_search_params_message(empty_search_params) + "Could not find values for the search parameters #{array_of_codes(empty_search_params.keys)}" + end + + def no_resources_skip_message(resource_type = self.resource_type) + msg = "No #{resource_type} resources appear to be available" + + if resource_type == 'Device' && implantable_device_codes.present? + msg.concat(" with the following Device Type Code filter: #{implantable_device_codes}") + end + + msg + '. Please use patients with more information' + end + + def fetch_all_bundled_resources( + reply_handler: nil, + max_pages: 20, + additional_resource_types: [], + resource_type: self.resource_type, + params: nil + ) + page_count = 1 + resources = [] + bundle = resource + + until bundle.nil? || page_count == max_pages + resources += bundle&.entry&.map { |entry| entry&.resource } + next_bundle_link = bundle&.link&.find { |link| link.relation == 'next' }&.url + reply_handler&.call(response) + + break if next_bundle_link.blank? + + reply = fhir_client.raw_read_url(next_bundle_link) + + store_request('outgoing', tags: tags(params)) { reply } + error_message = cant_resolve_next_bundle_message(next_bundle_link) + + assert_response_status(200) + assert_valid_json(reply.body, error_message) + + bundle = fhir_client.parse_reply(FHIR::Bundle, fhir_client.default_format, reply) + + page_count += 1 + end + + valid_resource_types = [resource_type, 'OperationOutcome'].concat(additional_resource_types) + valid_resource_types << 'Medication' if ['MedicationRequest', 'MedicationDispense'].include?(resource_type) + + invalid_resource_types = + resources.reject { |entry| valid_resource_types.include? entry.resourceType } + .map(&:resourceType) + .uniq + + if invalid_resource_types.any? + info "Received resource type(s) #{invalid_resource_types.join(', ')} in search bundle, " \ + "but only expected resource types #{valid_resource_types.join(', ')}. " + \ + 'This is unusual but allowed if the server believes additional resource types are relevant.' + end + + resources + end + + def cant_resolve_next_bundle_message(link) + "Could not resolve next bundle: #{link}" + end + + def search_param_value(name, resource, include_system: false) + paths = search_param_paths(name) + search_value = nil + paths.each do |path| + element = find_a_value_at(resource, path) { |element| element_has_valid_value?(element, include_system) } + + search_value = + case element + when FHIR::Period + if element.start.present? + 'gt' + (DateTime.xmlschema(element.start) - 1).xmlschema + else + end_datetime = get_fhir_datetime_range(element.end)[:end] + 'lt' + (end_datetime + 1).xmlschema + end + when FHIR::Reference + element.reference + when FHIR::CodeableConcept + if include_system + coding = + find_a_value_at(element, 'coding') { |coding| coding.code.present? && coding.system.present? } + "#{coding.system}|#{coding.code}" + else + find_a_value_at(element, 'coding.code') + end + when FHIR::Identifier + include_system ? "#{element.system}|#{element.value}" : element.value + when FHIR::Coding + include_system ? "#{element.system}|#{element.code}" : element.code + when FHIR::HumanName + element.family || element.given&.first || element.text + when FHIR::Address + element.text || element.city || element.state || element.postalCode || element.country + when USCoreTestKit::PrimitiveType + element.value + else + if metadata.version != 'v3.1.1' && + metadata.search_definitions[name.to_sym][:type] == 'date' && + params_with_comparators&.include?(name) + # convert date search to greath-than comparator search with correct precision + # For all date search parameters: + # Patient.birthDate does not mandate comparators so cannot be converted + # Goal.target-date has day precision + # All others have second + time offset precision + if /^\d{4}(-\d{2})?$/.match?(element) || # YYYY or YYYY-MM + (/^\d{4}-\d{2}-\d{2}$/.match?(element) && resource_type != 'Goal') # YYY-MM-DD AND Resource is NOT Goal + "gt#{(DateTime.xmlschema(element) - 1).xmlschema}" + else + element + end + else + element + end + end + + break if search_value.present? + end + + search_value&.gsub(',', '\\,') + end + + def element_has_valid_value?(element, include_system) + case element + when FHIR::Reference + element.reference.present? + when FHIR::CodeableConcept + if include_system + coding = + find_a_value_at(element, 'coding') { |coding| coding.code.present? && coding.system.present? } + coding.present? + else + find_a_value_at(element, 'coding.code').present? + end + when FHIR::Identifier + include_system ? element.value.present? && element.system.present? : element.value.present? + when FHIR::Coding + include_system ? element.code.present? && element.system.present? : element.code.present? + when FHIR::HumanName + (element.family || element.given&.first || element.text).present? + when FHIR::Address + (element.text || element.city || element.state || element.postalCode || element.country).present? + when USCoreTestKit::PrimitiveType + element.value.present? + else + true + end + end + + def save_resource_reference(resource_type, reference) + scratch[:references] ||= {} + scratch[:references][resource_type] ||= Set.new + scratch[:references][resource_type] << reference + end + + def save_delayed_references(resources, containing_resource_type = resource_type) + resources.each do |resource| + references_to_save(containing_resource_type).each do |reference_to_save| + resolve_path(resource, reference_to_save[:path]) + .select { |reference| reference.is_a?(FHIR::Reference) && !reference.contained? } + .each do |reference| + resource_type = reference.resource_class.name.demodulize + need_to_save = reference_to_save[:resources].include?(resource_type) + next unless need_to_save + + save_resource_reference(resource_type, reference) + end + end + end + end + + #### RESULT CHECKING #### + + def check_resource_against_params(resource, params) + params.each do |name, escaped_search_value| + values_found = [] + search_value = unescape_search_value(escaped_search_value) + + match_found = resource_matches_param?(resource, name, escaped_search_value, values_found) + + assert match_found, + "#{resource_type}/#{resource.id} did not match the search parameters:\n" \ + "* Expected: #{unescape_search_value(search_value)}\n" \ + "* Found: #{values_found.map(&:inspect).join(', ')}" + end + end + + def unescape_search_value(value) + value&.gsub('\\,', ',') + end + + def resource_matches_param?(resource, search_param_name, escaped_search_value, values_found = []) + search_value = unescape_search_value(escaped_search_value) + paths = search_param_paths(search_param_name) + + match_found = false + + paths.each do |path| + type = metadata.search_definitions[search_param_name.to_sym][:type] + + resolve_path(resource, path).each do |value| + values_found << + if value.is_a? FHIR::Reference + value.reference + elsif value.is_a? USCoreTestKit::PrimitiveType + value.value + else + value + end + end + + values_found.compact! + match_found = + case type + when 'Period', 'date', 'instant', 'dateTime' + values_found.any? { |date| validate_date_search(search_value, date) } + when 'HumanName' + # When a string search parameter refers to the types HumanName and Address, + # the search covers the elements of type string, and does not cover elements such as use and period + # https://www.hl7.org/fhir/search.html#string + search_value_downcase = search_value.downcase + values_found.any? do |name| + name&.text&.downcase&.start_with?(search_value_downcase) || + name&.family&.downcase&.start_with?(search_value_downcase) || + name&.given&.any? { |given| given.downcase.start_with?(search_value_downcase) } || + name&.prefix&.any? { |prefix| prefix.downcase.start_with?(search_value_downcase) } || + name&.suffix&.any? { |suffix| suffix.downcase.start_with?(search_value_downcase) } + end + when 'Address' + search_value_downcase = search_value.downcase + values_found.any? do |address| + address&.text&.downcase&.start_with?(search_value_downcase) || + address&.city&.downcase&.start_with?(search_value_downcase) || + address&.state&.downcase&.start_with?(search_value_downcase) || + address&.postalCode&.downcase&.start_with?(search_value_downcase) || + address&.country&.downcase&.start_with?(search_value_downcase) + end + when 'CodeableConcept' + # FHIR token search (https://www.hl7.org/fhir/search.html#token): "When in doubt, servers SHOULD + # treat tokens in a case-insensitive manner, on the grounds that including undesired data has + # less safety implications than excluding desired behavior". + codings = values_found.flat_map(&:coding) + if search_value.include? '|' + system = search_value.split('|').first + code = search_value.split('|').last + codings&.any? { |coding| coding.system == system && coding.code&.casecmp?(code) } + else + codings&.any? { |coding| coding.code&.casecmp?(search_value) } + end + when 'Coding' + if search_value.include? '|' + system = search_value.split('|').first + code = search_value.split('|').last + values_found.any? { |coding| coding.system == system && coding.code&.casecmp?(code) } + else + values_found.any? { |coding| coding.code&.casecmp?(search_value) } + end + when 'Identifier' + if search_value.include? '|' + values_found.any? { |identifier| "#{identifier.system}|#{identifier.value}" == search_value } + else + values_found.any? { |identifier| identifier.value == search_value } + end + when 'string' + searched_values = search_value.downcase.split(/(? ['v311', 'v400', 'v501', 'v610'], + 'Medication' => ['v311', 'v400', 'v501', 'v610', 'v700'], + 'PractitionerRole' => ['v311', 'v400'] + }.freeze + + PROFILES_TO_EXCLUDE = [ + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-survey', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-vital-signs' + ].freeze + + OPTIONAL_RESOURCES = [ + 'PractitionerRole', + 'QuestionnaireResponse' + ].freeze + + OPTIONAL_PROFILES = [ + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-simple-observation' + ].freeze + + NON_USCDI_RESOURCES = { + 'Encounter' => ['v311', 'v400'], + 'Location' => ['v311', 'v400', 'v501', 'v610'], + 'Organization' => ['v311', 'v400', 'v501', 'v610', 'v700'], + 'Practitioner' => ['v311', 'v400', 'v501', 'v610', 'v700'], + 'PractitionerRole' => ['v311', 'v400', 'v501', 'v610', 'v700'], + 'Provenance' => ['v311', 'v400', 'v501', 'v610', 'v700'], + 'RelatedPerson' => ['v501', 'v610', 'v700'] + }.freeze + + SEARCHABLE_DELAYED_RESOURCES = { + 'Location' => ['v700'] + }.freeze + + ALL_VERSION_CATEGORY_FIRST_PROFILES = [ + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-careplan', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-diagnosticreport-lab', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-diagnosticreport-note', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-clinical-result', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-clinical-test', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-imaging', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-lab', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-screening-assessment', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-sdoh-assessment', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-social-history', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-survey', + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-simple-observation' + ].freeze + + VERSION_SPECIFIC_CATEGORY_FIRST_PROFILES = { + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis' => ['v610', 'v700'], + 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-problems-health-concerns' => ['v610', 'v700'] + }.freeze + + class << self + def exclude_group?(group) + RESOURCES_TO_EXCLUDE.key?(group.resource) && + RESOURCES_TO_EXCLUDE[group.resource].include?(group.reformatted_version) + end + end + end + end +end