diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 000000000..bdabd139b --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,30 @@ +version: "2" # required to adjust maintainability checks +exclude_patterns: + - "!**/test/" +checks: + argument-count: + enabled: true + complex-logic: + enabled: true + file-lines: + enabled: true + method-complexity: + enabled: true + method-count: + enabled: false + method-lines: + enabled: true + nested-control-flow: + enabled: true + return-statements: + enabled: false + similar-code: + enabled: false + identical-code: + enabled: true +plugins: + rubocop: + enabled: true + channel: rubocop-0-67 + config: + file: .rubocop.yml diff --git a/.gitignore b/.gitignore index e1bd7d268..26674d9c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,5 @@ data/* coverage tmp/* .vscode/* -resources/terminology/* \ No newline at end of file +resources/terminology/* +.ruby-version diff --git a/.rubocop-windows.yml b/.rubocop-windows.yml new file mode 100644 index 000000000..db5d21f55 --- /dev/null +++ b/.rubocop-windows.yml @@ -0,0 +1,4 @@ +inherit_from: .rubocop.yml + +Layout/EndOfLine: + EnforcedStyle: crlf diff --git a/.rubocop.yml b/.rubocop.yml index df5df76c2..92eb478b1 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -21,3 +21,25 @@ Metrics/LineLength: Exclude: - 'lib/app/modules/**/*' - 'lib/app/helpers/browser_logic.rb' + +# Use code climate's metrics measurement rather than rubocop's +Metrics/AbcSize: + Enabled: false + +Metrics/BlockLength: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/ModuleLength: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Metrics/ParameterLists: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f1b9e8620..0f0f8f952 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -32,49 +32,15 @@ Lint/UselessAssignment: Exclude: - 'lib/tasks/tasks.rake' -# Offense count: 150 -Metrics/AbcSize: - Max: 176 - -# Offense count: 38 -# Configuration parameters: CountComments, ExcludedMethods. -# ExcludedMethods: refine -Metrics/BlockLength: - Max: 388 - # Offense count: 3 # Configuration parameters: CountBlocks. Metrics/BlockNesting: Max: 4 -# Offense count: 60 -# Configuration parameters: CountComments. -Metrics/ClassLength: - Max: 594 - # Offense count: 38 Metrics/CyclomaticComplexity: Max: 33 -# Offense count: 140 -# Configuration parameters: CountComments, ExcludedMethods. -Metrics/MethodLength: - Max: 142 - -# Offense count: 1 -# Configuration parameters: CountComments. -Metrics/ModuleLength: - Max: 199 - -# Offense count: 2 -# Configuration parameters: CountKeywordArgs. -Metrics/ParameterLists: - Max: 10 - -# Offense count: 19 -Metrics/PerceivedComplexity: - Max: 32 - # Offense count: 33 Style/ClassVars: Exclude: diff --git a/.travis.yml b/.travis.yml index 55ac21fd6..8b1e9ffb5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,19 @@ services: rvm: - 2.5 - 2.6 +before_install: + - gem update --system + - gem install bundler + - docker-compose build +before_script: + - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + - chmod +x ./cc-test-reporter + - ./cc-test-reporter before-build script: - bundle exec rake + # For unknown reasons, the test-reporter fails when using docker if run in an + # after_script section + - ./cc-test-reporter after-build -t simplecov --exit-code $TRAVIS_TEST_RESULT - docker-compose run ruby_server bundle exec rake - bundle exec rubocop notifications: @@ -13,7 +24,3 @@ notifications: recipients: - inferno@groups.mitre.org on_failure: change -before_install: - - gem update --system - - gem install bundler - - docker-compose build diff --git a/Dockerfile b/Dockerfile index f56e0d426..be8a0e645 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM ruby:2.5 # Install gems into a temporary directory COPY Gemfile* ./ -RUN bundle install +RUN gem install bundler && bundle install # Expose the port EXPOSE 4567 diff --git a/Gemfile b/Gemfile index 6fa973087..102c78d38 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,7 @@ gem 'fhir_client' gem 'json-jwt' gem 'kramdown' gem 'pry' +gem 'pry-byebug' gem 'rack-test' gem 'rake' gem 'rb-readline' diff --git a/Gemfile.lock b/Gemfile.lock index e4dbbdbe7..1330df39c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -22,6 +22,7 @@ GEM bloomer (1.0.0) bitarray msgpack + byebug (11.0.1) childprocess (1.0.1) rake (< 13.0) coderay (1.1.2) @@ -152,6 +153,9 @@ GEM pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) + pry-byebug (3.7.0) + byebug (~> 11.0) + pry (~> 0.10) psych (3.1.0) public_suffix (3.0.3) rack (2.0.7) @@ -233,6 +237,7 @@ DEPENDENCIES json-jwt kramdown pry + pry-byebug rack-test rake rb-readline @@ -246,3 +251,6 @@ DEPENDENCIES thin time_difference webmock + +BUNDLED WITH + 2.0.2 diff --git a/README.md b/README.md index a4a8edc04..5b59878a9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ - + -[![Build Status](https://travis-ci.org/siteadmin/inferno.svg?branch=master)](https://travis-ci.org/siteadmin/inferno) +[![Build Status](https://travis-ci.org/onc-healthit/inferno.svg?branch=master)](https://travis-ci.org/onc-healthit/inferno) Inferno is an open source tool that tests whether patients can access their health data through a standard interface. It makes HTTP(S) requests to test your server's conformance to authentication, authorization, and FHIR content standards and reports the results back to you. @@ -9,7 +9,7 @@ This application creates test clients that exercise the range of requirements of ## Using Inferno -If you are new to FHIR or SMART-on-FHIR, you may want to review the [Inferno Quick Start Guide](https://github.com/siteadmin/inferno/wiki/Quick-Start-Guide). +If you are new to FHIR or SMART-on-FHIR, you may want to review the [Inferno Quick Start Guide](https://github.com/onc-healthit/inferno/wiki/Quick-Start-Guide). ## Installation and Deployment @@ -18,14 +18,14 @@ If you are new to FHIR or SMART-on-FHIR, you may want to review the [Inferno Qui Docker is the recommended installation method for Windows devices and can also be used on Linux and MacOS hosts. 1. Install [Docker](https://www.docker.com/) for the host platform as well as the [docker-compose](https://docs.docker.com/compose/install/) tool (which may be included in the distribution, as is the case for Windows and MacOS). -2. Download the [latest release of the `inferno` project](https://github.com/siteadmin/inferno/releases) to your local computer on a directory of your choice. +2. Download the [latest release of the `inferno` project](https://github.com/onc-healthit/inferno/releases) to your local computer on a directory of your choice. 3. Open a terminal in the directory where the project was downloaded (above). 4. Run the command `docker-compose up` to start the server. This will automatically build the Docker image and launch both the ruby server (using unicorn) and an NGINX web server. 5. Navigate to http://localhost:8080 to find the running application. If the docker image gets out of sync with the underlying system, such as when new dependencies are added to the application, you need to run `docker-compose up --build` to rebuild the containers. -Check out the [Troubleshooting Documentation](https://github.com/siteadmin/inferno/wiki/Troubleshooting) for help. +Check out the [Troubleshooting Documentation](https://github.com/onc-healthit/inferno/wiki/Troubleshooting) for help. ### Native Installation @@ -39,7 +39,7 @@ And run the following commands from the terminal: ```sh # MacOS or Linux -git clone https://github.com/siteadmin/inferno +git clone https://github.com/onc-healthit/inferno cd inferno bundle install bundle exec rackup @@ -59,7 +59,7 @@ Inferno can also be deployed onto a server to test many different instances of t Deployment on a remote server can be done by using a modified form of the Docker containers provided (see above) or by direct installation on the remote host. -Please see the file [deployment-configuration.md](https://github.com/siteadmin/inferno/blob/master/deployment-configuration.md) for details. +Please see the file [deployment-configuration.md](https://github.com/onc-healthit/inferno/blob/master/deployment-configuration.md) for details. ### Reference Implementation @@ -82,7 +82,7 @@ bundle exec rake test ## Inspecting and Exporting Tests Tests are written to be easily understood, even by those who aren't familiar with Ruby. They can be -viewed directly [in this repository](https://github.com/siteadmin/inferno/tree/master/lib/app/modules). +viewed directly [in this repository](https://github.com/onc-healthit/inferno/tree/master/lib/app/sequences). Tests contain metadata that provide additional details and traceability to standards. The active tests and related metadata can be exported into CSV format and saved to a file named `testlist.csv` with the following command: @@ -134,7 +134,7 @@ bundle exec rake inferno:generate_script[https://my-server.org/data,onc] * The `confidential_client` field is a boolean and must be provided as `true` or `false` ## Using with Continuous Integration Systems -Instructions and examples are available in the [Continuous Integration Section of the Wiki](https://github.com/siteadmin/inferno/wiki/Using-with-Continuous-Integration-Systems). +Instructions and examples are available in the [Continuous Integration Section of the Wiki](https://github.com/onc-healthit/inferno/wiki/Using-with-Continuous-Integration-Systems). ## Contact Us The Inferno development team can be reached by email at inferno@groups.mitre.org. Inferno also has a dedicated [HL7 FHIR chat channel](https://chat.fhir.org/#narrow/stream/153-inferno). diff --git a/config.yml b/config.yml index 34560aab8..ee8319f47 100644 --- a/config.yml +++ b/config.yml @@ -57,7 +57,7 @@ presets: inferno_uri: https://inferno.healthit.gov client_id: vkPKDPcTIEMaw5Uf-DdUUtNMFMZaX0 client_secret: LS1nY3JFU3FDeEs0cWoxQWF6TVJFNU05RmZZNGZhZ2Vwb2JYWjdSWWJGakwwNTZ2Vng= - instructions: https://github.com/siteadmin/inferno/wiki/SITE-Preset-Instructions + instructions: https://github.com/onc-healthit/inferno/wiki/SITE-Preset-Instructions site_test_healthit_gov: name: SITE DSTU2 FHIR Sandbox uri: https://fhir.sitenv.org/secure/fhir @@ -65,7 +65,7 @@ presets: inferno_uri: https://infernotest.healthit.gov client_id: TrToU5piE-dJ1g6PBG1elFV4r9KLmH client_secret: ckFqb1ZhbmFMQS13WDE0c1dTLWxSdGRLSE8yUXpWNS1vSnd6azNrMmU3Y0JPdDRja3U= - instructions: https://github.com/siteadmin/inferno/wiki/SITE-Preset-Instructions + instructions: https://github.com/onc-healthit/inferno/wiki/SITE-Preset-Instructions site_local: name: SITE DSTU2 FHIR Sandbox uri: https://fhir.sitenv.org/secure/fhir @@ -73,4 +73,4 @@ presets: inferno_uri: http://localhost:4567 client_id: Yg0o6sJ8I8CfVVyHz1eA0m8jv6sXwe client_secret: UDVrTXlna0NvcGRQZ1VhMkZaZzQ0R1FxVGdtTWxFMXVoT3pPd1VRMUN4MFVkV25Gejk= - instructions: https://github.com/siteadmin/inferno/wiki/SITE-Preset-Instructions + instructions: https://github.com/onc-healthit/inferno/wiki/SITE-Preset-Instructions diff --git a/deployment-configuration.md b/deployment-configuration.md index c64c41f9b..f89f972dd 100644 --- a/deployment-configuration.md +++ b/deployment-configuration.md @@ -23,7 +23,7 @@ It is important to open port `80` for HTTP and port `22` for SSH if you need to "Review and Launch" button, click next button until you get to the Security Groups option. Ensure 80 is accessible from anywhere and 22 is available from an IP range from which you will connect. Below is an example: -![Security Groups Configuration](https://raw.githubusercontent.com/siteadmin/inferno/master/deployment-files/security-groups.png "Security Groups Configuration") +![Security Groups Configuration](https://raw.githubusercontent.com/onc-healthit/inferno/master/deployment-files/security-groups.png "Security Groups Configuration") After this step is done, launch the instance. Obtain your instance's IP or host name from the AWS console. Point a web browser to the instance using the IP address or host name. @@ -70,7 +70,7 @@ Now, issue the following commands to setup Inferno. sudo apt-get install git ruby-bundler ruby-dev sudo apt-get install sqlite3 libsqlite3-dev sudo apt-get install build-essential patch zlib1g-dev liblzma-dev - git clone https://github.com/siteadmin/inferno.git + git clone https://github.com/onc-healthit/inferno.git cd inferno bundle install @@ -183,7 +183,7 @@ This section describes how to setup the tool using Apache2 using Passenger. sudo apt-get install apache2 git ruby-bundler ruby-dev sudo apt-get install sqlite3 libsqlite3-dev sudo apt-get install build-essential patch zlib1g-dev liblzma-dev - git clone https://github.com/siteadmin/inferno.git + git clone https://github.com/onc-healthit/inferno.git cd inferno bundle install diff --git a/generators/argonaut-r4/generator.rb b/generators/uscore-r4/generator.rb similarity index 57% rename from generators/argonaut-r4/generator.rb rename to generators/uscore-r4/generator.rb index 4d7290baa..ab6d4ed03 100644 --- a/generators/argonaut-r4/generator.rb +++ b/generators/uscore-r4/generator.rb @@ -57,6 +57,7 @@ def generate_tests(metadata) end create_resource_profile_test(sequence) + create_must_support_test(sequence) create_references_resolved_test(sequence) end end @@ -74,15 +75,19 @@ def generate_sequence(sequence) def create_authorization_test(sequence) authorization_test = { tests_that: "Server rejects #{sequence[:resource]} search without authorization", - index: format('%02d', (sequence[:tests].length + 1)), + index: sequence[:tests].length + 1, link: 'http://www.fhir.org/guides/argonaut/r2/Conformance-server.html' } + first_search = sequence[:searches].find { |search_param| search_param[:expectation] == 'SHALL' } || + sequence[:searches].find { |search_param| search_param[:expectation] == 'SHOULD' } + return if first_search.nil? + authorization_test[:test_code] = %( @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - - reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), patient: @instance.patient_id) +#{get_search_params(first_search[:names], sequence)} + reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply) @@ -92,11 +97,11 @@ def create_authorization_test(sequence) def create_search_test(sequence, search_param) search_test = { tests_that: "Server returns expected results from #{sequence[:resource]} search by #{search_param[:names].join('+')}", - index: format('%02d', (sequence[:tests].length + 1)), + index: sequence[:tests].length + 1, link: 'https://build.fhir.org/ig/HL7/US-Core-R4/CapabilityStatement-us-core-server.html' } - is_first_search = search_test[:index] == '02' # if first search - fix this check later + is_first_search = search_test[:index] == 2 # if first search - fix this check later search_test[:test_code] = if is_first_search %(#{get_search_params(search_param[:names], sequence)} @@ -104,29 +109,32 @@ def create_search_test(sequence, search_param) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @#{sequence[:resource].downcase} = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('#{sequence[:resource]}'), reply)) + @#{sequence[:resource].downcase}_ary = reply&.resource&.entry&.map { |entry| entry&.resource } + save_resource_ids_in_bundle(versioned_resource_class('#{sequence[:resource]}'), reply) + validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params)) else %( skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@#{sequence[:resource].downcase}.nil?, 'Expected valid #{sequence[:resource]} resource to be present' #{get_search_params(search_param[:names], sequence)} reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params) + validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params) assert_response_ok(reply)) end + search_test[:test_code] += get_comparator_searches(search_param[:names], sequence) sequence[:tests] << search_test end def create_interaction_test(sequence, interaction) interaction_test = { tests_that: "#{sequence[:resource]} #{interaction[:code]} resource supported", - index: format('%02d', (sequence[:tests].length + 1)), + index: sequence[:tests].length + 1, link: 'https://build.fhir.org/ig/HL7/US-Core-R4/CapabilityStatement-us-core-server.html' } @@ -139,10 +147,71 @@ def create_interaction_test(sequence, interaction) sequence[:tests] << interaction_test end +def create_must_support_test(sequence) + test = { + tests_that: "At least one of every must support element is provided in any #{sequence[:resource]} for this patient.", + index: sequence[:tests].length + 1, + link: 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support', + test_code: '' + } + + test[:test_code] += %( + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @#{sequence[:resource].downcase}_ary&.any?) + + test[:test_code] += %( + must_support_confirmed = {}) + + extensions_list = [] + sequence[:must_supports].select { |must_support| must_support[:type] == 'extension' }.each do |extension| + extensions_list << "'#{extension[:id]}': '#{extension[:url]}'" + end + if extensions_list.any? + test[:test_code] += %( + extensions_list = { + #{extensions_list.join(",\n ")} + } + extensions_list.each do |id, url| + @#{sequence[:resource].downcase}_ary&.each do |resource| + must_support_confirmed[id] = true if resource.extension.any? { |extension| extension.url == url } + break if must_support_confirmed[id] + end + skip "Could not find \#{id} in any of the \#{@#{sequence[:resource].downcase}_ary.length} provided #{sequence[:resource]} resource(s)" unless must_support_confirmed[id] + end +) + end + elements_list = [] + sequence[:must_supports].select { |must_support| must_support[:type] == 'element' }.each do |element| + element[:path] = element[:path].gsub('.class', '.local_class') # class is mapped to local_class in fhir_models + elements_list << "'#{element[:path]}'" + end + + if elements_list.any? + test[:test_code] += %( + must_support_elements = [ + #{elements_list.join(",\n ")} + ] + must_support_elements.each do |path| + @#{sequence[:resource].downcase}_ary&.each do |resource| + truncated_path = path.gsub('#{sequence[:resource]}.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @#{sequence[:resource].downcase}_ary.length + + skip "Could not find \#{path} in any of the \#{resource_count} provided #{sequence[:resource]} resource(s)" unless must_support_confirmed[path] + end) + end + + test[:test_code] += %( + @instance.save!) + + sequence[:tests] << test +end + def create_resource_profile_test(sequence) test = { - tests_that: "#{sequence[:resource]} resources associated with Patient conform to Argonaut profiles", - index: format('%02d', (sequence[:tests].length + 1)), + tests_that: "#{sequence[:resource]} resources associated with Patient conform to US Core R4 profiles", + index: sequence[:tests].length + 1, link: sequence[:profile] } test[:test_code] = %( @@ -155,7 +224,7 @@ def create_resource_profile_test(sequence) def create_references_resolved_test(sequence) test = { tests_that: 'All references can be resolved', - index: format('%02d', (sequence[:tests].length + 1)), + index: sequence[:tests].length + 1, link: 'https://www.hl7.org/fhir/DSTU2/references.html' } @@ -167,45 +236,42 @@ def create_references_resolved_test(sequence) sequence[:tests] << test end -def get_safe_access_path(param, search_param_descriptions, element_descriptions) - element_path = search_param_descriptions[param.to_sym][:path] +def resolve_element_path(search_param_description) + type = search_param_description[:type] + element_path = search_param_description[:path] + get_value_path_by_type(type) + element_path.gsub('.class', '.local_class') # match fhir_models because class is protected keyword in ruby path_parts = element_path.split('.') - parts_with_multiple = [] - path_parts.each_with_index do |part, index| - next if index.zero? - next if element_path == 'Procedure.occurrenceDateTime' # bug in us core? - - cur_path = path_parts[0..index].join('.') - path_parts[index] = 'local_class' if part == 'class' # match fhir_models because class is protected keyword in ruby - parts_with_multiple << index if element_descriptions[cur_path.downcase.to_sym][:contains_multiple] - end - parts_with_multiple.each do |index| - path_parts[index] += '&.first' - end - path_parts[0] = "@#{path_parts[0].downcase}" - - path_parts.join('&.') + resource_val = "@#{path_parts.shift.downcase}" + "resolve_element_from_path(#{resource_val}, '#{path_parts.join('.')}')" end def get_value_path_by_type(type) case type when 'CodeableConcept' - '&.coding&.first&.code' + '.coding.code' when 'Reference' - '&.reference&.first' + '.reference' when 'Period' - '&.start' + '.start' when 'Identifier' - '&.value' + '.value' when 'Coding' - '&.code' + '.code' when 'HumanName' - '&.family' + '.family' else '' end end +def param_value_name(param) + if param == '_id' + 'id_val' + else + param.tr('-', '_') + '_val' + end +end + def get_search_params(search_parameters, sequence) unless search_param_constants(search_parameters, sequence).nil? return %( @@ -214,18 +280,12 @@ def get_search_params(search_parameters, sequence) search_values = [] search_assignments = [] search_parameters.each do |param| - type = sequence[:search_param_descriptions][param.to_sym][:type] - variable_name = - if param == '_id' - 'id_val' - else - param.tr('-', '_') + '_val' - end + variable_name = param_value_name(param) variable_value = if param == 'patient' '@instance.patient_id' else - get_safe_access_path(param, sequence[:search_param_descriptions], sequence[:element_descriptions]) + get_value_path_by_type(type) + resolve_element_path(sequence[:search_param_descriptions][param.to_sym]) end search_values << "#{variable_name} = #{variable_value}" search_assignments << "'#{param}': #{variable_name}" @@ -238,10 +298,39 @@ def get_search_params(search_parameters, sequence) end search_code += %( search_params = { #{search_assignments.join(', ')} } + search_params.each { |param, value| skip "Could not resolve \#{param} in given resource" if value.nil? } ) search_code end +def get_comparator_searches(search_params, sequence) + search_code = '' + search_assignments = search_params.map do |param| + "'#{param}': #{param_value_name(param)}" + end + search_assignments_str = "{ #{search_assignments.join(', ')} }" + search_params.each do |param| + param_val_name = param_value_name(param) + param_info = sequence[:search_param_descriptions][param.to_sym] + comparators = param_info[:comparators].select { |_comparator, expectation| ['SHALL', 'SHOULD'].include? expectation } + next if comparators.empty? + + type = param_info[:type] + case type + when 'Period', 'date' + search_code += %(\n + [#{comparators.keys.map { |comparator| "'#{comparator}'" }.join(', ')}].each do |comparator| + comparator_val = date_comparator_value(comparator, #{param_val_name}) + comparator_search_params = #{search_assignments_str.gsub(param_val_name, 'comparator_val')} + reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), comparator_search_params) + validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, comparator_search_params) + assert_response_ok(reply) + end) + end + end + search_code +end + def search_param_constants(search_parameters, sequence) return "patient: @instance.patient_id, category: 'assess-plan'" if search_parameters == ['patient', 'category'] && sequence[:resource] == 'CarePlan' return "patient: @instance.patient_id, status: 'active'" if search_parameters == ['patient', 'status'] && sequence[:resource] == 'CareTeam' @@ -258,69 +347,56 @@ def search_param_constants(search_parameters, sequence) def create_search_validation(sequence) search_validators = '' sequence[:search_param_descriptions].each do |element, definition| + search_validators += %( + when '#{element}') type = definition[:type] - contains_multiple = definition[:contains_multiple] path_parts = definition[:path].split('.') - path_parts[0] = 'resource' path_parts = path_parts.map { |part| part == 'class' ? 'local_class' : part } + path_parts.shift case type - when 'CodeableConcept' + when 'Period' search_validators += %( - when '#{element}' - codings = #{path_parts.join('&.')}#{'&.first' if contains_multiple}&.coding - assert !codings.nil?, '#{element} on resource did not match #{element} requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, '#{element} on resource did not match #{element} requested' + value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |period| + validate_period_search(value, period) + end + assert value_found, '#{element} on resource does not match #{element} requested' ) - when 'Reference' + when 'date' search_validators += %( - when '#{element}' - assert #{path_parts.join('&.')}&.reference&.include?(value), '#{element} on resource does not match #{element} requested' + value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |date| + validate_date_search(value, date) + end + assert value_found, '#{element} on resource does not match #{element} requested' ) 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_validators += %( + value = value.downcase + value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |name| + name&.text&.start_with?(value) || + name&.family&.downcase&.include?(value) || + name&.given&.any? { |given| given.downcase.start_with?(value) } || + name&.prefix&.any? { |prefix| prefix.downcase.start_with?(value) } || + name&.suffix&.any? { |suffix| suffix.downcase.start_with?(value) } + end + assert value_found, '#{element} on resource does not match #{element} requested' +) + else + # searching by patient requires special case because we are searching by a resource identifier + # references can also be URL's, so we made need to resolve those url's search_validators += - if contains_multiple + if ['subject', 'patient'].include? element.to_s %( - when '#{element}' - found = #{path_parts.join('&.')}&.any? do |name| - name.text&.include?(value) || - name.family.include?(value) || - name.given.any { |given| given&.include?(value) } || - name.prefix.any { |prefix| prefix.include?(value) } || - name.suffix.any { |suffix| suffix.include?(value) } - end - assert found, '#{element} on resource does not match #{element} requested') + value_found = can_resolve_path(resource, '#{path_parts.join('.') + get_value_path_by_type(type)}') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, '#{element} on resource does not match #{element} requested' +) else %( - when '#{element}' - name = #{path_parts.join('&.')} - found = name&.text&.include?(value) || - name.family.include?(value) || - name.given.any { |given| given&.include?(value) } || - name.prefix.any { |prefix| prefix.include?(value) } || - name.suffix.any { |suffix| suffix.include?(value) } - assert found, '#{element} on resource does not match #{element} requested') - end - when 'code', 'string', 'id' - search_validators += %( - when '#{element}' - assert #{path_parts.join('&.')} == value, '#{element} on resource did not match #{element} requested' -) - when 'Coding' - search_validators += %( - when '#{element}' - assert #{path_parts.join('&.')}&.code == value, '#{element} on resource did not match #{element} requested' -) - when 'Identifier' - search_validators += %( - when '#{element}' - assert #{path_parts.join('&.')}&.any? { |identifier| identifier.value == value }, '#{element} on resource did not match #{element} requested' -) - else - search_validators += %( - when '#{element}' + value_found = can_resolve_path(resource, '#{path_parts.join('.') + get_value_path_by_type(type)}') { |value_in_resource| value_in_resource == value } + assert value_found, '#{element} on resource does not match #{element} requested' ) + end end end diff --git a/generators/argonaut-r4/metadata_extractor.rb b/generators/uscore-r4/metadata_extractor.rb similarity index 68% rename from generators/argonaut-r4/metadata_extractor.rb rename to generators/uscore-r4/metadata_extractor.rb index e39af328c..210e9c0d0 100644 --- a/generators/argonaut-r4/metadata_extractor.rb +++ b/generators/uscore-r4/metadata_extractor.rb @@ -31,6 +31,29 @@ def extract_metadata @metadata end + def build_new_sequence(resource, profile) + base_name = profile.split('StructureDefinition/')[1] + profile_json = get_json_from_uri(profile_uri(base_name)) + profile_title = profile_json['title'].gsub(/US\s*Core\s*/, '').gsub(/\s*Profile/, '').strip + { + name: base_name.tr('-', '_'), + classname: base_name + .split('-') + .map(&:capitalize) + .join + .gsub('UsCore', 'USCoreR4') + 'Sequence', + resource: resource['type'], + profile: profile_uri(base_name), # link in capability statement is incorrect, + title: profile_title, + interactions: [], + searches: [], + search_param_descriptions: {}, + element_descriptions: {}, + must_supports: [], + tests: [] + } + end + def extract_metadata_from_resources(resources) data = { sequences: [] @@ -38,35 +61,15 @@ def extract_metadata_from_resources(resources) resources.each do |resource| resource['supportedProfile'].each do |supported_profile| - new_sequence = { - name: supported_profile.split('StructureDefinition/')[1].tr('-', '_'), - classname: supported_profile - .split('StructureDefinition/')[1] - .split('-') - .map(&:capitalize) - .join - .gsub('UsCore', 'UsCoreR4') + 'Sequence', - resource: resource['type'], - profile: profile_uri(supported_profile.split('StructureDefinition/')[1]), # link in capability statement is incorrect - interactions: [], - searches: [], - search_param_descriptions: {}, - element_descriptions: {}, - tests: [] - } + new_sequence = build_new_sequence(resource, supported_profile) - # add each basic search type add_basic_searches(resource, new_sequence) - - # add each search combination add_combo_searches(resource, new_sequence) - - # add each interaction add_interactions(resource, new_sequence) profile_definition = get_json_from_uri(new_sequence[:profile]) + add_must_support_elements(profile_definition, new_sequence) add_search_param_descriptions(profile_definition, new_sequence) - add_element_definitions(profile_definition, new_sequence) data[:sequences] << new_sequence @@ -119,6 +122,39 @@ def add_interactions(resource, sequence) end end + def add_must_support_elements(profile_definition, sequence) + profile_definition['snapshot']['element'].select { |el| el['mustSupport'] }.each do |element| + if element['path'].end_with? 'extension' + sequence[:must_supports] << + { + type: 'extension', + id: element['id'], + path: element['path'], + url: element['type'].first['profile'].first + } + next + end + + path = element['path'] + if path.include? '[x]' + choice_el = profile_definition['snapshot']['element'].find { |el| el['id'] == (path.split('[x]').first + '[x]') } + choice_el['type'].each do |type| + sequence[:must_supports] << + { + type: 'element', + path: path.gsub('[x]', type['code'].slice(0).capitalize + type['code'].slice(1..-1)) + } + end + else + sequence[:must_supports] << + { + type: 'element', + path: path + } + end + end + end + def add_search_param_descriptions(profile_definition, sequence) sequence[:search_param_descriptions].each_key do |param| search_param_definition = get_json_from_uri(search_param_uri(sequence[:resource], param.to_s)) @@ -130,16 +166,23 @@ def add_search_param_descriptions(profile_definition, sequence) path = path_parts[0] end profile_element = profile_definition['snapshot']['element'].select { |el| el['id'] == path }.first + param_metadata = { + path: path, + comparators: {} + } if !profile_element.nil? - sequence[:search_param_descriptions][param][:type] = profile_element['type'].first['code'] - sequence[:search_param_descriptions][param][:path] = path - sequence[:search_param_descriptions][param][:contains_multiple] = (profile_element['max'] == '*') + param_metadata[:type] = profile_element['type'].first['code'] + param_metadata[:contains_multiple] = (profile_element['max'] == '*') else # search is a variable type eg.) Condition.onsetDateTime - element in profile def is Condition.onset[x] - sequence[:search_param_descriptions][param][:type] = search_param_definition['type'] - sequence[:search_param_descriptions][param][:path] = path - sequence[:search_param_descriptions][param][:contains_multiple] = false + param_metadata[:type] = search_param_definition['type'] + param_metadata[:contains_multiple] = false + end + search_param_definition['comparator']&.each_with_index do |comparator, index| + expectation = search_param_definition['_comparator'][index]['extension'].first['valueCode'] + param_metadata[:comparators][comparator.to_sym] = expectation end + sequence[:search_param_descriptions][param] = param_metadata end end diff --git a/generators/argonaut-r4/templates/module.yml.erb b/generators/uscore-r4/templates/module.yml.erb similarity index 100% rename from generators/argonaut-r4/templates/module.yml.erb rename to generators/uscore-r4/templates/module.yml.erb diff --git a/generators/argonaut-r4/templates/sequence.rb.erb b/generators/uscore-r4/templates/sequence.rb.erb similarity index 84% rename from generators/argonaut-r4/templates/sequence.rb.erb rename to generators/uscore-r4/templates/sequence.rb.erb index 3e1ea3db6..cc15d93a0 100644 --- a/generators/argonaut-r4/templates/sequence.rb.erb +++ b/generators/uscore-r4/templates/sequence.rb.erb @@ -5,7 +5,7 @@ module Inferno class <%=classname%> < SequenceBase group 'US Core R4 Profile Conformance' - title '<%= classname.gsub('UsCoreR4','').gsub('Sequence','') %> Tests' + title '<%=title%> Tests' description 'Verify that <%=resource%> resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -17,7 +17,7 @@ module Inferno details %( The #{title} Sequence tests `#{title.gsub(/\s+/, '')}` resources associated with the provided patient. The resources - returned will be checked for consistency against the [<%= classname.gsub('UsCoreR4','').gsub('Sequence','') %> Argonaut Profile](<%=profile.gsub('.json','')%>) + returned will be checked for consistency against the [<%= classname.gsub('USCoreR4','').gsub('Sequence','') %> Argonaut Profile](<%=profile.gsub('.json','')%>) ) @@ -25,7 +25,7 @@ module Inferno test '<%=test[:tests_that]%>' do metadata do - id '<%=test[:index]%>' + id '<%=format('%02d', test[:index])%>' link '<%=test[:link]%>' desc %( ) diff --git a/lib/app.rb b/lib/app.rb index 1cc4f1c23..a3dc3bc27 100644 --- a/lib/app.rb +++ b/lib/app.rb @@ -9,6 +9,7 @@ require 'rest-client' require 'time_difference' require 'pry' +require 'pry-byebug' require 'dm-core' require 'dm-migrations' require 'jwt' @@ -30,8 +31,8 @@ class App attr_reader :app def initialize @app = Rack::Builder.app do - Endpoint.subclasses.each do |e| - map(e.prefix) { run(e.new) } + Endpoint.subclasses.each do |endpoint| + map(endpoint.prefix) { run(endpoint.new) } end end end diff --git a/lib/app/endpoint.rb b/lib/app/endpoint.rb index d4c76457a..03e71dabc 100644 --- a/lib/app/endpoint.rb +++ b/lib/app/endpoint.rb @@ -20,20 +20,24 @@ class Endpoint < Sinatra::Base Inferno::EXTRAS = settings.include_extras if settings.logging_enabled - Inferno.logger = if settings.log_to_file - ::Logger.new('logs.log', level: settings.log_level.to_sym, progname: 'Inferno') - else - l = ::Logger.new(STDOUT, level: settings.log_level.to_sym, progname: 'Inferno') - l.formatter = proc do |severity, _datetime, progname, msg| - "#{severity} | #{progname} | #{msg}\n" - end - l - end + Inferno.logger = + if ENV['RACK_ENV'] == 'test' + FileUtils.mkdir_p 'tmp' + ::Logger.new(File.join('tmp', 'test.log'), level: settings.log_level.to_sym, progname: 'Inferno') + elsif settings.log_to_file + ::Logger.new('logs.log', level: settings.log_level.to_sym, progname: 'Inferno') + else + l = ::Logger.new(STDOUT, level: settings.log_level.to_sym, progname: 'Inferno') + l.formatter = proc do |severity, _datetime, progname, msg| + "#{severity} | #{progname} | #{msg}\n" + end + l + end # FIXME: Really don't want a direct dependency to DataMapper here DataMapper.logger = Inferno.logger if Inferno::ENVIRONMENT == :development - FHIR.logger = Inferno.logger + FHIR.logger = FHIR::STU3.logger = FHIR::DSTU2.logger = Inferno.logger Inferno.logger.info "Environment: #{Inferno::ENVIRONMENT}" diff --git a/lib/app/endpoint/home.rb b/lib/app/endpoint/home.rb index b25c50cd3..3dd157a3f 100644 --- a/lib/app/endpoint/home.rb +++ b/lib/app/endpoint/home.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require_relative 'oauth2_endpoints' +require_relative 'test_set_endpoints' + module Inferno class App class Endpoint @@ -10,271 +13,14 @@ class Home < Endpoint # Set the url prefix these routes will map to set :prefix, "/#{base_path}" + include OAuth2Endpoints + include TestSetEndpoints + # Return the index page of the application get '/?' do render_index end - # Returns the static files associated with web app - get '/static/*' do - call! env.merge('PATH_INFO' => '/' + params['splat'].first) - end - - # Resume oauth2 flow - # This must be early so it doesn't get picked up by the other routes - get '/oauth2/:key/:endpoint/?' do - instance = nil - error_message = nil - - if params[:endpoint] == 'redirect' - instance = Inferno::Models::TestingInstance.first(state: params[:state]) - if instance.nil? - instance = Inferno::Models::TestingInstance.get(cookies[:instance_id_test_set]&.split('/')&.first) - if instance.nil? - error_message = %( -

- Inferno has detected an issue with the SMART launch. - No actively running launch sequences found with a state of #{params[:state]}. - The authorization server is not returning the correct state variable and - therefore Inferno cannot identify which server is currently under test. - Please click your browser's "Back" button to return to Inferno, - and click "Refresh" to ensure that the most recent test results are visible. -

- ) - - error_message += "

Error returned by server: #{params[:error]}.

" if params[:error].present? - - error_message += "

Error description returned by server: #{params[:error_description]}.

" unless params[:error_description].nil? - - halt 500, error_message - elsif instance&.waiting_on_sequence&.wait? - error_message = "State provided in redirect (#{params[:state]}) does not match expected state (#{instance.state})." - else - redirect "#{base_path}/#{cookies[:instance_id_test_set]}/?error=no_state&state=#{params[:state]}" - end - end - end - - if params[:endpoint] == 'launch' - recent_results = Inferno::Models::SequenceResult.all( - :created_at.gte => 5.minutes.ago, - :result => 'wait', - :order => [:created_at.desc] - ) - iss_url = params[:iss]&.downcase&.split('://')&.last&.chomp('/') - - matching_results = recent_results.select do |sr| - testing_instance_url = sr.testing_instance.url.downcase.split('://').last.chomp('/') - testing_instance_url == iss_url - end - - instance = matching_results&.first&.testing_instance - if instance.nil? - instance = Inferno::Models::TestingInstance.get(cookies[:instance_id_test_set]&.split('/')&.first) - if instance.nil? - message = "Error: No actively running launch sequences found for iss #{params[:iss]}. " \ - 'Please ensure that the EHR launch test is actively running before attempting to launch Inferno from the EHR.' - halt 500, message - elsif instance&.waiting_on_sequence&.wait? - error_message = 'No iss for redirect' - else - redirect "#{base_path}/#{cookies[:instance_id_test_set]}/?error=no_ehr_launch&iss=#{params[:iss]}" - end - end - end - halt 500, 'Error: Could not find a running test that match this set of critera' unless !instance.nil? && - instance.client_endpoint_key == params[:key] && - %w[launch redirect].include?(params[:endpoint]) - - sequence_result = instance.waiting_on_sequence - if sequence_result&.wait? - test_set = instance.module.test_sets[sequence_result.test_set_id.to_sym] - failed_test_cases = [] - all_test_cases = [] - test_case = test_set.test_case_by_id(sequence_result.test_case_id) - test_group = test_case.test_group - - client = FHIR::Client.new(instance.url) - case instance.fhir_version - when 'stu3' - client.use_stu3 - when 'dstu2' - client.use_dstu2 - else - client.use_r4 - end - client.default_json - sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests, sequence_result) - first_test_count = sequence.test_count - - timer_count = 0 - stayalive_timer_seconds = 20 - - finished = false - - stream :keep_open do |out| - EventMachine::PeriodicTimer.new(stayalive_timer_seconds) do - timer_count += 1 - out << js_stayalive(timer_count * stayalive_timer_seconds) - end - - # finish the inprocess stream - - out << erb(instance.module.view_by_test_set(test_set.id), - {}, - instance: instance, - test_set: test_set, - sequence_results: instance.latest_results_by_case, - tests_running: true) - - out << js_hide_wait_modal - out << js_show_test_modal - count = sequence_result.test_results.length - - submitted_test_cases_count = sequence_result.next_test_cases.split(',') - total_tests = submitted_test_cases_count.reduce(first_test_count) do |total, set| - sequence_test_count = test_set.test_case_by_id(set).sequence.test_count - total + sequence_test_count - end - - sequence_result = sequence.resume(request, headers, request.params, error_message) do |result| - count += 1 - out << js_update_result(sequence, test_set, result, count, sequence.test_count, count, total_tests) - instance.save! - end - all_test_cases << test_case.id - failed_test_cases << test_case.id if sequence_result.fail? - instance.sequence_results.push(sequence_result) - instance.save! - - submitted_test_cases = sequence_result.next_test_cases.split(',') - - next_test_case = submitted_test_cases.shift - finished = next_test_case.nil? - if sequence_result.redirect_to_url - out << js_redirect_modal(sequence_result.redirect_to_url, sequence_result, instance) - next_test_case = nil - finished = false - elsif !submitted_test_cases.empty? - out << js_next_sequence(sequence_result.next_test_cases) - else - finished = true - end - - # continue processesing any afterwards - - test_count = first_test_count - until next_test_case.nil? - test_case = test_set.test_case_by_id(next_test_case) - - next_test_case = submitted_test_cases.shift - if test_case.nil? - finished = next_test_case.nil? - next - end - - out << js_show_test_modal - - instance.reload # ensure that we have all the latest data - sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests) - count = 0 - sequence_result = sequence.start do |result| - test_count += 1 - count += 1 - out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests) - end - all_test_cases << test_case.id - failed_test_cases << test_case.id if sequence_result.fail? - - sequence_result.test_set_id = test_set.id - sequence_result.test_case_id = test_case.id - - sequence_result.next_test_cases = ([next_test_case] + submitted_test_cases).join(',') - - sequence_result.save! - if sequence_result.redirect_to_url - out << js_redirect_modal(sequence_result.redirect_to_url, sequence_result, instance) - finished = false - elsif !submitted_test_cases.empty? - out << js_next_sequence(sequence_result.next_test_cases) - else - finished = true - end - end - - query_target = failed_test_cases.join(',') - query_target = all_test_cases.join(',') if all_test_cases.length == 1 - - query_target = "#{test_group.id}/#{query_target}" unless test_group.nil? - - out << js_redirect("#{base_path}/#{instance.id}/#{test_set.id}/##{query_target}") if finished - end - else - latest_sequence_result = Inferno::Models::SequenceResult.first(testing_instance: instance) - test_set_id = latest_sequence_result&.test_set_id || instance.module.default_test_set - redirect "#{BASE_PATH}/#{instance.id}/#{test_set_id}/?error=no_#{params[:endpoint]}" - end - end - - # Returns a specific testing instance test page - get '/:id/?' do - instance = Inferno::Models::TestingInstance.get(params[:id]) - halt 404 if instance.nil? - - redirect "#{base_path}/#{instance.id}/#{instance.module.default_test_set}/#{'?error=' + params[:error] unless params[:error].nil?}" - end - - # Returns a specific testing instance test page - get '/:id/:test_set/?' do - instance = Inferno::Models::TestingInstance.get(params[:id]) - halt 404 if instance.nil? - test_set = instance.module.test_sets[params[:test_set].to_sym] - halt 404 if test_set.nil? - sequence_results = instance.latest_results_by_case - - erb instance.module.view_by_test_set(params[:test_set]), {}, instance: instance, - test_set: test_set, - sequence_results: sequence_results, - error_code: params[:error] - end - - get '/:id/:test_set/report?' do - instance = Inferno::Models::TestingInstance.get(params[:id]) - halt 404 if instance.nil? - test_set = instance.module.test_sets[params[:test_set].to_sym] - halt 404 if test_set.nil? - sequence_results = instance.latest_results_by_case - - request_response_count = Inferno::Models::RequestResponse.all(instance_id: instance.id).count - latest_sequence_time = - if instance.sequence_results.count.positive? - Inferno::Models::SequenceResult.first(testing_instance: instance).created_at.strftime('%m/%d/%Y %H:%M') - else - 'No tests ran' - end - - report_summary = { - fhir_version: instance.fhir_version, - app_version: VERSION, - resource_references: instance.resource_references.count, - supported_resources: instance.supported_resources.count, - request_response: request_response_count, - latest_sequence_time: latest_sequence_time, - final_result: instance.final_result(params[:test_set]), - inferno_url: "#{request.base_url}#{base_path}/#{instance.id}/#{params[:test_set]}/" - } - - erb( - :report, - { layout: false }, - instance: instance, - test_set: test_set, - show_button: false, - sequence_results: sequence_results, - report_summary: report_summary - ) - end - # Creates a new testing instance at the provided FHIR Server URL post '/?' do url = params['fhir_server'] @@ -282,7 +28,7 @@ class Home < Endpoint inferno_module = Inferno::Module.get(params[:module]) if inferno_module.nil? - FHIR.logger.error "Unknown module: #{params[:module]}" + Inferno.logger.error "Unknown module: #{params[:module]}" halt 404, "Unknown module: #{params[:module]}" end @@ -306,13 +52,26 @@ class Home < Endpoint @instance.initiate_login_uri = "#{request.base_url}#{base_path}/oauth2/#{@instance.client_endpoint_key}/launch" @instance.redirect_uris = "#{request.base_url}#{base_path}/oauth2/#{@instance.client_endpoint_key}/redirect" - cookies[:instance_id_test_set] = "#{@instance.id}/#{inferno_module.default_test_set}" + cookies[:instance_id_test_set] = "#{@instance.id}/test_sets/#{inferno_module.default_test_set}" @instance.save! redirect "#{base_path}/#{@instance.id}/#{'?autoRun=CapabilityStatementSequence' if settings.autorun_capability}" end + # Returns the static files associated with web app + get '/static/*' do + call! env.merge('PATH_INFO' => '/' + params['splat'].first) + end + + # Returns a specific testing instance test page + get '/:id/?' do + instance = Inferno::Models::TestingInstance.get(params[:id]) + halt 404 if instance.nil? + + redirect "#{base_path}/#{instance.id}/test_sets/#{instance.module.default_test_set}/#{'?error=' + params[:error] unless params[:error].nil?}" + end + # Returns test details for a specific test including any applicable requests and responses. # This route is typically used for retrieving test metadata before the test has been run get '/test_details/:module/:sequence_name/:test_index?' do @@ -340,162 +99,6 @@ class Home < Endpoint halt 404 if request_response.instance_id != params[:id] erb :request_details, { layout: false }, rr: request_response end - - # Cancels the currently running test - get '/:id/:test_set/sequence_result/:sequence_result_id/cancel' do - sequence_result = Inferno::Models::SequenceResult.get(params[:sequence_result_id]) - halt 404 if sequence_result.testing_instance.id != params[:id] - test_set = sequence_result.testing_instance.module.test_sets[params[:test_set].to_sym] - halt 404 if test_set.nil? - - sequence_result.result = 'cancel' - cancel_message = 'Test cancelled by user.' - - unless sequence_result.test_results.empty? - last_result = sequence_result.test_results.last - last_result.result = 'cancel' - last_result.message = cancel_message - end - - sequence = sequence_result.testing_instance.module.sequences.find do |x| - x.sequence_name == sequence_result.name - end - - current_test_count = sequence_result.test_results.length - - sequence.tests.each_with_index do |test, index| - next if index < current_test_count - - sequence_result.test_results << Inferno::Models::TestResult.new(test_id: test[:test_id], - name: test[:name], - result: 'cancel', - url: test[:url], - description: test[:description], - test_index: test[:test_index], - message: cancel_message) - end - - sequence_result.save! - - test_group = test_set.test_case_by_id(sequence_result.test_case_id).test_group - - query_target = sequence_result.test_case_id - query_target = "#{test_group.id}/#{sequence_result.test_case_id}" unless test_group.nil? - - redirect "#{base_path}/#{params[:id]}/#{params[:test_set]}/##{query_target}" - end - - get '/:id/:test_set/sequence_result?' do - redirect "#{base_path}/#{params[:id]}/#{params[:test_set]}/" - end - - # Run a sequence and get the results - post '/:id/:test_set/sequence_result?' do - instance = Inferno::Models::TestingInstance.get(params[:id]) - halt 404 if instance.nil? - test_set = instance.module.test_sets[params[:test_set].to_sym] - halt 404 if test_set.nil? - - cookies[:instance_id_test_set] = "#{instance.id}/#{params[:test_set]}" - - # Save params - params[:required_fields].split(',').each do |field| - instance.send("#{field}=", params[field]) if instance.respond_to? field - end - - instance.save! - - client = FHIR::Client.new(instance.url) - case instance.fhir_version - when 'stu3' - client.use_stu3 - when 'dstu2' - client.use_dstu2 - else - client.use_r4 - end - client.default_json - submitted_test_cases = params[:test_case].split(',') - - instance.reload # ensure that we have all the latest data - - total_tests = submitted_test_cases.reduce(0) do |total, set| - sequence_test_count = test_set.test_case_by_id(set).sequence.test_count - total + sequence_test_count - end - - test_group = nil - test_group = test_set.test_case_by_id(submitted_test_cases.first).test_group - failed_test_cases = [] - all_test_cases = [] - - timer_count = 0 - stayalive_timer_seconds = 20 - - finished = false - stream :keep_open do |out| - EventMachine::PeriodicTimer.new(stayalive_timer_seconds) do - timer_count += 1 - out << js_stayalive(timer_count * stayalive_timer_seconds) - end - - out << erb(instance.module.view_by_test_set(params[:test_set]), {}, instance: instance, - test_set: test_set, - sequence_results: instance.latest_results_by_case, - tests_running: true) - - next_test_case = submitted_test_cases.shift - finished = next_test_case.nil? - - test_count = 0 - until next_test_case.nil? - test_case = test_set.test_case_by_id(next_test_case) - - next_test_case = submitted_test_cases.shift - if test_case.nil? - finished = next_test_case.nil? - next - end - - out << js_show_test_modal - - instance.reload # ensure that we have all the latest data - sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests) - count = 0 - sequence_result = sequence.start(test_set.id, test_case.id) do |result| - count += 1 - test_count += 1 - out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests) - end - - sequence_result.next_test_cases = ([next_test_case] + submitted_test_cases).join(',') - - all_test_cases << test_case.id - failed_test_cases << test_case.id if sequence_result.fail? - - sequence_result.save! - if sequence_result.redirect_to_url - out << js_redirect_modal(sequence_result.redirect_to_url, sequence_result, instance) - next_test_case = nil - finished = false - elsif sequence_result.wait_at_endpoint - next_test_case = nil - finished = true - elsif !submitted_test_cases.empty? - out << js_next_sequence(sequence_result.next_test_cases) - else - finished = true - end - end - - query_target = failed_test_cases.join(',') - query_target = all_test_cases.join(',') if all_test_cases.length == 1 - - query_target = "#{test_group.id}/#{query_target}" unless test_group.nil? - - out << js_redirect("#{base_path}/#{params[:id]}/#{params[:test_set]}/##{query_target}") if finished - end - end end end end diff --git a/lib/app/endpoint/oauth2_endpoints.rb b/lib/app/endpoint/oauth2_endpoints.rb new file mode 100644 index 000000000..ce7d560a1 --- /dev/null +++ b/lib/app/endpoint/oauth2_endpoints.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +module Inferno + class App + module OAuth2Endpoints + def self.included(klass) + klass.class_eval do + # Resume oauth2 flow + # This must be early so it doesn't get picked up by the other routes + get '/oauth2/:key/:endpoint/?' do + instance = nil + error_message = nil + + if params[:endpoint] == 'redirect' + instance = Inferno::Models::TestingInstance.first(state: params[:state]) + if instance.nil? + instance = Inferno::Models::TestingInstance.get(cookies[:instance_id_test_set]&.split('/')&.first) + if instance.nil? + error_message = %( +

+ Inferno has detected an issue with the SMART launch. + No actively running launch sequences found with a state of #{params[:state]}. + The authorization server is not returning the correct state variable and + therefore Inferno cannot identify which server is currently under test. + Please click your browser's "Back" button to return to Inferno, + and click "Refresh" to ensure that the most recent test results are visible. +

+ ) + + error_message += "

Error returned by server: #{params[:error]}.

" if params[:error].present? + + error_message += "

Error description returned by server: #{params[:error_description]}.

" unless params[:error_description].nil? + + halt 500, error_message + elsif instance&.waiting_on_sequence&.wait? + error_message = "State provided in redirect (#{params[:state]}) does not match expected state (#{instance.state})." + else + redirect "#{base_path}/#{cookies[:instance_id_test_set]}/?error=no_state&state=#{params[:state]}" + end + end + end + + if params[:endpoint] == 'launch' + recent_results = Inferno::Models::SequenceResult.all( + :created_at.gte => 5.minutes.ago, + :result => 'wait', + :order => [:created_at.desc] + ) + iss_url = params[:iss]&.downcase&.split('://')&.last&.chomp('/') + + matching_results = recent_results.select do |sr| + testing_instance_url = sr.testing_instance.url.downcase.split('://').last.chomp('/') + testing_instance_url == iss_url + end + + instance = matching_results&.first&.testing_instance + if instance.nil? + instance = Inferno::Models::TestingInstance.get(cookies[:instance_id_test_set]&.split('/')&.first) + if instance.nil? + message = "Error: No actively running launch sequences found for iss #{params[:iss]}. " \ + 'Please ensure that the EHR launch test is actively running before attempting to launch Inferno from the EHR.' + halt 500, message + elsif instance&.waiting_on_sequence&.wait? + error_message = 'No iss for redirect' + else + redirect "#{base_path}/#{cookies[:instance_id_test_set]}/?error=no_ehr_launch&iss=#{params[:iss]}" + end + end + end + halt 500, 'Error: Could not find a running test that match this set of critera' unless !instance.nil? && + instance.client_endpoint_key == params[:key] && + %w[launch redirect].include?(params[:endpoint]) + + sequence_result = instance.waiting_on_sequence + if sequence_result&.wait? + test_set = instance.module.test_sets[sequence_result.test_set_id.to_sym] + failed_test_cases = [] + all_test_cases = [] + test_case = test_set.test_case_by_id(sequence_result.test_case_id) + test_group = test_case.test_group + + client = FHIR::Client.new(instance.url) + case instance.fhir_version + when 'stu3' + client.use_stu3 + when 'dstu2' + client.use_dstu2 + else + client.use_r4 + end + client.default_json + sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests, sequence_result) + first_test_count = sequence.test_count + + timer_count = 0 + stayalive_timer_seconds = 20 + + finished = false + + stream :keep_open do |out| + EventMachine::PeriodicTimer.new(stayalive_timer_seconds) do + timer_count += 1 + out << js_stayalive(timer_count * stayalive_timer_seconds) + end + + # finish the inprocess stream + + out << erb(instance.module.view_by_test_set(test_set.id), + {}, + instance: instance, + test_set: test_set, + sequence_results: instance.latest_results_by_case, + tests_running: true, + test_group: test_group.id) + + out << js_hide_wait_modal + out << js_show_test_modal + count = sequence_result.result_count + + submitted_test_cases_count = sequence_result.next_test_cases.split(',') + total_tests = submitted_test_cases_count.reduce(first_test_count) do |total, set| + sequence_test_count = test_set.test_case_by_id(set).sequence.test_count + total + sequence_test_count + end + + sequence_result = sequence.resume(request, headers, request.params, error_message) do |result| + count += 1 + out << js_update_result(sequence, test_set, result, count, sequence.test_count, count, total_tests) + instance.save! + end + all_test_cases << test_case.id + failed_test_cases << test_case.id if sequence_result.fail? + instance.sequence_results.push(sequence_result) + instance.save! + + submitted_test_cases = sequence_result.next_test_cases.split(',') + + next_test_case = submitted_test_cases.shift + finished = next_test_case.nil? + if sequence_result.redirect_to_url + out << js_redirect_modal(sequence_result.redirect_to_url, sequence_result, instance) + next_test_case = nil + finished = false + elsif !submitted_test_cases.empty? + out << js_next_sequence(sequence_result.next_test_cases) + else + finished = true + end + + # continue processesing any afterwards + + test_count = first_test_count + until next_test_case.nil? + test_case = test_set.test_case_by_id(next_test_case) + + next_test_case = submitted_test_cases.shift + if test_case.nil? + finished = next_test_case.nil? + next + end + + out << js_show_test_modal + + instance.reload # ensure that we have all the latest data + sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests) + count = 0 + sequence_result = sequence.start do |result| + test_count += 1 + count += 1 + out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests) + end + all_test_cases << test_case.id + failed_test_cases << test_case.id if sequence_result.fail? + + sequence_result.test_set_id = test_set.id + sequence_result.test_case_id = test_case.id + + sequence_result.next_test_cases = ([next_test_case] + submitted_test_cases).join(',') + + sequence_result.save! + if sequence_result.redirect_to_url + out << js_redirect_modal(sequence_result.redirect_to_url, sequence_result, instance) + finished = false + elsif !submitted_test_cases.empty? + out << js_next_sequence(sequence_result.next_test_cases) + else + finished = true + end + end + + query_target = failed_test_cases.join(',') + query_target = all_test_cases.join(',') if all_test_cases.length == 1 + + query_target = "#{test_group.id}/#{query_target}" unless test_group.nil? + + out << js_redirect("#{base_path}/#{instance.id}/test_sets/#{test_set.id}/##{query_target}") if finished + end + else + latest_sequence_result = Inferno::Models::SequenceResult.first(testing_instance: instance) + test_set_id = latest_sequence_result&.test_set_id || instance.module.default_test_set + redirect "#{BASE_PATH}/#{instance.id}/test_sets/#{test_set_id}/?error=no_#{params[:endpoint]}" + end + end + end + end + end + end +end diff --git a/lib/app/endpoint/test_set_endpoints.rb b/lib/app/endpoint/test_set_endpoints.rb new file mode 100644 index 000000000..c58ef8cdf --- /dev/null +++ b/lib/app/endpoint/test_set_endpoints.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +module Inferno + class App + module TestSetEndpoints + def self.included(klass) + klass.class_eval do + # Returns a specific testing instance test page + get '/:id/test_sets/:test_set_id/?' do + instance = Inferno::Models::TestingInstance.get(params[:id]) + halt 404 if instance.nil? + test_set = instance.module.test_sets[params[:test_set_id].to_sym] + halt 404 if test_set.nil? + sequence_results = instance.latest_results_by_case + + erb( + instance.module.view_by_test_set(params[:test_set_id]), + {}, + instance: instance, + test_set: test_set, + sequence_results: sequence_results, + error_code: params[:error] + ) + end + + get '/:id/test_sets/:test_set_id/report?' do + instance = Inferno::Models::TestingInstance.get(params[:id]) + halt 404 if instance.nil? + test_set = instance.module.test_sets[params[:test_set_id].to_sym] + halt 404 if test_set.nil? + sequence_results = instance.latest_results_by_case + + request_response_count = Inferno::Models::RequestResponse.all(instance_id: instance.id).count + latest_sequence_time = + if instance.sequence_results.count.positive? + Inferno::Models::SequenceResult.first(testing_instance: instance).created_at.strftime('%m/%d/%Y %H:%M') + else + 'No tests ran' + end + + report_summary = { + fhir_version: instance.fhir_version, + app_version: VERSION, + resource_references: instance.resource_references.count, + supported_resources: instance.supported_resources.count, + request_response: request_response_count, + latest_sequence_time: latest_sequence_time, + final_result: instance.final_result(params[:test_set_id]), + inferno_url: "#{request.base_url}#{base_path}/#{instance.id}/test_sets/#{params[:test_set_id]}/" + } + + erb( + :report, + { layout: false }, + instance: instance, + test_set: test_set, + show_button: false, + sequence_results: sequence_results, + report_summary: report_summary + ) + end + + # Cancels the currently running test + get '/:id/test_sets/:test_set_id/sequence_result/:sequence_result_id/cancel' do + sequence_result = Inferno::Models::SequenceResult.get(params[:sequence_result_id]) + halt 404 if sequence_result.testing_instance.id != params[:id] + test_set = sequence_result.testing_instance.module.test_sets[params[:test_set_id].to_sym] + halt 404 if test_set.nil? + + sequence_result.result = 'cancel' + cancel_message = 'Test cancelled by user.' + + unless sequence_result.test_results.empty? + last_result = sequence_result.test_results.last + last_result.result = 'cancel' + last_result.message = cancel_message + end + + sequence = sequence_result.testing_instance.module.sequences.find do |x| + x.sequence_name == sequence_result.name + end + + current_test_count = sequence_result.result_count + + sequence.tests.each_with_index do |test, index| + next if index < current_test_count + + sequence_result.test_results << Inferno::Models::TestResult.new(test_id: test[:test_id], + name: test[:name], + result: 'cancel', + url: test[:url], + description: test[:description], + test_index: test[:test_index], + message: cancel_message) + end + + sequence_result.save! + + test_group = test_set.test_case_by_id(sequence_result.test_case_id).test_group + + query_target = sequence_result.test_case_id + query_target = "#{test_group.id}/#{sequence_result.test_case_id}" unless test_group.nil? + + redirect "#{base_path}/#{params[:id]}/test_sets/#{params[:test_set_id]}/##{query_target}" + end + + get '/:id/test_sets/:test_set_id/sequence_result?' do + redirect "#{base_path}/#{params[:id]}/test_sets/#{params[:test_set_id]}/" + end + + # Run a sequence and get the results + post '/:id/test_sets/:test_set_id/sequence_result?' do + instance = Inferno::Models::TestingInstance.get(params[:id]) + halt 404 if instance.nil? + test_set = instance.module.test_sets[params[:test_set_id].to_sym] + halt 404 if test_set.nil? + + cookies[:instance_id_test_set] = "#{instance.id}/test_sets/#{params[:test_set_id]}" + + # Save params + params[:required_fields].split(',').each do |field| + instance.send("#{field}=", params[field]) if instance.respond_to? field + end + + instance.save! + + client = FHIR::Client.new(instance.url) + case instance.fhir_version + when 'stu3' + client.use_stu3 + when 'dstu2' + client.use_dstu2 + else + client.use_r4 + end + client.default_json + submitted_test_cases = params[:test_case].split(',') + + instance.reload # ensure that we have all the latest data + + total_tests = submitted_test_cases.reduce(0) do |total, set| + sequence_test_count = test_set.test_case_by_id(set).sequence.test_count + total + sequence_test_count + end + + test_group = nil + test_group = test_set.test_case_by_id(submitted_test_cases.first).test_group + failed_test_cases = [] + all_test_cases = [] + + timer_count = 0 + stayalive_timer_seconds = 20 + + finished = false + stream :keep_open do |out| + EventMachine::PeriodicTimer.new(stayalive_timer_seconds) do + timer_count += 1 + out << js_stayalive(timer_count * stayalive_timer_seconds) + end + + out << erb( + instance.module.view_by_test_set(params[:test_set_id]), + {}, + instance: instance, + test_set: test_set, + sequence_results: instance.latest_results_by_case, + tests_running: true, + test_group: test_group.id + ) + + next_test_case = submitted_test_cases.shift + finished = next_test_case.nil? + + test_count = 0 + until next_test_case.nil? + test_case = test_set.test_case_by_id(next_test_case) + + next_test_case = submitted_test_cases.shift + if test_case.nil? + finished = next_test_case.nil? + next + end + + out << js_show_test_modal + + instance.reload # ensure that we have all the latest data + sequence = test_case.sequence.new(instance, client, settings.disable_tls_tests) + count = 0 + sequence_result = sequence.start(test_set.id, test_case.id) do |result| + count += 1 + test_count += 1 + out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests) + end + + sequence_result.next_test_cases = ([next_test_case] + submitted_test_cases).join(',') + + all_test_cases << test_case.id + failed_test_cases << test_case.id if sequence_result.fail? + + sequence_result.save! + if sequence_result.redirect_to_url + out << js_redirect_modal(sequence_result.redirect_to_url, sequence_result, instance) + next_test_case = nil + finished = false + elsif sequence_result.wait_at_endpoint + next_test_case = nil + finished = true + elsif !submitted_test_cases.empty? + out << js_next_sequence(sequence_result.next_test_cases) + else + finished = true + end + end + + query_target = failed_test_cases.join(',') + query_target = all_test_cases.join(',') if all_test_cases.length == 1 + + query_target = "#{test_group.id}/#{query_target}" unless test_group.nil? + + out << js_redirect("#{base_path}/#{params[:id]}/test_sets/#{params[:test_set_id]}/##{query_target}") if finished + end + end + end + end + end + end +end diff --git a/lib/app/models/resource_reference.rb b/lib/app/models/resource_reference.rb index f8b03f5b2..39996dc54 100644 --- a/lib/app/models/resource_reference.rb +++ b/lib/app/models/resource_reference.rb @@ -7,6 +7,7 @@ class ResourceReference property :id, String, key: true, default: proc { SecureRandom.uuid } property :resource_type, String property :resource_id, String + property :profile, String belongs_to :testing_instance end diff --git a/lib/app/models/sequence_result.rb b/lib/app/models/sequence_result.rb index 5a63b8bb6..453528384 100644 --- a/lib/app/models/sequence_result.rb +++ b/lib/app/models/sequence_result.rb @@ -52,6 +52,46 @@ def reset! 'optional_total' ].each { |field| send("#{field}=", 0) } end + + def result_count + test_results.length + end + + def update_result_counts + test_results.each do |result| + if result.required + self.required_total += 1 + else + self.optional_total += 1 + end + case result.result + when ResultStatuses::PASS + if result.required + self.required_passed += 1 + else + self.optional_passed += 1 + end + when ResultStatuses::TODO + self.todo_count += 1 + when ResultStatuses::FAIL + if result.required + self.result = result.result unless error? + end + when ResultStatuses::ERROR + if result.required + self.error_count += 1 + self.result = result.result + end + when ResultStatuses::SKIP + if result.required + self.skip_count += 1 + self.result = result.result if pass? + end + when ResultStatuses::WAIT + self.result = result.result + end + end + end end end end diff --git a/lib/app/models/testing_instance.rb b/lib/app/models/testing_instance.rb index c65ad3f33..e91bb7e93 100644 --- a/lib/app/models/testing_instance.rb +++ b/lib/app/models/testing_instance.rb @@ -52,6 +52,8 @@ class TestingInstance property :dynamic_registration_token, String + property :must_support_confirmed, String, default: '' + has n, :sequence_results has n, :supported_resources, order: [:index.asc] has n, :resource_references @@ -161,6 +163,7 @@ def save_supported_resources(conformance) resources = ['Patient', 'AllergyIntolerance', 'CarePlan', + 'CareTeam', 'Condition', 'Device', 'DiagnosticReport', @@ -169,20 +172,25 @@ def save_supported_resources(conformance) 'ExplanationOfBenefit', 'Goal', 'Immunization', + 'Location', 'Medication', 'MedicationDispense', 'MedicationStatement', + 'MedicationRequest', 'MedicationOrder', 'Observation', + 'Organization', 'Procedure', 'DocumentReference', - 'Provenance'] + 'Provenance', + 'Practitioner', + 'PractitionerRole'] supported_resource_capabilities = conformance .rest.first.resource .select { |resource| resources.include? resource.type } - .each_with_object({}) { |resource, hash| hash[resource.type] = resource } + .index_by(&:type) supported_resources.each(&:destroy) save! @@ -225,17 +233,43 @@ def conformance_supported?(resource, methods = []) end end - def post_resource_references(resource_type: nil, resource_id: nil) - resource_references.each do |ref| - ref.destroy if (ref.resource_type == resource_type) && (ref.resource_id == resource_id) - end - resource_references << ResourceReference.new(resource_type: resource_type, - resource_id: resource_id) + def save_resource_reference(type, id, profile = nil) + resource_references + .select { |ref| (ref.resource_type == type) && (ref.resource_id == id) } + .each(&:destroy) + + new_reference = ResourceReference.new( + resource_type: type, + resource_id: id, + profile: profile + ) + resource_references << new_reference + save! # Ensure the instance resource references are accurate reload end + def save_resource_ids_in_bundle(klass, reply, profile = nil) + return if reply&.resource&.entry&.blank? + + reply.resource.entry + .select { |entry| entry.resource.class == klass } + .each do |entry| + save_resource_reference(klass.name.demodulize, entry.resource.id, profile) + end + end + + def versioned_conformance_class + if fhir_version == 'dstu2' + FHIR::DSTU2::Conformance + elsif fhir_version == 'stu3' + FHIR::STU3::CapabilityStatement + else + FHIR::CapabilityStatement + end + end + private def interaction_supported?(capabilities, interaction_code) diff --git a/lib/app/modules/argonaut/argonaut_careplan_sequence.rb b/lib/app/modules/argonaut/argonaut_careplan_sequence.rb index 15af8d612..cc9659ee2 100644 --- a/lib/app/modules/argonaut/argonaut_careplan_sequence.rb +++ b/lib/app/modules/argonaut/argonaut_careplan_sequence.rb @@ -3,6 +3,8 @@ module Inferno module Sequence class ArgonautCarePlanSequence < SequenceBase + PROFILE = Inferno::ValidationUtil::ARGONAUT_URIS[:care_plan] + group 'Argonaut Profile Conformance' title 'Care Plan' @@ -102,7 +104,7 @@ def validate_resource_item(resource, property, value) @careplan = reply.try(:resource).try(:entry).try(:first).try(:resource) validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('CarePlan'), reply) + save_resource_ids_in_bundle(versioned_resource_class('CarePlan'), reply, PROFILE) end test 'Server returns expected results from CarePlan search by patient + category + date' do @@ -228,9 +230,9 @@ def validate_resource_item(resource, property, value) ) versions :dstu2 end - test_resources_against_profile('CarePlan', Inferno::ValidationUtil::ARGONAUT_URIS[:care_plan]) - skip_unless @profiles_encountered.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:care_plan]), 'No CarePlans found.' - assert !@profiles_failed.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:care_plan]), "CarePlans failed validation.
#{@profiles_failed[Inferno::ValidationUtil::ARGONAUT_URIS[:care_plan]]}" + test_resources_against_profile('CarePlan', PROFILE) + skip_unless @profiles_encountered.include?(PROFILE), 'No CarePlans found.' + assert !@profiles_failed.include?(PROFILE), "CarePlans failed validation.
#{@profiles_failed[PROFILE]}" end test 'All references can be resolved' do diff --git a/lib/app/modules/argonaut/argonaut_careteam_sequence.rb b/lib/app/modules/argonaut/argonaut_careteam_sequence.rb index e3e6fe187..d5dadf8b6 100644 --- a/lib/app/modules/argonaut/argonaut_careteam_sequence.rb +++ b/lib/app/modules/argonaut/argonaut_careteam_sequence.rb @@ -3,6 +3,8 @@ module Inferno module Sequence class ArgonautCareTeamSequence < SequenceBase + PROFILE = Inferno::ValidationUtil::ARGONAUT_URIS[:care_team] + group 'Argonaut Profile Conformance' title 'Care Team' @@ -73,10 +75,10 @@ def validate_resource_item(resource, property, value) resource_count = reply.try(:resource).try(:entry).try(:length) || 0 @resources_found = resource_count.positive? - skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found + skip_unless @resources_found, 'No resources appear to be available for this patient. Please use patients with more information.' @careteam = reply.try(:resource).try(:entry).try(:first).try(:resource) validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('CarePlan'), reply) + save_resource_ids_in_bundle(versioned_resource_class('CarePlan'), reply, PROFILE) end test 'CareTeam resources associated with Patient conform to Argonaut profiles' do @@ -88,9 +90,9 @@ def validate_resource_item(resource, property, value) ) versions :dstu2 end - test_resources_against_profile('CarePlan', Inferno::ValidationUtil::ARGONAUT_URIS[:care_team]) - skip_unless @profiles_encountered.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:care_team]), 'No CareTeams found.' - assert !@profiles_failed.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:care_team]), "CareTeams failed validation.
#{@profiles_failed[Inferno::ValidationUtil::ARGONAUT_URIS[:care_team]]}" + test_resources_against_profile('CarePlan', PROFILE) + skip_unless @profiles_encountered.include?(PROFILE), 'No CareTeams found.' + assert !@profiles_failed.include?(PROFILE), "CareTeams failed validation.
#{@profiles_failed[PROFILE]}" end test 'All references can be resolved' do diff --git a/lib/app/modules/argonaut/argonaut_conformance_sequence.rb b/lib/app/modules/argonaut/argonaut_conformance_sequence.rb index a92b2becc..8e8b5a265 100644 --- a/lib/app/modules/argonaut/argonaut_conformance_sequence.rb +++ b/lib/app/modules/argonaut/argonaut_conformance_sequence.rb @@ -59,7 +59,7 @@ class ArgonautConformanceSequence < CapabilityStatementSequence test 'FHIR server conformance states JSON support' do metadata do - id '03' + id '04' link 'http://www.fhir.org/guides/argonaut/r2/Conformance-server.html' desc %( @@ -93,7 +93,7 @@ class ArgonautConformanceSequence < CapabilityStatementSequence test 'Conformance Statement describes SMART on FHIR core capabilities' do metadata do - id '04' + id '05' link 'http://www.hl7.org/fhir/smart-app-launch/conformance/' optional desc %( diff --git a/lib/app/modules/argonaut/argonaut_observation_sequence.rb b/lib/app/modules/argonaut/argonaut_observation_sequence.rb index 41ab67e7d..e78f9ac83 100644 --- a/lib/app/modules/argonaut/argonaut_observation_sequence.rb +++ b/lib/app/modules/argonaut/argonaut_observation_sequence.rb @@ -3,6 +3,9 @@ module Inferno module Sequence class ArgonautObservationSequence < SequenceBase + SMOKING_STATUS_PROFILE = Inferno::ValidationUtil::ARGONAUT_URIS[:smoking_status] + OBSERVATION_RESULTS_PROFILE = Inferno::ValidationUtil::ARGONAUT_URIS[:observation_results] + title 'Observation' description 'Verify that Observation resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -92,7 +95,7 @@ def validate_resource_item(resource, property, value) @observationresults = reply.try(:resource).try(:entry).try(:first).try(:resource) validate_search_reply(versioned_resource_class('Observation'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply, OBSERVATION_RESULTS_PROFILE) end test 'Server returns expected results from Observation Results search by patient + category + date' do @@ -198,7 +201,7 @@ def validate_resource_item(resource, property, value) search_params = { patient: @instance.patient_id, code: '72166-2' } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) validate_search_reply(versioned_resource_class('Observation'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply, SMOKING_STATUS_PROFILE) end test 'Observation read resource supported' do @@ -261,9 +264,9 @@ def validate_resource_item(resource, property, value) versions :dstu2 end - test_resources_against_profile('Observation', Inferno::ValidationUtil::ARGONAUT_URIS[:observation_results]) - skip_unless @profiles_encountered.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:observation_results]), 'No Observation Results found.' - assert !@profiles_failed.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:observation_results]), "Observation Results failed validation.
#{@profiles_failed[Inferno::ValidationUtil::ARGONAUT_URIS[:observation_results]]}" + test_resources_against_profile('Observation', OBSERVATION_RESULTS_PROFILE) + skip_unless @profiles_encountered.include?(OBSERVATION_RESULTS_PROFILE), 'No Observation Results found.' + assert !@profiles_failed.include?(OBSERVATION_RESULTS_PROFILE), "Observation Results failed validation.
#{@profiles_failed[OBSERVATION_RESULTS_PROFILE]}" end test 'All references can be resolved' do diff --git a/lib/app/modules/argonaut/argonaut_smoking_status_sequence.rb b/lib/app/modules/argonaut/argonaut_smoking_status_sequence.rb index 3d948fa33..908a22fd4 100644 --- a/lib/app/modules/argonaut/argonaut_smoking_status_sequence.rb +++ b/lib/app/modules/argonaut/argonaut_smoking_status_sequence.rb @@ -3,6 +3,8 @@ module Inferno module Sequence class ArgonautSmokingStatusSequence < SequenceBase + PROFILE = Inferno::ValidationUtil::ARGONAUT_URIS[:smoking_status] + group 'Argonaut Profile Conformance' title 'Smoking Status' @@ -84,7 +86,7 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found validate_search_reply(versioned_resource_class('Observation'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply, PROFILE) end test 'Smoking Status resources associated with Patient conform to Argonaut profiles' do @@ -96,9 +98,9 @@ def validate_resource_item(resource, property, value) ) versions :dstu2 end - test_resources_against_profile('Observation', Inferno::ValidationUtil::ARGONAUT_URIS[:smoking_status]) - skip_unless @profiles_encountered.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:smoking_status]), 'No Smoking Status Observations found.' - assert !@profiles_failed.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:smoking_status]), "Smoking Status Observations failed validation.
#{@profiles_failed[Inferno::ValidationUtil::ARGONAUT_URIS[:smoking_status]]}" + test_resources_against_profile('Observation', PROFILE) + skip_unless @profiles_encountered.include?(PROFILE), 'No Smoking Status Observations found.' + assert !@profiles_failed.include?(PROFILE), "Smoking Status Observations failed validation.
#{@profiles_failed[PROFILE]}" end end end diff --git a/lib/app/modules/argonaut/argonaut_vital_signs_observation_sequence.rb b/lib/app/modules/argonaut/argonaut_vital_signs_observation_sequence.rb index ff2d925d0..588d44db3 100644 --- a/lib/app/modules/argonaut/argonaut_vital_signs_observation_sequence.rb +++ b/lib/app/modules/argonaut/argonaut_vital_signs_observation_sequence.rb @@ -3,6 +3,8 @@ module Inferno module Sequence class ArgonautVitalSignsSequence < SequenceBase + PROFILE = Inferno::ValidationUtil::ARGONAUT_URIS[:vital_signs] + title 'Vital Signs' description 'Verify that Vital Signs are collected on the FHIR server according to the Argonaut Data Query Implementation Guide' @@ -92,7 +94,7 @@ def validate_resource_item(resource, property, value) @vitalsigns = reply.try(:resource).try(:entry).try(:first).try(:resource) validate_search_reply(versioned_resource_class('Observation'), reply, search_params) - save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply, PROFILE) end test 'Server returns expected results from Vital Signs search by patient + category + date' do @@ -170,9 +172,9 @@ def validate_resource_item(resource, property, value) versions :dstu2 end - test_resources_against_profile('Observation', Inferno::ValidationUtil::ARGONAUT_URIS[:vital_signs]) - skip_unless @profiles_encountered.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:vital_signs]), 'No Vital Sign Observations found.' - assert !@profiles_failed.include?(Inferno::ValidationUtil::ARGONAUT_URIS[:vital_signs]), "Vital Sign Observations failed validation.
#{@profiles_failed[Inferno::ValidationUtil::ARGONAUT_URIS[:vital_signs]]}" + test_resources_against_profile('Observation', PROFILE) + skip_unless @profiles_encountered.include?(PROFILE), 'No Vital Sign Observations found.' + assert !@profiles_failed.include?(PROFILE), "Vital Sign Observations failed validation.
#{@profiles_failed[PROFILE]}" end end end diff --git a/lib/app/modules/core/capability_statement_sequence.rb b/lib/app/modules/core/capability_statement_sequence.rb index 4651f835b..6d52cbb47 100644 --- a/lib/app/modules/core/capability_statement_sequence.rb +++ b/lib/app/modules/core/capability_statement_sequence.rb @@ -71,9 +71,25 @@ class CapabilityStatementSequence < SequenceBase end end - test 'FHIR server supports the conformance interaction that defines how it supports resources' do + test 'FHIR version of the server matches the FHIR version expected by tests' do metadata do id '02' + link 'http://www.hl7.org/fhir/directory.cfml' + desc %( + Checks that the FHIR version of the server matches the FHIR version expected by the tests. + This test will inspect the CapabilityStatement returned by the server to verify the FHIR version of the server. + ) + end + + pass 'Tests are not version dependent' if @instance.fhir_version.blank? + + # @client.detect_version is a symbol + assert_equal(@instance.fhir_version.upcase, @client.detect_version.to_s.upcase, 'FHIR client version does not match with instance version.') + end + + test 'FHIR server supports the conformance interaction that defines how it supports resources' do + metadata do + id '03' link 'http://hl7.org/fhir/DSTU2/http.html#conformance' desc %( The conformance 'whole system' interaction provides a method to get the conformance statement for diff --git a/lib/app/modules/onc_program_r4_module.yml b/lib/app/modules/onc_program_r4_module.yml index 3ba739124..15c960756 100644 --- a/lib/app/modules/onc_program_r4_module.yml +++ b/lib/app/modules/onc_program_r4_module.yml @@ -109,45 +109,45 @@ test_sets: - patient_id - token sequences: - - sequence: UsCoreR4PatientSequence + - sequence: USCoreR4PatientSequence title: Patient - - sequence: UsCoreR4AllergyintoleranceSequence + - sequence: USCoreR4AllergyintoleranceSequence title: Allergy Intolerance - - sequence: UsCoreR4CareplanSequence + - sequence: USCoreR4CareplanSequence title: Careplan - - sequence: UsCoreR4CareteamSequence + - sequence: USCoreR4CareteamSequence title: Careteam - - sequence: UsCoreR4ConditionSequence + - sequence: USCoreR4ConditionSequence title: Condition - - sequence: UsCoreR4DeviceSequence + - sequence: USCoreR4DeviceSequence title: Device - - sequence: UsCoreR4DiagnosticreportNoteSequence + - sequence: USCoreR4DiagnosticreportNoteSequence title: Diagnostic Report Note - - sequence: UsCoreR4DiagnosticreportLabSequence + - sequence: USCoreR4DiagnosticreportLabSequence title: Diagnostic Report Lab - - sequence: UsCoreR4DocumentreferenceSequence + - sequence: USCoreR4DocumentreferenceSequence title: Document Reference - - sequence: UsCoreR4EncounterSequence + - sequence: USCoreR4EncounterSequence title: Encounter - - sequence: UsCoreR4GoalSequence + - sequence: USCoreR4GoalSequence title: Goal - - sequence: UsCoreR4ImmunizationSequence + - sequence: USCoreR4ImmunizationSequence title: Immunization - - sequence: UsCoreR4MedicationSequence + - sequence: USCoreR4MedicationSequence title: Medication - - sequence: UsCoreR4MedicationrequestSequence + - sequence: USCoreR4MedicationrequestSequence title: Medication Request - - sequence: UsCoreR4MedicationstatementSequence + - sequence: USCoreR4MedicationstatementSequence title: Medication Statement - - sequence: UsCoreR4SmokingstatusSequence + - sequence: USCoreR4SmokingstatusSequence title: Smoking Status - sequence: PediatricWeightForHeightSequence title: Pediatric Weight for Height - - sequence: UsCoreR4ObservationLabSequence + - sequence: USCoreR4ObservationLabSequence title: Observation Lab - sequence: PediatricBmiForAgeSequence title: BMI For Age - - sequence: UsCoreR4ProcedureSequence + - sequence: USCoreR4ProcedureSequence title: Procedure - sequence: R4ProvenanceSequence title: Provenance diff --git a/lib/app/modules/smart/smart_discovery_sequence.rb b/lib/app/modules/smart/smart_discovery_sequence.rb index 1c56865e8..b4306f5d7 100644 --- a/lib/app/modules/smart/smart_discovery_sequence.rb +++ b/lib/app/modules/smart/smart_discovery_sequence.rb @@ -84,9 +84,9 @@ class SMARTDiscoverySequence < SequenceBase @conformance_authorize_url = oauth_metadata[:authorize_url] @conformance_token_url = oauth_metadata[:token_url] assert !@conformance_authorize_url.blank?, 'No authorize URI provided in Conformance/CapabilityStatement resource' - assert (@conformance_authorize_url =~ /\A#{URI.regexp(['http', 'https'])}\z/).zero?, "Invalid authorize url: '#{@conformance_authorize_url}'" + assert_valid_http_uri @conformance_authorize_url, "Invalid authorize url: '#{@conformance_authorize_url}'" assert !@conformance_token_url.blank?, 'No token URI provided in conformance statement.' - assert (@conformance_token_url =~ /\A#{URI.regexp(['http', 'https'])}\z/).zero?, "Invalid token url: '#{@conformance_token_url}'" + assert_valid_http_uri @conformance_token_url, "Invalid token url: '#{@conformance_token_url}'" warning do service = [] @@ -109,7 +109,7 @@ class SMARTDiscoverySequence < SequenceBase registration_url = security_info.extension.find { |x| x.url == 'register' } registration_url = registration_url.value if registration_url assert !registration_url.blank?, 'No dynamic registration endpoint in conformance.' - assert (registration_url =~ /\A#{URI.regexp(['http', 'https'])}\z/).zero?, "Invalid registration url: '#{registration_url}'" + assert_valid_http_uri registration_url, "Invalid registration url: '#{registration_url}'" manage_url = security_info.extension.find { |x| x.url == 'manage' } manage_url = manage_url.value if manage_url diff --git a/lib/app/modules/smart/standalone_launch_sequence.rb b/lib/app/modules/smart/standalone_launch_sequence.rb index bef06f67e..ad9fc17a4 100644 --- a/lib/app/modules/smart/standalone_launch_sequence.rb +++ b/lib/app/modules/smart/standalone_launch_sequence.rb @@ -35,6 +35,9 @@ class StandaloneLaunchSequence < SequenceBase !@instance.client_id.nil? end + OAUTH_REDIRECT_FAILED = 'Redirect to OAuth server failed' + NO_TOKEN = 'No valid token' + test 'OAuth 2.0 authorize endpoint secured by transport layer security' do metadata do id '01' @@ -72,7 +75,12 @@ class StandaloneLaunchSequence < SequenceBase 'aud' => @instance.url } - oauth2_auth_query = @instance.oauth_authorize_endpoint + oauth_authorize_endpoint = @instance.oauth_authorize_endpoint + + # Confirm that oauth2_auth_endpoint is valid before moving forward + assert_valid_http_uri oauth_authorize_endpoint, "OAuth2 Authorization Endpoint: \"#{oauth_authorize_endpoint}\" is not a valid URI" + + oauth2_auth_query = oauth_authorize_endpoint oauth2_auth_query += if @instance.oauth_authorize_endpoint.include? '?' '&' @@ -96,6 +104,9 @@ class StandaloneLaunchSequence < SequenceBase ) end + # Confirm that there is a @params object from the redirect + assert !@params.nil?, OAUTH_REDIRECT_FAILED + assert @params['error'].nil?, "Error returned from authorization server: code #{@params['error']}, description: #{@params['error_description']}" assert @params['state'] == @instance.state, "OAuth server state querystring parameter (#{@params['state']}) did not match state from app #{@instance.state}" assert !@params['code'].nil?, 'Expected code to be submitted in request' @@ -138,6 +149,9 @@ class StandaloneLaunchSequence < SequenceBase token_response = LoggedRestClient.post(@instance.oauth_token_endpoint, oauth2_params.to_json, headers) assert_response_bad_or_unauthorized token_response + # Confirm that there is a @params object from the redirect + assert !@params.nil?, OAUTH_REDIRECT_FAILED + oauth2_params = { 'grant_type' => 'authorization_code', 'code' => @params['code'], @@ -158,6 +172,9 @@ class StandaloneLaunchSequence < SequenceBase ) end + # Confirm that there is a @params object from the redirect + assert !@params.nil?, OAUTH_REDIRECT_FAILED + oauth2_params = { 'grant_type' => 'authorization_code', 'code' => @params['code'], @@ -185,6 +202,9 @@ class StandaloneLaunchSequence < SequenceBase ) end + # Confirm that there is valid token + assert !@token_response.nil?, NO_TOKEN + @token_response_headers = @token_response.headers assert_valid_json(@token_response.body) @token_response_body = JSON.parse(@token_response.body) @@ -242,6 +262,9 @@ class StandaloneLaunchSequence < SequenceBase ) end + # Confirm that there is valid token + assert !@token_response.nil?, NO_TOKEN + [:cache_control, :pragma].each do |key| assert @token_response_headers.key?(key), "Token response headers did not contain #{key} as is required in the SMART App Launch Guide." end diff --git a/lib/app/modules/smart/token_introspection_sequence.rb b/lib/app/modules/smart/token_introspection_sequence.rb index 96cea0da1..a79b4e4aa 100644 --- a/lib/app/modules/smart/token_introspection_sequence.rb +++ b/lib/app/modules/smart/token_introspection_sequence.rb @@ -70,7 +70,7 @@ class TokenIntrospectionSequence < SequenceBase @introspection_response_body = JSON.parse(@introspection_response.body) assert !@introspection_response_body.nil?, 'No introspection response body' - FHIR.logger.debug "Introspection response: #{@introspection_response}" + Inferno.logger.debug "Introspection response: #{@introspection_response}" assert !(@introspection_response['error'] || @introspection_response['error_description']), 'Got an error from the introspection endpoint' end @@ -106,7 +106,7 @@ class TokenIntrospectionSequence < SequenceBase expected_scopes = @instance.scopes.split(' ') actual_scopes = @introspection_response_body['scope'].split(' ') - FHIR.logger.debug "Introspection: Expected scopes #{expected_scopes}, Actual scopes #{actual_scopes}" + Inferno.logger.debug "Introspection: Expected scopes #{expected_scopes}, Actual scopes #{actual_scopes}" missing_scopes = (expected_scopes - actual_scopes) assert missing_scopes.empty?, "Introspection response did not include expected scopes: #{missing_scopes}" @@ -167,7 +167,7 @@ class TokenIntrospectionSequence < SequenceBase @introspection_response_body = JSON.parse(@introspection_response.body) assert !@introspection_response_body.nil?, 'No refresh token introspection response body' - FHIR.logger.debug "Refresh Token Introspection response: #{@introspection_response}" + Inferno.logger.debug "Refresh Token Introspection response: #{@introspection_response}" assert !(@introspection_response['error'] || @introspection_response['error_description']), 'Got an error from the introspection endpoint' end diff --git a/lib/app/modules/smart/token_refresh_sequence.rb b/lib/app/modules/smart/token_refresh_sequence.rb index 3b2e3605b..81b262f04 100644 --- a/lib/app/modules/smart/token_refresh_sequence.rb +++ b/lib/app/modules/smart/token_refresh_sequence.rb @@ -17,9 +17,12 @@ class TokenRefreshSequence < SequenceBase Refresh tokens are typically longer lived than access tokens and allow client applications to obtain a new access token Refresh tokens themselves cannot provide access to resources on the server. + Token refreshes are accomplished through a `POST` request to the token exchange endpoint as described in the + [SMART App Launch Framework](http://www.hl7.org/fhir/smart-app-launch/#step-5-later-app-uses-a-refresh-token-to-obtain-a-new-access-token) + # Test Methodology - Inferno will attempt to exchange the refresh token for a new access token and verify that the information returned + This test attempts to exchange the refresh token for a new access token and verify that the information returned contains the required fields and uses the proper headers. For more information see: @@ -28,133 +31,164 @@ class TokenRefreshSequence < SequenceBase * [Using a refresh token to obtain a new access token](http://hl7.org/fhir/smart-app-launch/#step-5-later-app-uses-a-refresh-token-to-obtain-a-new-access-token) ) - test 'Refresh token exchange fails when supplied invalid Refresh Token or Client ID.' do - metadata do - id '01' - link 'https://tools.ietf.org/html/rfc6749' - desc %( - If the request failed verification or is invalid, the authorization server returns an error response. ) - end + INVALID_CLIENT_ID = 'INVALID_CLIENT_ID' + INVALID_REFRESH_TOKEN = 'INVALID_REFRESH_TOKEN' + + def encoded_secret(client_id, client_secret) + "Basic #{Base64.strict_encode64(client_id + ':' + client_secret)}" + end + def perform_refresh_request(client_id, refresh_token, provide_scope = false) oauth2_params = { 'grant_type' => 'refresh_token', - 'refresh_token' => 'INVALID REFRESH TOKEN', - 'client_id' => @instance.client_id + 'refresh_token' => refresh_token } + oauth2_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' } - token_response = LoggedRestClient.post(@instance.oauth_token_endpoint, oauth2_params) - assert_response_bad_or_unauthorized token_response + if @instance.confidential_client + oauth2_headers['Authorization'] = encoded_secret(client_id, @instance.client_secret) + else + oauth2_params['client_id'] = client_id + end - oauth2_params = { - 'grant_type' => 'refresh_token', - 'refresh_token' => @instance.refresh_token, - 'client_id' => 'INVALID_CLIENT_ID' - } + oauth2_params['scope'] = @instance.scopes if provide_scope - token_response = LoggedRestClient.post(@instance.oauth_token_endpoint, oauth2_params) - assert_response_bad_or_unauthorized token_response + LoggedRestClient.post(@instance.oauth_token_endpoint, oauth2_params, oauth2_headers) end - test 'Server successfully exchanges refresh token at OAuth token endpoint.' do + test 'Refresh token exchange fails when provided invalid Refresh Token.' do metadata do - id '02' + id '01' link 'https://tools.ietf.org/html/rfc6749' desc %( - Server successfully exchanges refresh token at OAuth token endpoint. - ) - end - - oauth2_params = { - 'grant_type' => 'refresh_token', - 'refresh_token' => @instance.refresh_token - } - oauth2_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' } - - if @instance.confidential_client - oauth2_headers['Authorization'] = "Basic #{Base64.strict_encode64(@instance.client_id + - ':' + - @instance.client_secret)}" - else - oauth2_params['client_id'] = @instance.client_id + If the request failed verification or is invalid, the authorization server returns an error response. ) end - @token_response = LoggedRestClient.post(@instance.oauth_token_endpoint, oauth2_params, oauth2_headers) - assert_response_ok(@token_response) + token_response = perform_refresh_request(@instance.client_id, INVALID_REFRESH_TOKEN) + assert_response_bad_or_unauthorized token_response end - test 'Data returned from refresh token exchange contains required information encoded in JSON.' do + test 'Refresh token exchange fails when provided invalid Client ID.' do metadata do - id '03' - link 'http://www.hl7.org/fhir/smart-app-launch/' + id '02' + link 'https://tools.ietf.org/html/rfc6749' desc %( - The EHR authorization server SHALL return a JSON structure that includes an access token or a message indicating that the authorization request has been denied. - access_token, token_type, and scope are required. access_token must be Bearer. - ) + If the request failed verification or is invalid, the authorization server returns an error response. ) end - @token_response_headers = @token_response.headers - assert_valid_json(@token_response.body) - @token_response_body = JSON.parse(@token_response.body) + token_response = perform_refresh_request(INVALID_CLIENT_ID, @instance.refresh_token) + assert_response_bad_or_unauthorized token_response + end - assert @token_response_body.key?('access_token'), 'Token response did not contain access_token as required' + def validate_and_save_refresh_response(token_response) + assert_response_ok(token_response) + assert_valid_json(token_response.body) + token_response_body = JSON.parse(token_response.body) + + # The minimum we need to 'progress' is the access token, + # so first just check and save access token, before validating rest of payload. + # This is done to make things easier for developers. + + assert token_response_body.key?('access_token'), 'Token response did not contain access_token as required' token_retrieved_at = DateTime.now @instance.resource_references.each(&:destroy) - @instance.resource_references << Inferno::Models::ResourceReference.new(resource_type: 'Patient', resource_id: @token_response_body['patient']) if @token_response_body.key?('patient') + @instance.resource_references << Inferno::Models::ResourceReference.new(resource_type: 'Patient', resource_id: token_response_body['patient']) if token_response_body.key?('patient') @instance.save! - @instance.update(token: @token_response_body['access_token'], token_retrieved_at: token_retrieved_at) + @instance.update(token: token_response_body['access_token'], token_retrieved_at: token_retrieved_at) - ['token_type', 'scope'].each do |key| - assert @token_response_body.key?(key), "Token response did not contain #{key} as required" + ['expires_in', 'token_type', 'scope'].each do |key| + assert token_response_body.key?(key), "Token response did not contain #{key} as required" end # case insentitive per https://tools.ietf.org/html/rfc6749#section-5.1 - assert @token_response_body['token_type'].casecmp('bearer').zero?, 'Token type must be Bearer.' + assert token_response_body['token_type'].casecmp('bearer').zero?, 'Token type must be Bearer.' expected_scopes = @instance.scopes.split(' ') - actual_scopes = @token_response_body['scope'].split(' ') + actual_scopes = token_response_body['scope'].split(' ') warning do missing_scopes = (expected_scopes - actual_scopes) assert missing_scopes.empty?, "Token exchange response did not include expected scopes: #{missing_scopes}" - assert @token_response_body.key?('patient'), 'No patient id provided in token exchange.' + assert token_response_body.key?('patient'), 'No patient id provided in token exchange.' end - scopes = @token_response_body['scope'] || @instance.scopes + scopes = token_response_body['scope'] || @instance.scopes @instance.save! @instance.update(scopes: scopes) - if @token_response_body.key?('id_token') + if token_response_body.key?('id_token') @instance.save! - @instance.update(id_token: @token_response_body['id_token']) + @instance.update(id_token: token_response_body['id_token']) end - if @token_response_body.key?('refresh_token') + if token_response_body.key?('refresh_token') @instance.save! - @instance.update(refresh_token: @token_response_body['refresh_token']) + @instance.update(refresh_token: token_response_body['refresh_token']) + end + + warning do + # These should be required but due to a gap in the SMART App Launch Guide they are not currently required + # See https://github.com/HL7/smart-app-launch/issues/293 + [:cache_control, :pragma].each do |key| + assert token_response.headers.key?(key), "Token response headers did not contain #{key} as is recommended for token exchanges." + end + + assert token_response.headers[:cache_control].downcase.include?('no-store'), 'Token response header should have cache_control containing no-store.' + assert token_response.headers[:pragma].downcase.include?('no-cache'), 'Token response header should have pragma containing no-cache.' end end - test 'Response includes correct HTTP Cache-Control and Pragma headers' do + test 'Server successfully refreshes the access token when optional scope parameter omitted.' do metadata do - id '04' - link 'http://www.hl7.org/fhir/smart-app-launch/' + id '03' + link 'https://tools.ietf.org/html/rfc6749' desc %( - The authorization servers response must include the HTTP Cache-Control response header field with a value of no-store, as well as the Pragma response header field with a value of no-cache. + Server successfully exchanges refresh token at OAuth token endpoint without providing scope in + the body of the request. + + The EHR authorization server SHALL return a JSON structure that includes an access token or a message indicating that the authorization request has been denied. + access_token, expires_in, token_type, and scope are required. access_token must be Bearer. + + Although not required in the token refresh portion of the SMART App Launch Guide, + the token refresh response should include the HTTP Cache-Control response header field with a value of no-store, as well as the Pragma response header field with a value of no-cache + to be consistent with the requirements of the inital access token exchange. + ) end - [:cache_control, :pragma].each do |key| - assert @token_response_headers.key?(key), "Token response headers did not contain #{key} as is required in the SMART App Launch Guide." + specify_scopes = false + + token_response = perform_refresh_request(@instance.client_id, @instance.refresh_token, specify_scopes) + validate_and_save_refresh_response(token_response) + end + + test 'Server successfully refreshes the access token when optional scope parameter provided.' do + metadata do + id '04' + link 'https://tools.ietf.org/html/rfc6749' + desc %( + Server successfully exchanges refresh token at OAuth token endpoint while providing scope in + the body of the request. + + The EHR authorization server SHALL return a JSON structure that includes an access token or a message indicating that the authorization request has been denied. + access_token, token_type, and scope are required. access_token must be Bearer. + + Although not required in the token refresh portion of the SMART App Launch Guide, + the token refresh response should include the HTTP Cache-Control response header field with a value of no-store, as well as the Pragma response header field with a value of no-cache + to be consistent with the requirements of the inital access token exchange. + ) end - assert @token_response_headers[:cache_control].downcase.include?('no-store'), 'Token response header must have cache_control containing no-store.' - assert @token_response_headers[:pragma].downcase.include?('no-cache'), 'Token response header must have pragma containing no-cache.' + specify_scopes = true + + token_response = perform_refresh_request(@instance.client_id, @instance.refresh_token, specify_scopes) + validate_and_save_refresh_response(token_response) end end end diff --git a/lib/app/modules/us_core_module.yml b/lib/app/modules/us_core_module.yml index 352799717..b46bd9467 100644 --- a/lib/app/modules/us_core_module.yml +++ b/lib/app/modules/us_core_module.yml @@ -21,27 +21,28 @@ test_sets: - name: US Core R4 run_all: true sequences: - - UsCoreR4AllergyintoleranceSequence - - UsCoreR4CareplanSequence - - UsCoreR4CareteamSequence - - UsCoreR4ConditionSequence - - UsCoreR4DeviceSequence - - UsCoreR4DiagnosticreportNoteSequence - - UsCoreR4DiagnosticreportLabSequence - - UsCoreR4DocumentreferenceSequence - - UsCoreR4EncounterSequence - - UsCoreR4GoalSequence - - UsCoreR4ImmunizationSequence - - UsCoreR4LocationSequence - - UsCoreR4MedicationSequence - - UsCoreR4MedicationrequestSequence - - UsCoreR4MedicationstatementSequence - - UsCoreR4SmokingstatusSequence - - PediatricWeightForHeightSequence - - UsCoreR4ObservationLabSequence - - PediatricBmiForAgeSequence - - UsCoreR4OrganizationSequence - - UsCoreR4PatientSequence - - UsCoreR4PractitionerSequence - - UsCoreR4PractitionerroleSequence - - UsCoreR4ProcedureSequence \ No newline at end of file + - USCoreR4AllergyintoleranceSequence + - USCoreR4CareplanSequence + - USCoreR4CareteamSequence + - USCoreR4ConditionSequence + - USCoreR4DeviceSequence + - USCoreR4DiagnosticreportNoteSequence + - USCoreR4DiagnosticreportLabSequence + - USCoreR4DocumentreferenceSequence + - USCoreR4EncounterSequence + - USCoreR4GoalSequence + - USCoreR4ImmunizationSequence + - USCoreR4LocationSequence + - USCoreR4MedicationSequence + - USCoreR4MedicationrequestSequence + - USCoreR4MedicationstatementSequence + - USCoreR4SmokingstatusSequence + - PediatricWeightForHeightSequence + - USCoreR4ObservationLabSequence + - PediatricBmiForAgeSequence + - USCoreR4OrganizationSequence + - USCoreR4PatientSequence + - USCoreR4PractitionerSequence + - USCoreR4PractitionerroleSequence + - USCoreR4ProcedureSequence + - R4ProvenanceSequence diff --git a/lib/app/modules/us_core_r4/clinicalnotes_sequence.rb b/lib/app/modules/us_core_r4/clinicalnotes_sequence.rb new file mode 100644 index 000000000..ed7b20e23 --- /dev/null +++ b/lib/app/modules/us_core_r4/clinicalnotes_sequence.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +module Inferno + module Sequence + class UsCoreR4ClinicalNotesSequence < SequenceBase + group 'US Core R4 Profile Conformance' + + title 'Clinical Notes Guideline Tests' + + description 'Verify that DocumentReference and DiagnosticReport resources on the FHIR server follow the US Core R4 Clinical Notes Guideline' + + test_id_prefix 'ClinicalNotes' + + requires :token, :patient_id + conformance_supports :DocumentReference, :DiagnosticReport + + details %( + + The #{title} Sequence tests DiagnosticReport and DocumentReference resources associated with the provided patient. The resources + returned will be checked for consistency against the [US Core Clinical Notes Guidance](https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html) + + ) + + @clinical_notes_found = false + + def test_clinical_notes_document_reference(type_code) + skip 'No Clinical Notes appear to be available for this patient. Please use patients with more information.' unless @clinical_notes_found + + assert @actual_type_codes.include?(type_code), "Clinical Notes shall have at least one DocumentReference with type #{type_code}" + end + + def test_clinical_notes_diagnostic_report(category_code) + skip 'No Clinical Notes appear to be available for this patient. Please use patients with more information.' unless @clinical_notes_found + + search_params = { 'patient': @instance.patient_id, 'category': category_code } + resource_class = 'DiagnosticReport' + @report_attachments = ClinicalNoteAttachment.new(resource_class) + + reply = get_resource_by_params(versioned_resource_class(resource_class), search_params) + assert_response_ok(reply) + assert_bundle_response(reply) + + resource_count = reply&.resource&.entry&.length || 0 + @resources_found = true if resource_count.positive? + + assert @resources_found, "Clinical Notes shall have at least one DiagnosticReport with category #{category_code}" + + diagnostic_reports = reply&.resource&.entry&.map { |entry| entry&.resource } + + diagnostic_reports&.each do |report| + report&.presentedForm&.select { |attachment| !@report_attachments.attachment.key?(attachment&.url) }&.each do |attachment| + @report_attachments.attachment[attachment.url] = report.id + end + end + end + + test 'Server returns expected results from DocumentReference search by patient+clinicalnotes' do + metadata do + id '01' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + @client.set_no_auth if @instance.token.blank? + + search_params = { 'patient': @instance.patient_id, 'category': 'clinical-note' } + resource_class = 'DocumentReference' + + @document_attachments = ClinicalNoteAttachment.new(resource_class) + + reply = get_resource_by_params(versioned_resource_class(resource_class), search_params) + assert_response_ok(reply) + assert_bundle_response(reply) + + resource_count = reply&.resource&.entry&.length || 0 + @clinical_notes_found = true if resource_count.positive? + + skip 'No Clinical Notes appear to be available for this patient. Please use patients with more information.' unless @clinical_notes_found + + document_references = reply&.resource&.entry&.map { |entry| entry&.resource } + + @required_type_codes = Set[ + '11488-4', # Consultation Note + '18842-5', # Dischard Summary + '34117-2', # History and physical note + '28570-0', # Procedure note + '11506-3' # Progress note + ] + + @actual_type_codes = Set[] + + document_references&.select { |document| document&.type&.coding&.present? }&.each do |document| + document.type.coding.select { |coding| coding&.system == 'http://loinc.org' && @required_type_codes.include?(coding&.code) }&.each do |coding| + @actual_type_codes << coding.code + + document&.content&.select { |content| !@document_attachments.attachment.key?(content&.attachment&.url) }&.each do |content| + @document_attachments.attachment[content.attachment.url] = document.id + end + end + end + end + + test 'Server shall have Consultation Notes' do + metadata do + id '02' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_document_reference('11488-4') + end + + test 'Server shall have Discharge Summary' do + metadata do + id '03' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_document_reference('18842-5') + end + + test 'Server shall have History and Physical Note' do + metadata do + id '04' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_document_reference('34117-2') + end + + test 'Server shall have Procedures Note' do + metadata do + id '05' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_document_reference('28570-0') + end + + test 'Server shall have Progress Note' do + metadata do + id '06' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_document_reference('11506-3') + end + + test 'Server returns Cardiology report from DiagnosticReport search by patient+category' do + metadata do + id '07' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_diagnostic_report('http://loinc.org|LP29708-2') + end + + test 'Server returns Pathology report from DiagnosticReport search by patient+category' do + metadata do + id '08' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_diagnostic_report('http://loinc.org|LP7839-6') + end + + test 'Server returns Radiology report from DiagnosticReport search by patient+category' do + metadata do + id '09' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + test_clinical_notes_diagnostic_report('http://loinc.org|LP29684-5') + end + + test 'DiagnosticReport and DocumentReference reference the same attachment' do + metadata do + id '10' + link 'https://www.hl7.org/fhir/us/core/clinical-notes-guidance.html' + desc %( + ) + versions :r4 + end + + skip 'No Clinical Notes appear to be available for this patient. Please use patients with more information.' unless @clinical_notes_found + + assert_attachment_matched(@document_attachments, @report_attachments) + assert_attachment_matched(@report_attachments, @document_attachments) + end + + def assert_attachment_matched(source_attachments, target_attachments) + not_matched_urls = source_attachments.attachment.keys - target_attachments.attachment.keys + not_matched_attachments = not_matched_urls.map { |url| "#{url} in #{source_attachments.resource_class}/#{source_attachments.attachment[url]}" } + + assert not_matched_attachments.empty?, "Attachments #{not_matched_attachments.join(', ')} are not referenced in any #{target_attachments.resource_class}." + end + end + + class ClinicalNoteAttachment + attr_reader :resource_class + attr_reader :attachment + + def initialize(resource_class) + @resource_class = resource_class + @attachment = {} + end + end + end +end diff --git a/lib/app/modules/us_core_r4/pediatric_bmi_for_age_sequence.rb b/lib/app/modules/us_core_r4/pediatric_bmi_for_age_sequence.rb index 4ad188bf4..072736d10 100644 --- a/lib/app/modules/us_core_r4/pediatric_bmi_for_age_sequence.rb +++ b/lib/app/modules/us_core_r4/pediatric_bmi_for_age_sequence.rb @@ -5,7 +5,7 @@ module Sequence class PediatricBmiForAgeSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'PediatricBmiForAge Tests' + title 'Pediatric BMI for Age Observation Tests' description 'Verify that Observation resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,22 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' when 'date' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' end end @@ -59,7 +63,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Observation'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, code: '59576-9' } + + reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,14 +85,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @observation = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Observation'), reply, search_params) + @observation_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) end test 'Server returns expected results from Observation search by patient+category' do @@ -102,10 +109,12 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code + category_val = resolve_element_from_path(@observation, 'category.coding.code') search_params = { 'patient': patient_val, 'category': category_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -122,12 +131,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - date_val = @observation&.effectiveDateTime + category_val = resolve_element_from_path(@observation, 'category.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+code+date' do @@ -143,12 +162,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - code_val = @observation&.code&.coding&.first&.code - date_val = @observation&.effectiveDateTime + code_val = resolve_element_from_path(@observation, 'code.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+category+status' do @@ -164,11 +193,13 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - status_val = @observation&.status + category_val = resolve_element_from_path(@observation, 'category.coding.code') + status_val = resolve_element_from_path(@observation, 'status') search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -217,7 +248,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@observation, versioned_resource_class('Observation')) end - test 'Observation resources associated with Patient conform to Argonaut profiles' do + test 'Observation resources associated with Patient conform to US Core R4 profiles' do metadata do id '10' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-pediatric-bmi-for-age.json' @@ -230,9 +261,63 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Observation') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Observation for this patient.' do metadata do id '11' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @observation_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Observation.status', + 'Observation.category', + 'Observation.category', + 'Observation.category.coding', + 'Observation.category.coding.system', + 'Observation.category.coding.code', + 'Observation.subject', + 'Observation.effectiveDateTime', + 'Observation.effectivePeriod', + 'Observation.valueQuantity.value', + 'Observation.valueQuantity.unit', + 'Observation.valueQuantity.system', + 'Observation.valueQuantity.code', + 'Observation.dataAbsentReason', + 'Observation.component', + 'Observation.component.code', + 'Observation.component.valueQuantity', + 'Observation.component.valueCodeableConcept', + 'Observation.component.valueString', + 'Observation.component.valueBoolean', + 'Observation.component.valueInteger', + 'Observation.component.valueRange', + 'Observation.component.valueRatio', + 'Observation.component.valueSampledData', + 'Observation.component.valueTime', + 'Observation.component.valueDateTime', + 'Observation.component.valuePeriod', + 'Observation.component.dataAbsentReason' + ] + must_support_elements.each do |path| + @observation_ary&.each do |resource| + truncated_path = path.gsub('Observation.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @observation_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Observation resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '12' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/pediatric_weight_for_height_sequence.rb b/lib/app/modules/us_core_r4/pediatric_weight_for_height_sequence.rb index acedfcb84..472da6498 100644 --- a/lib/app/modules/us_core_r4/pediatric_weight_for_height_sequence.rb +++ b/lib/app/modules/us_core_r4/pediatric_weight_for_height_sequence.rb @@ -5,7 +5,7 @@ module Sequence class PediatricWeightForHeightSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'PediatricWeightForHeight Tests' + title 'Pediatric Weight for Height Observation Tests' description 'Verify that Observation resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,22 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' when 'date' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' end end @@ -59,7 +63,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Observation'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, code: '77606-2' } + + reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,14 +85,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @observation = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Observation'), reply, search_params) + @observation_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) end test 'Server returns expected results from Observation search by patient+category' do @@ -102,10 +109,12 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code + category_val = resolve_element_from_path(@observation, 'category.coding.code') search_params = { 'patient': patient_val, 'category': category_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -122,12 +131,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - date_val = @observation&.effectiveDateTime + category_val = resolve_element_from_path(@observation, 'category.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+code+date' do @@ -143,12 +162,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - code_val = @observation&.code&.coding&.first&.code - date_val = @observation&.effectiveDateTime + code_val = resolve_element_from_path(@observation, 'code.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+category+status' do @@ -164,11 +193,13 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - status_val = @observation&.status + category_val = resolve_element_from_path(@observation, 'category.coding.code') + status_val = resolve_element_from_path(@observation, 'status') search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -217,7 +248,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@observation, versioned_resource_class('Observation')) end - test 'Observation resources associated with Patient conform to Argonaut profiles' do + test 'Observation resources associated with Patient conform to US Core R4 profiles' do metadata do id '10' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-pediatric-weight-for-height.json' @@ -230,9 +261,63 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Observation') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Observation for this patient.' do metadata do id '11' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @observation_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Observation.status', + 'Observation.category', + 'Observation.category', + 'Observation.category.coding', + 'Observation.category.coding.system', + 'Observation.category.coding.code', + 'Observation.subject', + 'Observation.effectiveDateTime', + 'Observation.effectivePeriod', + 'Observation.valueQuantity.value', + 'Observation.valueQuantity.unit', + 'Observation.valueQuantity.system', + 'Observation.valueQuantity.code', + 'Observation.dataAbsentReason', + 'Observation.component', + 'Observation.component.code', + 'Observation.component.valueQuantity', + 'Observation.component.valueCodeableConcept', + 'Observation.component.valueString', + 'Observation.component.valueBoolean', + 'Observation.component.valueInteger', + 'Observation.component.valueRange', + 'Observation.component.valueRatio', + 'Observation.component.valueSampledData', + 'Observation.component.valueTime', + 'Observation.component.valueDateTime', + 'Observation.component.valuePeriod', + 'Observation.component.dataAbsentReason' + ] + must_support_elements.each do |path| + @observation_ary&.each do |resource| + truncated_path = path.gsub('Observation.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @observation_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Observation resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '12' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_allergyintolerance_sequence.rb b/lib/app/modules/us_core_r4/us_core_allergyintolerance_sequence.rb index 8fec0fd7f..1ce0d3e01 100644 --- a/lib/app/modules/us_core_r4/us_core_allergyintolerance_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_allergyintolerance_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4AllergyintoleranceSequence < SequenceBase + class USCoreR4AllergyintoleranceSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Allergyintolerance Tests' + title 'AllergyIntolerance Tests' description 'Verify that AllergyIntolerance resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,12 +18,12 @@ def validate_resource_item(resource, property, value) case property when 'clinical-status' - codings = resource&.clinicalStatus&.coding - assert !codings.nil?, 'clinical-status on resource did not match clinical-status requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'clinical-status on resource did not match clinical-status requested' + value_found = can_resolve_path(resource, 'clinicalStatus.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'clinical-status on resource does not match clinical-status requested' when 'patient' - assert resource&.patient&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'patient.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' end end @@ -49,7 +49,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('AllergyIntolerance'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('AllergyIntolerance'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -65,19 +68,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('AllergyIntolerance'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @allergyintolerance = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('AllergyIntolerance'), reply, search_params) + @allergyintolerance_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('AllergyIntolerance'), reply) + validate_search_reply(versioned_resource_class('AllergyIntolerance'), reply, search_params) end test 'Server returns expected results from AllergyIntolerance search by patient+clinical-status' do @@ -93,10 +98,12 @@ def validate_resource_item(resource, property, value) assert !@allergyintolerance.nil?, 'Expected valid AllergyIntolerance resource to be present' patient_val = @instance.patient_id - clinical_status_val = @allergyintolerance&.clinicalStatus&.coding&.first&.code + clinical_status_val = resolve_element_from_path(@allergyintolerance, 'clinicalStatus.coding.code') search_params = { 'patient': patient_val, 'clinical-status': clinical_status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('AllergyIntolerance'), search_params) + validate_search_reply(versioned_resource_class('AllergyIntolerance'), reply, search_params) assert_response_ok(reply) end @@ -145,7 +152,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@allergyintolerance, versioned_resource_class('AllergyIntolerance')) end - test 'AllergyIntolerance resources associated with Patient conform to Argonaut profiles' do + test 'AllergyIntolerance resources associated with Patient conform to US Core R4 profiles' do metadata do id '07' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-allergyintolerance.json' @@ -158,9 +165,39 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('AllergyIntolerance') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any AllergyIntolerance for this patient.' do metadata do id '08' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @allergyintolerance_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'AllergyIntolerance.clinicalStatus', + 'AllergyIntolerance.verificationStatus', + 'AllergyIntolerance.code', + 'AllergyIntolerance.patient' + ] + must_support_elements.each do |path| + @allergyintolerance_ary&.each do |resource| + truncated_path = path.gsub('AllergyIntolerance.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @allergyintolerance_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided AllergyIntolerance resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '09' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_careplan_sequence.rb b/lib/app/modules/us_core_r4/us_core_careplan_sequence.rb index 28913b11d..14ac40341 100644 --- a/lib/app/modules/us_core_r4/us_core_careplan_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_careplan_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4CareplanSequence < SequenceBase + class USCoreR4CareplanSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Careplan Tests' + title 'CarePlan Tests' description 'Verify that CarePlan resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,17 +18,22 @@ def validate_resource_item(resource, property, value) case property when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'date' + value_found = can_resolve_path(resource, 'period') do |period| + validate_period_search(value, period) + end + assert value_found, 'date on resource does not match date requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' end end @@ -54,7 +59,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('CarePlan'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, category: 'assess-plan' } + + reply = get_resource_by_params(versioned_resource_class('CarePlan'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -74,14 +81,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @careplan = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) + @careplan_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('CarePlan'), reply) + validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) end test 'Server returns expected results from CarePlan search by patient+category+status' do @@ -97,11 +105,13 @@ def validate_resource_item(resource, property, value) assert !@careplan.nil?, 'Expected valid CarePlan resource to be present' patient_val = @instance.patient_id - category_val = @careplan&.category&.first&.coding&.first&.code - status_val = @careplan&.status + category_val = resolve_element_from_path(@careplan, 'category.coding.code') + status_val = resolve_element_from_path(@careplan, 'status') search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('CarePlan'), search_params) + validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) assert_response_ok(reply) end @@ -118,13 +128,23 @@ def validate_resource_item(resource, property, value) assert !@careplan.nil?, 'Expected valid CarePlan resource to be present' patient_val = @instance.patient_id - category_val = @careplan&.category&.first&.coding&.first&.code - status_val = @careplan&.status - date_val = @careplan&.period&.start + category_val = resolve_element_from_path(@careplan, 'category.coding.code') + status_val = resolve_element_from_path(@careplan, 'status') + date_val = resolve_element_from_path(@careplan, 'period.start') search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('CarePlan'), search_params) + validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('CarePlan'), comparator_search_params) + validate_search_reply(versioned_resource_class('CarePlan'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from CarePlan search by patient+category+date' do @@ -140,12 +160,22 @@ def validate_resource_item(resource, property, value) assert !@careplan.nil?, 'Expected valid CarePlan resource to be present' patient_val = @instance.patient_id - category_val = @careplan&.category&.first&.coding&.first&.code - date_val = @careplan&.period&.start + category_val = resolve_element_from_path(@careplan, 'category.coding.code') + date_val = resolve_element_from_path(@careplan, 'period.start') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('CarePlan'), search_params) + validate_search_reply(versioned_resource_class('CarePlan'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('CarePlan'), comparator_search_params) + validate_search_reply(versioned_resource_class('CarePlan'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'CarePlan read resource supported' do @@ -193,7 +223,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@careplan, versioned_resource_class('CarePlan')) end - test 'CarePlan resources associated with Patient conform to Argonaut profiles' do + test 'CarePlan resources associated with Patient conform to US Core R4 profiles' do metadata do id '09' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-careplan.json' @@ -206,9 +236,42 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('CarePlan') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any CarePlan for this patient.' do metadata do id '10' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @careplan_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'CarePlan.text', + 'CarePlan.text.status', + 'CarePlan.status', + 'CarePlan.intent', + 'CarePlan.category', + 'CarePlan.category', + 'CarePlan.subject' + ] + must_support_elements.each do |path| + @careplan_ary&.each do |resource| + truncated_path = path.gsub('CarePlan.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @careplan_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided CarePlan resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '11' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_careteam_sequence.rb b/lib/app/modules/us_core_r4/us_core_careteam_sequence.rb index 61169f318..b88f600cc 100644 --- a/lib/app/modules/us_core_r4/us_core_careteam_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_careteam_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4CareteamSequence < SequenceBase + class USCoreR4CareteamSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Careteam Tests' + title 'CareTeam Tests' description 'Verify that CareTeam resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,10 +18,12 @@ def validate_resource_item(resource, property, value) case property when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' end end @@ -47,7 +49,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('CareTeam'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, status: 'active' } + + reply = get_resource_by_params(versioned_resource_class('CareTeam'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -67,14 +71,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @careteam = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('CareTeam'), reply, search_params) + @careteam_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('CareTeam'), reply) + validate_search_reply(versioned_resource_class('CareTeam'), reply, search_params) end test 'CareTeam read resource supported' do @@ -122,7 +127,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@careteam, versioned_resource_class('CareTeam')) end - test 'CareTeam resources associated with Patient conform to Argonaut profiles' do + test 'CareTeam resources associated with Patient conform to US Core R4 profiles' do metadata do id '06' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-careteam.json' @@ -135,9 +140,40 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('CareTeam') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any CareTeam for this patient.' do metadata do id '07' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @careteam_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'CareTeam.status', + 'CareTeam.subject', + 'CareTeam.participant', + 'CareTeam.participant.role', + 'CareTeam.participant.member' + ] + must_support_elements.each do |path| + @careteam_ary&.each do |resource| + truncated_path = path.gsub('CareTeam.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @careteam_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided CareTeam resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '08' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_condition_sequence.rb b/lib/app/modules/us_core_r4/us_core_condition_sequence.rb index a14ebddac..27615ebfe 100644 --- a/lib/app/modules/us_core_r4/us_core_condition_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_condition_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4ConditionSequence < SequenceBase + class USCoreR4ConditionSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Condition Tests' @@ -18,24 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'clinical-status' - codings = resource&.clinicalStatus&.coding - assert !codings.nil?, 'clinical-status on resource did not match clinical-status requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'clinical-status on resource did not match clinical-status requested' + value_found = can_resolve_path(resource, 'clinicalStatus.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'clinical-status on resource does not match clinical-status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'onset-date' + value_found = can_resolve_path(resource, 'onsetDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'onset-date on resource does not match onset-date requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' end end @@ -61,7 +63,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Condition'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('Condition'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -77,19 +82,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Condition'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @condition = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Condition'), reply, search_params) + @condition_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Condition'), reply) + validate_search_reply(versioned_resource_class('Condition'), reply, search_params) end test 'Server returns expected results from Condition search by patient+onset-date' do @@ -105,11 +112,21 @@ def validate_resource_item(resource, property, value) assert !@condition.nil?, 'Expected valid Condition resource to be present' patient_val = @instance.patient_id - onset_date_val = @condition&.onsetDateTime + onset_date_val = resolve_element_from_path(@condition, 'onsetDateTime') search_params = { 'patient': patient_val, 'onset-date': onset_date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Condition'), search_params) + validate_search_reply(versioned_resource_class('Condition'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, onset_date_val) + comparator_search_params = { 'patient': patient_val, 'onset-date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Condition'), comparator_search_params) + validate_search_reply(versioned_resource_class('Condition'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Condition search by patient+category' do @@ -125,10 +142,12 @@ def validate_resource_item(resource, property, value) assert !@condition.nil?, 'Expected valid Condition resource to be present' patient_val = @instance.patient_id - category_val = @condition&.category&.first&.coding&.first&.code + category_val = resolve_element_from_path(@condition, 'category.coding.code') search_params = { 'patient': patient_val, 'category': category_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Condition'), search_params) + validate_search_reply(versioned_resource_class('Condition'), reply, search_params) assert_response_ok(reply) end @@ -145,10 +164,12 @@ def validate_resource_item(resource, property, value) assert !@condition.nil?, 'Expected valid Condition resource to be present' patient_val = @instance.patient_id - code_val = @condition&.code&.coding&.first&.code + code_val = resolve_element_from_path(@condition, 'code.coding.code') search_params = { 'patient': patient_val, 'code': code_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Condition'), search_params) + validate_search_reply(versioned_resource_class('Condition'), reply, search_params) assert_response_ok(reply) end @@ -165,10 +186,12 @@ def validate_resource_item(resource, property, value) assert !@condition.nil?, 'Expected valid Condition resource to be present' patient_val = @instance.patient_id - clinical_status_val = @condition&.clinicalStatus&.coding&.first&.code + clinical_status_val = resolve_element_from_path(@condition, 'clinicalStatus.coding.code') search_params = { 'patient': patient_val, 'clinical-status': clinical_status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Condition'), search_params) + validate_search_reply(versioned_resource_class('Condition'), reply, search_params) assert_response_ok(reply) end @@ -217,7 +240,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@condition, versioned_resource_class('Condition')) end - test 'Condition resources associated with Patient conform to Argonaut profiles' do + test 'Condition resources associated with Patient conform to US Core R4 profiles' do metadata do id '10' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-condition.json' @@ -230,9 +253,40 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Condition') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Condition for this patient.' do metadata do id '11' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @condition_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Condition.clinicalStatus', + 'Condition.verificationStatus', + 'Condition.category', + 'Condition.code', + 'Condition.subject' + ] + must_support_elements.each do |path| + @condition_ary&.each do |resource| + truncated_path = path.gsub('Condition.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @condition_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Condition resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '12' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_device_sequence.rb b/lib/app/modules/us_core_r4/us_core_device_sequence.rb index 0861e2736..18ac82ea1 100644 --- a/lib/app/modules/us_core_r4/us_core_device_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_device_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4DeviceSequence < SequenceBase + class USCoreR4DeviceSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Device Tests' @@ -18,12 +18,12 @@ def validate_resource_item(resource, property, value) case property when 'patient' - assert resource&.patient&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'patient.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'type' - codings = resource&.type&.coding - assert !codings.nil?, 'type on resource did not match type requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'type on resource did not match type requested' + value_found = can_resolve_path(resource, 'type.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'type on resource does not match type requested' end end @@ -49,7 +49,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Device'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('Device'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -65,19 +68,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Device'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @device = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Device'), reply, search_params) + @device_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Device'), reply) + validate_search_reply(versioned_resource_class('Device'), reply, search_params) end test 'Server returns expected results from Device search by patient+type' do @@ -93,10 +98,12 @@ def validate_resource_item(resource, property, value) assert !@device.nil?, 'Expected valid Device resource to be present' patient_val = @instance.patient_id - type_val = @device&.type&.coding&.first&.code + type_val = resolve_element_from_path(@device, 'type.coding.code') search_params = { 'patient': patient_val, 'type': type_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Device'), search_params) + validate_search_reply(versioned_resource_class('Device'), reply, search_params) assert_response_ok(reply) end @@ -145,7 +152,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@device, versioned_resource_class('Device')) end - test 'Device resources associated with Patient conform to Argonaut profiles' do + test 'Device resources associated with Patient conform to US Core R4 profiles' do metadata do id '07' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-device.json' @@ -158,9 +165,40 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Device') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Device for this patient.' do metadata do id '08' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @device_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Device.udiCarrier', + 'Device.udiCarrier.carrierAIDC', + 'Device.udiCarrier.carrierHRF', + 'Device.type', + 'Device.patient' + ] + must_support_elements.each do |path| + @device_ary&.each do |resource| + truncated_path = path.gsub('Device.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @device_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Device resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '09' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_diagnosticreport_lab_sequence.rb b/lib/app/modules/us_core_r4/us_core_diagnosticreport_lab_sequence.rb index e4d2a127e..abcbfd3e9 100644 --- a/lib/app/modules/us_core_r4/us_core_diagnosticreport_lab_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_diagnosticreport_lab_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4DiagnosticreportLabSequence < SequenceBase + class USCoreR4DiagnosticreportLabSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'DiagnosticreportLab Tests' + title 'DiagnosticReport for Laboratory Results Reporting Tests' description 'Verify that DiagnosticReport resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,22 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'category' - codings = resource&.category&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' when 'date' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' end end @@ -59,7 +63,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, category: 'LAB' } + + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,14 +85,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @diagnosticreport = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) + @diagnosticreport_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('DiagnosticReport'), reply) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) end test 'Server returns expected results from DiagnosticReport search by patient+code' do @@ -102,10 +109,12 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - code_val = @diagnosticreport&.code&.coding&.first&.code + code_val = resolve_element_from_path(@diagnosticreport, 'code.coding.code') search_params = { 'patient': patient_val, 'code': code_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) end @@ -122,12 +131,22 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - category_val = @diagnosticreport&.category&.coding&.first&.code - date_val = @diagnosticreport&.effectiveDateTime + category_val = resolve_element_from_path(@diagnosticreport, 'category.coding.code') + date_val = resolve_element_from_path(@diagnosticreport, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), comparator_search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from DiagnosticReport search by patient+code+date' do @@ -143,12 +162,22 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - code_val = @diagnosticreport&.code&.coding&.first&.code - date_val = @diagnosticreport&.effectiveDateTime + code_val = resolve_element_from_path(@diagnosticreport, 'code.coding.code') + date_val = resolve_element_from_path(@diagnosticreport, 'effectiveDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), comparator_search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from DiagnosticReport search by patient+status' do @@ -164,10 +193,12 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - status_val = @diagnosticreport&.status + status_val = resolve_element_from_path(@diagnosticreport, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) end @@ -186,6 +217,7 @@ def validate_resource_item(resource, property, value) search_params = { patient: @instance.patient_id, category: 'LAB' } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) end @@ -202,12 +234,22 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - category_val = @diagnosticreport&.category&.coding&.first&.code - date_val = @diagnosticreport&.effectiveDateTime + category_val = resolve_element_from_path(@diagnosticreport, 'category.coding.code') + date_val = resolve_element_from_path(@diagnosticreport, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), comparator_search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'DiagnosticReport create resource supported' do @@ -270,7 +312,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@diagnosticreport, versioned_resource_class('DiagnosticReport')) end - test 'DiagnosticReport resources associated with Patient conform to Argonaut profiles' do + test 'DiagnosticReport resources associated with Patient conform to US Core R4 profiles' do metadata do id '13' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-diagnosticreport-lab.json' @@ -283,9 +325,46 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('DiagnosticReport') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any DiagnosticReport for this patient.' do metadata do id '14' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @diagnosticreport_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'DiagnosticReport.status', + 'DiagnosticReport.category', + 'DiagnosticReport.code', + 'DiagnosticReport.subject', + 'DiagnosticReport.effectiveDateTime', + 'DiagnosticReport.effectivePeriod', + 'DiagnosticReport.issued', + 'DiagnosticReport.performer', + 'DiagnosticReport.result', + 'DiagnosticReport.media', + 'DiagnosticReport.presentedForm' + ] + must_support_elements.each do |path| + @diagnosticreport_ary&.each do |resource| + truncated_path = path.gsub('DiagnosticReport.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @diagnosticreport_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided DiagnosticReport resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '15' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_diagnosticreport_note_sequence.rb b/lib/app/modules/us_core_r4/us_core_diagnosticreport_note_sequence.rb index 9783a9a06..f5004464a 100644 --- a/lib/app/modules/us_core_r4/us_core_diagnosticreport_note_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_diagnosticreport_note_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4DiagnosticreportNoteSequence < SequenceBase + class USCoreR4DiagnosticreportNoteSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'DiagnosticreportNote Tests' + title 'DiagnosticReport for Report and Note exchange Tests' description 'Verify that DiagnosticReport resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,22 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' when 'date' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' end end @@ -59,7 +63,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, code: 'LP29684-5' } + + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,14 +85,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @diagnosticreport = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) + @diagnosticreport_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('DiagnosticReport'), reply) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) end test 'Server returns expected results from DiagnosticReport search by patient+code' do @@ -102,10 +109,12 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - code_val = @diagnosticreport&.code&.coding&.first&.code + code_val = resolve_element_from_path(@diagnosticreport, 'code.coding.code') search_params = { 'patient': patient_val, 'code': code_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) end @@ -122,12 +131,22 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - category_val = @diagnosticreport&.category&.first&.coding&.first&.code - date_val = @diagnosticreport&.effectiveDateTime + category_val = resolve_element_from_path(@diagnosticreport, 'category.coding.code') + date_val = resolve_element_from_path(@diagnosticreport, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), comparator_search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from DiagnosticReport search by patient+code+date' do @@ -143,12 +162,22 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - code_val = @diagnosticreport&.code&.coding&.first&.code - date_val = @diagnosticreport&.effectiveDateTime + code_val = resolve_element_from_path(@diagnosticreport, 'code.coding.code') + date_val = resolve_element_from_path(@diagnosticreport, 'effectiveDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), comparator_search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from DiagnosticReport search by patient+status' do @@ -164,10 +193,12 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - status_val = @diagnosticreport&.status + status_val = resolve_element_from_path(@diagnosticreport, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) end @@ -186,6 +217,7 @@ def validate_resource_item(resource, property, value) search_params = { patient: @instance.patient_id, code: 'LP29684-5' } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) end @@ -202,12 +234,22 @@ def validate_resource_item(resource, property, value) assert !@diagnosticreport.nil?, 'Expected valid DiagnosticReport resource to be present' patient_val = @instance.patient_id - category_val = @diagnosticreport&.category&.first&.coding&.first&.code - date_val = @diagnosticreport&.effectiveDateTime + category_val = resolve_element_from_path(@diagnosticreport, 'category.coding.code') + date_val = resolve_element_from_path(@diagnosticreport, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DiagnosticReport'), comparator_search_params) + validate_search_reply(versioned_resource_class('DiagnosticReport'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'DiagnosticReport create resource supported' do @@ -270,7 +312,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@diagnosticreport, versioned_resource_class('DiagnosticReport')) end - test 'DiagnosticReport resources associated with Patient conform to Argonaut profiles' do + test 'DiagnosticReport resources associated with Patient conform to US Core R4 profiles' do metadata do id '13' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-diagnosticreport-note.json' @@ -283,9 +325,46 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('DiagnosticReport') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any DiagnosticReport for this patient.' do metadata do id '14' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @diagnosticreport_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'DiagnosticReport.status', + 'DiagnosticReport.category', + 'DiagnosticReport.code', + 'DiagnosticReport.subject', + 'DiagnosticReport.encounter', + 'DiagnosticReport.effectiveDateTime', + 'DiagnosticReport.effectivePeriod', + 'DiagnosticReport.issued', + 'DiagnosticReport.performer', + 'DiagnosticReport.media', + 'DiagnosticReport.presentedForm' + ] + must_support_elements.each do |path| + @diagnosticreport_ary&.each do |resource| + truncated_path = path.gsub('DiagnosticReport.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @diagnosticreport_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided DiagnosticReport resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '15' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_documentreference_sequence.rb b/lib/app/modules/us_core_r4/us_core_documentreference_sequence.rb index cb372728d..a24f08210 100644 --- a/lib/app/modules/us_core_r4/us_core_documentreference_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_documentreference_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4DocumentreferenceSequence < SequenceBase + class USCoreR4DocumentreferenceSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Documentreference Tests' + title 'DocumentReference Tests' description 'Verify that DocumentReference resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,27 +18,34 @@ def validate_resource_item(resource, property, value) case property when '_id' - assert resource&.id == value, '_id on resource did not match _id requested' + value_found = can_resolve_path(resource, 'id') { |value_in_resource| value_in_resource == value } + assert value_found, '_id on resource does not match _id requested' when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'type' - codings = resource&.type&.coding - assert !codings.nil?, 'type on resource did not match type requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'type on resource did not match type requested' + value_found = can_resolve_path(resource, 'type.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'type on resource does not match type requested' when 'date' + value_found = can_resolve_path(resource, 'date') { |value_in_resource| value_in_resource == value } + assert value_found, 'date on resource does not match date requested' when 'period' + value_found = can_resolve_path(resource, 'context.period') do |period| + validate_period_search(value, period) + end + assert value_found, 'period on resource does not match period requested' end end @@ -64,7 +71,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('DocumentReference'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -80,19 +90,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @documentreference = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) + @documentreference_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('DocumentReference'), reply) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) end test 'Server returns expected results from DocumentReference search by _id' do @@ -107,10 +119,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@documentreference.nil?, 'Expected valid DocumentReference resource to be present' - id_val = @documentreference&.id + id_val = resolve_element_from_path(@documentreference, 'id') search_params = { '_id': id_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) assert_response_ok(reply) end @@ -127,10 +141,12 @@ def validate_resource_item(resource, property, value) assert !@documentreference.nil?, 'Expected valid DocumentReference resource to be present' patient_val = @instance.patient_id - category_val = @documentreference&.category&.first&.coding&.first&.code + category_val = resolve_element_from_path(@documentreference, 'category.coding.code') search_params = { 'patient': patient_val, 'category': category_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) assert_response_ok(reply) end @@ -147,11 +163,13 @@ def validate_resource_item(resource, property, value) assert !@documentreference.nil?, 'Expected valid DocumentReference resource to be present' patient_val = @instance.patient_id - category_val = @documentreference&.category&.first&.coding&.first&.code - date_val = @documentreference&.date + category_val = resolve_element_from_path(@documentreference, 'category.coding.code') + date_val = resolve_element_from_path(@documentreference, 'date') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) assert_response_ok(reply) end @@ -168,10 +186,12 @@ def validate_resource_item(resource, property, value) assert !@documentreference.nil?, 'Expected valid DocumentReference resource to be present' patient_val = @instance.patient_id - type_val = @documentreference&.type&.coding&.first&.code + type_val = resolve_element_from_path(@documentreference, 'type.coding.code') search_params = { 'patient': patient_val, 'type': type_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) assert_response_ok(reply) end @@ -188,10 +208,12 @@ def validate_resource_item(resource, property, value) assert !@documentreference.nil?, 'Expected valid DocumentReference resource to be present' patient_val = @instance.patient_id - status_val = @documentreference&.status + status_val = resolve_element_from_path(@documentreference, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) assert_response_ok(reply) end @@ -208,12 +230,22 @@ def validate_resource_item(resource, property, value) assert !@documentreference.nil?, 'Expected valid DocumentReference resource to be present' patient_val = @instance.patient_id - type_val = @documentreference&.type&.coding&.first&.code - period_val = @documentreference&.context&.period&.start + type_val = resolve_element_from_path(@documentreference, 'type.coding.code') + period_val = resolve_element_from_path(@documentreference, 'context.period.start') search_params = { 'patient': patient_val, 'type': type_val, 'period': period_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('DocumentReference'), search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, period_val) + comparator_search_params = { 'patient': patient_val, 'type': type_val, 'period': comparator_val } + reply = get_resource_by_params(versioned_resource_class('DocumentReference'), comparator_search_params) + validate_search_reply(versioned_resource_class('DocumentReference'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'DocumentReference create resource supported' do @@ -276,7 +308,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@documentreference, versioned_resource_class('DocumentReference')) end - test 'DocumentReference resources associated with Patient conform to Argonaut profiles' do + test 'DocumentReference resources associated with Patient conform to US Core R4 profiles' do metadata do id '13' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-documentreference.json' @@ -289,9 +321,52 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('DocumentReference') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any DocumentReference for this patient.' do metadata do id '14' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @documentreference_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'DocumentReference.identifier', + 'DocumentReference.status', + 'DocumentReference.type', + 'DocumentReference.category', + 'DocumentReference.subject', + 'DocumentReference.date', + 'DocumentReference.author', + 'DocumentReference.custodian', + 'DocumentReference.content', + 'DocumentReference.content.attachment', + 'DocumentReference.content.attachment.contentType', + 'DocumentReference.content.attachment.data', + 'DocumentReference.content.attachment.url', + 'DocumentReference.content.format', + 'DocumentReference.context', + 'DocumentReference.context.encounter', + 'DocumentReference.context.period' + ] + must_support_elements.each do |path| + @documentreference_ary&.each do |resource| + truncated_path = path.gsub('DocumentReference.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @documentreference_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided DocumentReference resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '15' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_encounter_sequence.rb b/lib/app/modules/us_core_r4/us_core_encounter_sequence.rb index b96594e62..ec408e5a9 100644 --- a/lib/app/modules/us_core_r4/us_core_encounter_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_encounter_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4EncounterSequence < SequenceBase + class USCoreR4EncounterSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Encounter Tests' @@ -18,26 +18,34 @@ def validate_resource_item(resource, property, value) case property when '_id' - assert resource&.id == value, '_id on resource did not match _id requested' + value_found = can_resolve_path(resource, 'id') { |value_in_resource| value_in_resource == value } + assert value_found, '_id on resource does not match _id requested' when 'class' - assert resource&.local_class&.code == value, 'class on resource did not match class requested' + value_found = can_resolve_path(resource, 'local_class.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'class on resource does not match class requested' when 'date' + value_found = can_resolve_path(resource, 'period') do |period| + validate_period_search(value, period) + end + assert value_found, 'date on resource does not match date requested' when 'identifier' - assert resource&.identifier&.any? { |identifier| identifier.value == value }, 'identifier on resource did not match identifier requested' + value_found = can_resolve_path(resource, 'identifier.value') { |value_in_resource| value_in_resource == value } + assert value_found, 'identifier on resource does not match identifier requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'type' - codings = resource&.type&.first&.coding - assert !codings.nil?, 'type on resource did not match type requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'type on resource did not match type requested' + value_found = can_resolve_path(resource, 'type.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'type on resource does not match type requested' end end @@ -63,7 +71,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Encounter'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,19 +90,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @encounter = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) + @encounter_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Encounter'), reply) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) end test 'Server returns expected results from Encounter search by _id' do @@ -106,10 +119,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@encounter.nil?, 'Expected valid Encounter resource to be present' - id_val = @encounter&.id + id_val = resolve_element_from_path(@encounter, 'id') search_params = { '_id': id_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) assert_response_ok(reply) end @@ -125,12 +140,22 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@encounter.nil?, 'Expected valid Encounter resource to be present' - date_val = @encounter&.period&.start + date_val = resolve_element_from_path(@encounter, 'period.start') patient_val = @instance.patient_id search_params = { 'date': date_val, 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'date': comparator_val, 'patient': patient_val } + reply = get_resource_by_params(versioned_resource_class('Encounter'), comparator_search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Encounter search by identifier' do @@ -145,10 +170,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@encounter.nil?, 'Expected valid Encounter resource to be present' - identifier_val = @encounter&.identifier&.first&.value + identifier_val = resolve_element_from_path(@encounter, 'identifier.value') search_params = { 'identifier': identifier_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) assert_response_ok(reply) end @@ -165,10 +192,12 @@ def validate_resource_item(resource, property, value) assert !@encounter.nil?, 'Expected valid Encounter resource to be present' patient_val = @instance.patient_id - status_val = @encounter&.status + status_val = resolve_element_from_path(@encounter, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) assert_response_ok(reply) end @@ -184,11 +213,13 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@encounter.nil?, 'Expected valid Encounter resource to be present' - class_val = @encounter&.local_class&.code + class_val = resolve_element_from_path(@encounter, 'class.code') patient_val = @instance.patient_id search_params = { 'class': class_val, 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) assert_response_ok(reply) end @@ -205,10 +236,12 @@ def validate_resource_item(resource, property, value) assert !@encounter.nil?, 'Expected valid Encounter resource to be present' patient_val = @instance.patient_id - type_val = @encounter&.type&.first&.coding&.first&.code + type_val = resolve_element_from_path(@encounter, 'type.coding.code') search_params = { 'patient': patient_val, 'type': type_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Encounter'), search_params) + validate_search_reply(versioned_resource_class('Encounter'), reply, search_params) assert_response_ok(reply) end @@ -257,7 +290,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@encounter, versioned_resource_class('Encounter')) end - test 'Encounter resources associated with Patient conform to Argonaut profiles' do + test 'Encounter resources associated with Patient conform to US Core R4 profiles' do metadata do id '12' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-encounter.json' @@ -270,9 +303,52 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Encounter') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Encounter for this patient.' do metadata do id '13' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @encounter_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Encounter.identifier', + 'Encounter.identifier.system', + 'Encounter.identifier.value', + 'Encounter.status', + 'Encounter.local_class', + 'Encounter.type', + 'Encounter.subject', + 'Encounter.participant', + 'Encounter.participant.type', + 'Encounter.participant.period', + 'Encounter.participant.individual', + 'Encounter.period', + 'Encounter.reasonCode', + 'Encounter.hospitalization', + 'Encounter.hospitalization.dischargeDisposition', + 'Encounter.location', + 'Encounter.location.location' + ] + must_support_elements.each do |path| + @encounter_ary&.each do |resource| + truncated_path = path.gsub('Encounter.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @encounter_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Encounter resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '14' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_goal_sequence.rb b/lib/app/modules/us_core_r4/us_core_goal_sequence.rb index 31455eca9..033e8f5f4 100644 --- a/lib/app/modules/us_core_r4/us_core_goal_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_goal_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4GoalSequence < SequenceBase + class USCoreR4GoalSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Goal Tests' @@ -18,12 +18,18 @@ def validate_resource_item(resource, property, value) case property when 'lifecycle-status' - assert resource&.lifecycleStatus == value, 'lifecycle-status on resource did not match lifecycle-status requested' + value_found = can_resolve_path(resource, 'lifecycleStatus') { |value_in_resource| value_in_resource == value } + assert value_found, 'lifecycle-status on resource does not match lifecycle-status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'target-date' + value_found = can_resolve_path(resource, 'target.dueDate') do |date| + validate_date_search(value, date) + end + assert value_found, 'target-date on resource does not match target-date requested' end end @@ -49,7 +55,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Goal'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('Goal'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -65,19 +74,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Goal'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @goal = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Goal'), reply, search_params) + @goal_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Goal'), reply) + validate_search_reply(versioned_resource_class('Goal'), reply, search_params) end test 'Server returns expected results from Goal search by patient+target-date' do @@ -93,11 +104,21 @@ def validate_resource_item(resource, property, value) assert !@goal.nil?, 'Expected valid Goal resource to be present' patient_val = @instance.patient_id - target_date_val = @goal&.target&.first&.dueDate + target_date_val = resolve_element_from_path(@goal, 'target.dueDate') search_params = { 'patient': patient_val, 'target-date': target_date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Goal'), search_params) + validate_search_reply(versioned_resource_class('Goal'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, target_date_val) + comparator_search_params = { 'patient': patient_val, 'target-date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Goal'), comparator_search_params) + validate_search_reply(versioned_resource_class('Goal'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Goal search by patient+lifecycle-status' do @@ -113,10 +134,12 @@ def validate_resource_item(resource, property, value) assert !@goal.nil?, 'Expected valid Goal resource to be present' patient_val = @instance.patient_id - lifecycle_status_val = @goal&.lifecycleStatus + lifecycle_status_val = resolve_element_from_path(@goal, 'lifecycleStatus') search_params = { 'patient': patient_val, 'lifecycle-status': lifecycle_status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Goal'), search_params) + validate_search_reply(versioned_resource_class('Goal'), reply, search_params) assert_response_ok(reply) end @@ -165,7 +188,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@goal, versioned_resource_class('Goal')) end - test 'Goal resources associated with Patient conform to Argonaut profiles' do + test 'Goal resources associated with Patient conform to US Core R4 profiles' do metadata do id '08' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-goal.json' @@ -178,9 +201,41 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Goal') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Goal for this patient.' do metadata do id '09' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @goal_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Goal.lifecycleStatus', + 'Goal.description', + 'Goal.subject', + 'Goal.target', + 'Goal.target.dueDate', + 'Goal.target.dueDuration' + ] + must_support_elements.each do |path| + @goal_ary&.each do |resource| + truncated_path = path.gsub('Goal.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @goal_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Goal resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '10' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_immunization_sequence.rb b/lib/app/modules/us_core_r4/us_core_immunization_sequence.rb index ea7d3ee0e..f20b53d88 100644 --- a/lib/app/modules/us_core_r4/us_core_immunization_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_immunization_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4ImmunizationSequence < SequenceBase + class USCoreR4ImmunizationSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Immunization Tests' @@ -18,12 +18,18 @@ def validate_resource_item(resource, property, value) case property when 'patient' - assert resource&.patient&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'patient.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'date' + value_found = can_resolve_path(resource, 'occurrenceDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' end end @@ -49,7 +55,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Immunization'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('Immunization'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -65,19 +74,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Immunization'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @immunization = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Immunization'), reply, search_params) + @immunization_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Immunization'), reply) + validate_search_reply(versioned_resource_class('Immunization'), reply, search_params) end test 'Server returns expected results from Immunization search by patient+date' do @@ -93,11 +104,21 @@ def validate_resource_item(resource, property, value) assert !@immunization.nil?, 'Expected valid Immunization resource to be present' patient_val = @instance.patient_id - date_val = @immunization&.occurrenceDateTime + date_val = resolve_element_from_path(@immunization, 'occurrenceDateTime') search_params = { 'patient': patient_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Immunization'), search_params) + validate_search_reply(versioned_resource_class('Immunization'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Immunization'), comparator_search_params) + validate_search_reply(versioned_resource_class('Immunization'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Immunization search by patient+status' do @@ -113,10 +134,12 @@ def validate_resource_item(resource, property, value) assert !@immunization.nil?, 'Expected valid Immunization resource to be present' patient_val = @instance.patient_id - status_val = @immunization&.status + status_val = resolve_element_from_path(@immunization, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Immunization'), search_params) + validate_search_reply(versioned_resource_class('Immunization'), reply, search_params) assert_response_ok(reply) end @@ -165,7 +188,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@immunization, versioned_resource_class('Immunization')) end - test 'Immunization resources associated with Patient conform to Argonaut profiles' do + test 'Immunization resources associated with Patient conform to US Core R4 profiles' do metadata do id '08' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-immunization.json' @@ -178,9 +201,42 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Immunization') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Immunization for this patient.' do metadata do id '09' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @immunization_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Immunization.status', + 'Immunization.statusReason', + 'Immunization.vaccineCode', + 'Immunization.patient', + 'Immunization.occurrenceDateTime', + 'Immunization.occurrenceString', + 'Immunization.primarySource' + ] + must_support_elements.each do |path| + @immunization_ary&.each do |resource| + truncated_path = path.gsub('Immunization.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @immunization_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Immunization resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '10' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_location_sequence.rb b/lib/app/modules/us_core_r4/us_core_location_sequence.rb index 6fc6e6703..00881a850 100644 --- a/lib/app/modules/us_core_r4/us_core_location_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_location_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4LocationSequence < SequenceBase + class USCoreR4LocationSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Location Tests' @@ -18,18 +18,24 @@ def validate_resource_item(resource, property, value) case property when 'name' - assert resource&.name == value, 'name on resource did not match name requested' + value_found = can_resolve_path(resource, 'name') { |value_in_resource| value_in_resource == value } + assert value_found, 'name on resource does not match name requested' when 'address' + value_found = can_resolve_path(resource, 'address') { |value_in_resource| value_in_resource == value } + assert value_found, 'address on resource does not match address requested' when 'address-city' - assert resource&.address&.city == value, 'address-city on resource did not match address-city requested' + value_found = can_resolve_path(resource, 'address.city') { |value_in_resource| value_in_resource == value } + assert value_found, 'address-city on resource does not match address-city requested' when 'address-state' - assert resource&.address&.state == value, 'address-state on resource did not match address-state requested' + value_found = can_resolve_path(resource, 'address.state') { |value_in_resource| value_in_resource == value } + assert value_found, 'address-state on resource does not match address-state requested' when 'address-postalcode' - assert resource&.address&.postalCode == value, 'address-postalcode on resource did not match address-postalcode requested' + value_found = can_resolve_path(resource, 'address.postalCode') { |value_in_resource| value_in_resource == value } + assert value_found, 'address-postalcode on resource does not match address-postalcode requested' end end @@ -55,7 +61,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Location'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, name: 'Boston' } + + reply = get_resource_by_params(versioned_resource_class('Location'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -75,14 +83,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @location = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Location'), reply, search_params) + @location_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Location'), reply) + validate_search_reply(versioned_resource_class('Location'), reply, search_params) end test 'Server returns expected results from Location search by address' do @@ -97,10 +106,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@location.nil?, 'Expected valid Location resource to be present' - address_val = @location&.address + address_val = resolve_element_from_path(@location, 'address') search_params = { 'address': address_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Location'), search_params) + validate_search_reply(versioned_resource_class('Location'), reply, search_params) assert_response_ok(reply) end @@ -116,10 +127,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@location.nil?, 'Expected valid Location resource to be present' - address_city_val = @location&.address&.city + address_city_val = resolve_element_from_path(@location, 'address.city') search_params = { 'address-city': address_city_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Location'), search_params) + validate_search_reply(versioned_resource_class('Location'), reply, search_params) assert_response_ok(reply) end @@ -135,10 +148,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@location.nil?, 'Expected valid Location resource to be present' - address_state_val = @location&.address&.state + address_state_val = resolve_element_from_path(@location, 'address.state') search_params = { 'address-state': address_state_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Location'), search_params) + validate_search_reply(versioned_resource_class('Location'), reply, search_params) assert_response_ok(reply) end @@ -154,10 +169,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@location.nil?, 'Expected valid Location resource to be present' - address_postalcode_val = @location&.address&.postalCode + address_postalcode_val = resolve_element_from_path(@location, 'address.postalCode') search_params = { 'address-postalcode': address_postalcode_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Location'), search_params) + validate_search_reply(versioned_resource_class('Location'), reply, search_params) assert_response_ok(reply) end @@ -206,7 +223,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@location, versioned_resource_class('Location')) end - test 'Location resources associated with Patient conform to Argonaut profiles' do + test 'Location resources associated with Patient conform to US Core R4 profiles' do metadata do id '10' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-location.json' @@ -219,9 +236,44 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Location') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Location for this patient.' do metadata do id '11' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @location_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Location.status', + 'Location.name', + 'Location.telecom', + 'Location.address', + 'Location.address.line', + 'Location.address.city', + 'Location.address.state', + 'Location.address.postalCode', + 'Location.managingOrganization' + ] + must_support_elements.each do |path| + @location_ary&.each do |resource| + truncated_path = path.gsub('Location.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @location_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Location resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '12' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_medication_sequence.rb b/lib/app/modules/us_core_r4/us_core_medication_sequence.rb index 58842d85f..6543d5242 100644 --- a/lib/app/modules/us_core_r4/us_core_medication_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_medication_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4MedicationSequence < SequenceBase + class USCoreR4MedicationSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Medication Tests' @@ -23,26 +23,9 @@ class UsCoreR4MedicationSequence < SequenceBase @resources_found = false - test 'Server rejects Medication search without authorization' do - metadata do - id '01' - link 'http://www.fhir.org/guides/argonaut/r2/Conformance-server.html' - desc %( - ) - versions :r4 - end - - @client.set_no_auth - skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - - reply = get_resource_by_params(versioned_resource_class('Medication'), patient: @instance.patient_id) - @client.set_bearer_token(@instance.token) - assert_response_unauthorized reply - end - test 'Medication read resource supported' do metadata do - id '02' + id '01' link 'https://build.fhir.org/ig/HL7/US-Core-R4/CapabilityStatement-us-core-server.html' desc %( ) @@ -57,7 +40,7 @@ class UsCoreR4MedicationSequence < SequenceBase test 'Medication vread resource supported' do metadata do - id '03' + id '02' link 'https://build.fhir.org/ig/HL7/US-Core-R4/CapabilityStatement-us-core-server.html' desc %( ) @@ -72,7 +55,7 @@ class UsCoreR4MedicationSequence < SequenceBase test 'Medication history resource supported' do metadata do - id '04' + id '03' link 'https://build.fhir.org/ig/HL7/US-Core-R4/CapabilityStatement-us-core-server.html' desc %( ) @@ -85,9 +68,9 @@ class UsCoreR4MedicationSequence < SequenceBase validate_history_reply(@medication, versioned_resource_class('Medication')) end - test 'Medication resources associated with Patient conform to Argonaut profiles' do + test 'Medication resources associated with Patient conform to US Core R4 profiles' do metadata do - id '05' + id '04' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-medication.json' desc %( ) @@ -98,6 +81,33 @@ class UsCoreR4MedicationSequence < SequenceBase test_resources_against_profile('Medication') end + test 'At least one of every must support element is provided in any Medication for this patient.' do + metadata do + id '05' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @medication_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Medication.code' + ] + must_support_elements.each do |path| + @medication_ary&.each do |resource| + truncated_path = path.gsub('Medication.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @medication_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Medication resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + test 'All references can be resolved' do metadata do id '06' diff --git a/lib/app/modules/us_core_r4/us_core_medicationrequest_sequence.rb b/lib/app/modules/us_core_r4/us_core_medicationrequest_sequence.rb index c4b2e8a52..7c5cf4b96 100644 --- a/lib/app/modules/us_core_r4/us_core_medicationrequest_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_medicationrequest_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4MedicationrequestSequence < SequenceBase + class USCoreR4MedicationrequestSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Medicationrequest Tests' + title 'MedicationRequest Tests' description 'Verify that MedicationRequest resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,12 +18,16 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'authoredon' + value_found = can_resolve_path(resource, 'authoredOn') { |value_in_resource| value_in_resource == value } + assert value_found, 'authoredon on resource does not match authoredon requested' end end @@ -49,7 +53,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('MedicationRequest'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('MedicationRequest'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -65,19 +72,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('MedicationRequest'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @medicationrequest = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('MedicationRequest'), reply, search_params) + @medicationrequest_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('MedicationRequest'), reply) + validate_search_reply(versioned_resource_class('MedicationRequest'), reply, search_params) end test 'Server returns expected results from MedicationRequest search by patient+status' do @@ -93,10 +102,12 @@ def validate_resource_item(resource, property, value) assert !@medicationrequest.nil?, 'Expected valid MedicationRequest resource to be present' patient_val = @instance.patient_id - status_val = @medicationrequest&.status + status_val = resolve_element_from_path(@medicationrequest, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('MedicationRequest'), search_params) + validate_search_reply(versioned_resource_class('MedicationRequest'), reply, search_params) assert_response_ok(reply) end @@ -113,10 +124,12 @@ def validate_resource_item(resource, property, value) assert !@medicationrequest.nil?, 'Expected valid MedicationRequest resource to be present' patient_val = @instance.patient_id - authoredon_val = @medicationrequest&.authoredOn + authoredon_val = resolve_element_from_path(@medicationrequest, 'authoredOn') search_params = { 'patient': patient_val, 'authoredon': authoredon_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('MedicationRequest'), search_params) + validate_search_reply(versioned_resource_class('MedicationRequest'), reply, search_params) assert_response_ok(reply) end @@ -165,7 +178,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@medicationrequest, versioned_resource_class('MedicationRequest')) end - test 'MedicationRequest resources associated with Patient conform to Argonaut profiles' do + test 'MedicationRequest resources associated with Patient conform to US Core R4 profiles' do metadata do id '08' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-medicationrequest.json' @@ -178,9 +191,43 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('MedicationRequest') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any MedicationRequest for this patient.' do metadata do id '09' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @medicationrequest_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'MedicationRequest.status', + 'MedicationRequest.medicationCodeableConcept', + 'MedicationRequest.medicationReference', + 'MedicationRequest.subject', + 'MedicationRequest.authoredOn', + 'MedicationRequest.requester', + 'MedicationRequest.dosageInstruction', + 'MedicationRequest.dosageInstruction.text' + ] + must_support_elements.each do |path| + @medicationrequest_ary&.each do |resource| + truncated_path = path.gsub('MedicationRequest.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @medicationrequest_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided MedicationRequest resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '10' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_medicationstatement_sequence.rb b/lib/app/modules/us_core_r4/us_core_medicationstatement_sequence.rb index 8664da0fe..5081eb44d 100644 --- a/lib/app/modules/us_core_r4/us_core_medicationstatement_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_medicationstatement_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4MedicationstatementSequence < SequenceBase + class USCoreR4MedicationstatementSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Medicationstatement Tests' + title 'MedicationStatement Tests' description 'Verify that MedicationStatement resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,12 +18,18 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'effective' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'effective on resource does not match effective requested' end end @@ -49,7 +55,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('MedicationStatement'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('MedicationStatement'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -65,19 +74,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('MedicationStatement'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @medicationstatement = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('MedicationStatement'), reply, search_params) + @medicationstatement_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('MedicationStatement'), reply) + validate_search_reply(versioned_resource_class('MedicationStatement'), reply, search_params) end test 'Server returns expected results from MedicationStatement search by patient+effective' do @@ -93,11 +104,21 @@ def validate_resource_item(resource, property, value) assert !@medicationstatement.nil?, 'Expected valid MedicationStatement resource to be present' patient_val = @instance.patient_id - effective_val = @medicationstatement&.effectiveDateTime + effective_val = resolve_element_from_path(@medicationstatement, 'effectiveDateTime') search_params = { 'patient': patient_val, 'effective': effective_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('MedicationStatement'), search_params) + validate_search_reply(versioned_resource_class('MedicationStatement'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, effective_val) + comparator_search_params = { 'patient': patient_val, 'effective': comparator_val } + reply = get_resource_by_params(versioned_resource_class('MedicationStatement'), comparator_search_params) + validate_search_reply(versioned_resource_class('MedicationStatement'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from MedicationStatement search by patient+status' do @@ -113,10 +134,12 @@ def validate_resource_item(resource, property, value) assert !@medicationstatement.nil?, 'Expected valid MedicationStatement resource to be present' patient_val = @instance.patient_id - status_val = @medicationstatement&.status + status_val = resolve_element_from_path(@medicationstatement, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('MedicationStatement'), search_params) + validate_search_reply(versioned_resource_class('MedicationStatement'), reply, search_params) assert_response_ok(reply) end @@ -165,7 +188,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@medicationstatement, versioned_resource_class('MedicationStatement')) end - test 'MedicationStatement resources associated with Patient conform to Argonaut profiles' do + test 'MedicationStatement resources associated with Patient conform to US Core R4 profiles' do metadata do id '08' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-medicationstatement.json' @@ -178,9 +201,43 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('MedicationStatement') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any MedicationStatement for this patient.' do metadata do id '09' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @medicationstatement_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'MedicationStatement.status', + 'MedicationStatement.medicationCodeableConcept', + 'MedicationStatement.medicationReference', + 'MedicationStatement.subject', + 'MedicationStatement.effectiveDateTime', + 'MedicationStatement.effectivePeriod', + 'MedicationStatement.dateAsserted', + 'MedicationStatement.derivedFrom' + ] + must_support_elements.each do |path| + @medicationstatement_ary&.each do |resource| + truncated_path = path.gsub('MedicationStatement.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @medicationstatement_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided MedicationStatement resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '10' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_observation_lab_sequence.rb b/lib/app/modules/us_core_r4/us_core_observation_lab_sequence.rb index 458afcbf4..a2b658ad0 100644 --- a/lib/app/modules/us_core_r4/us_core_observation_lab_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_observation_lab_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4ObservationLabSequence < SequenceBase + class USCoreR4ObservationLabSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'ObservationLab Tests' + title 'Laboratory Result Observation Tests' description 'Verify that Observation resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,22 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' when 'date' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' end end @@ -59,7 +63,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Observation'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, category: 'laboratory' } + + reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,14 +85,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @observation = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Observation'), reply, search_params) + @observation_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) end test 'Server returns expected results from Observation search by patient+code' do @@ -102,10 +109,12 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - code_val = @observation&.code&.coding&.first&.code + code_val = resolve_element_from_path(@observation, 'code.coding.code') search_params = { 'patient': patient_val, 'code': code_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -122,12 +131,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - date_val = @observation&.effectiveDateTime + category_val = resolve_element_from_path(@observation, 'category.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+code+date' do @@ -143,12 +162,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - code_val = @observation&.code&.coding&.first&.code - date_val = @observation&.effectiveDateTime + code_val = resolve_element_from_path(@observation, 'code.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+category+status' do @@ -164,11 +193,13 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - status_val = @observation&.status + category_val = resolve_element_from_path(@observation, 'category.coding.code') + status_val = resolve_element_from_path(@observation, 'status') search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -217,7 +248,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@observation, versioned_resource_class('Observation')) end - test 'Observation resources associated with Patient conform to Argonaut profiles' do + test 'Observation resources associated with Patient conform to US Core R4 profiles' do metadata do id '10' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-observation-lab.json' @@ -230,9 +261,53 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Observation') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Observation for this patient.' do metadata do id '11' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @observation_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Observation.status', + 'Observation.category', + 'Observation.code', + 'Observation.subject', + 'Observation.effectiveDateTime', + 'Observation.effectivePeriod', + 'Observation.valueQuantity', + 'Observation.valueCodeableConcept', + 'Observation.valueString', + 'Observation.valueBoolean', + 'Observation.valueInteger', + 'Observation.valueRange', + 'Observation.valueRatio', + 'Observation.valueSampledData', + 'Observation.valueTime', + 'Observation.valueDateTime', + 'Observation.valuePeriod', + 'Observation.dataAbsentReason' + ] + must_support_elements.each do |path| + @observation_ary&.each do |resource| + truncated_path = path.gsub('Observation.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @observation_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Observation resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '12' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_organization_sequence.rb b/lib/app/modules/us_core_r4/us_core_organization_sequence.rb index 69c4189cd..474d93d13 100644 --- a/lib/app/modules/us_core_r4/us_core_organization_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_organization_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4OrganizationSequence < SequenceBase + class USCoreR4OrganizationSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Organization Tests' @@ -18,9 +18,12 @@ def validate_resource_item(resource, property, value) case property when 'name' - assert resource&.name == value, 'name on resource did not match name requested' + value_found = can_resolve_path(resource, 'name') { |value_in_resource| value_in_resource == value } + assert value_found, 'name on resource does not match name requested' when 'address' + value_found = can_resolve_path(resource, 'address') { |value_in_resource| value_in_resource == value } + assert value_found, 'address on resource does not match address requested' end end @@ -46,7 +49,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Organization'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, name: 'Boston' } + + reply = get_resource_by_params(versioned_resource_class('Organization'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -66,14 +71,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @organization = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Organization'), reply, search_params) + @organization_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Organization'), reply) + validate_search_reply(versioned_resource_class('Organization'), reply, search_params) end test 'Server returns expected results from Organization search by address' do @@ -88,10 +94,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@organization.nil?, 'Expected valid Organization resource to be present' - address_val = @organization&.address&.first + address_val = resolve_element_from_path(@organization, 'address') search_params = { 'address': address_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Organization'), search_params) + validate_search_reply(versioned_resource_class('Organization'), reply, search_params) assert_response_ok(reply) end @@ -140,7 +148,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@organization, versioned_resource_class('Organization')) end - test 'Organization resources associated with Patient conform to Argonaut profiles' do + test 'Organization resources associated with Patient conform to US Core R4 profiles' do metadata do id '07' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-organization.json' @@ -153,9 +161,47 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Organization') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Organization for this patient.' do metadata do id '08' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @organization_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Organization.identifier', + 'Organization.identifier.system', + 'Organization.active', + 'Organization.name', + 'Organization.telecom', + 'Organization.address', + 'Organization.address.line', + 'Organization.address.city', + 'Organization.address.state', + 'Organization.address.postalCode', + 'Organization.address.country', + 'Organization.endpoint' + ] + must_support_elements.each do |path| + @organization_ary&.each do |resource| + truncated_path = path.gsub('Organization.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @organization_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Organization resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '09' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_patient_sequence.rb b/lib/app/modules/us_core_r4/us_core_patient_sequence.rb index 511db4a49..05a662335 100644 --- a/lib/app/modules/us_core_r4/us_core_patient_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_patient_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4PatientSequence < SequenceBase + class USCoreR4PatientSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Patient Tests' @@ -18,31 +18,42 @@ def validate_resource_item(resource, property, value) case property when '_id' - assert resource&.id == value, '_id on resource did not match _id requested' + value_found = can_resolve_path(resource, 'id') { |value_in_resource| value_in_resource == value } + assert value_found, '_id on resource does not match _id requested' when 'birthdate' + value_found = can_resolve_path(resource, 'birthDate') do |date| + validate_date_search(value, date) + end + assert value_found, 'birthdate on resource does not match birthdate requested' when 'family' - assert resource&.name&.family == value, 'family on resource did not match family requested' + value_found = can_resolve_path(resource, 'name.family') { |value_in_resource| value_in_resource == value } + assert value_found, 'family on resource does not match family requested' when 'gender' - assert resource&.gender == value, 'gender on resource did not match gender requested' + value_found = can_resolve_path(resource, 'gender') { |value_in_resource| value_in_resource == value } + assert value_found, 'gender on resource does not match gender requested' when 'given' - assert resource&.name&.given == value, 'given on resource did not match given requested' + value_found = can_resolve_path(resource, 'name.given') { |value_in_resource| value_in_resource == value } + assert value_found, 'given on resource does not match given requested' when 'identifier' - assert resource&.identifier&.any? { |identifier| identifier.value == value }, 'identifier on resource did not match identifier requested' + value_found = can_resolve_path(resource, 'identifier.value') { |value_in_resource| value_in_resource == value } + assert value_found, 'identifier on resource does not match identifier requested' when 'name' - found = resource&.name&.any? do |name| - name.text&.include?(value) || - name.family.include?(value) || - name.given.any { |given| given&.include?(value) } || - name.prefix.any { |prefix| prefix.include?(value) } || - name.suffix.any { |suffix| suffix.include?(value) } + value = value.downcase + value_found = can_resolve_path(resource, 'name') do |name| + name&.text&.start_with?(value) || + name&.family&.downcase&.include?(value) || + name&.given&.any? { |given| given.downcase.start_with?(value) } || + name&.prefix&.any? { |prefix| prefix.downcase.start_with?(value) } || + name&.suffix&.any? { |suffix| suffix.downcase.start_with?(value) } end - assert found, 'name on resource does not match name requested' + assert value_found, 'name on resource does not match name requested' + end end @@ -67,7 +78,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Patient'), patient: @instance.patient_id) + search_params = { '_id': @instance.patient_id } + + reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -87,14 +100,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @patient = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) + @patient_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Patient'), reply) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) end test 'Server returns expected results from Patient search by identifier' do @@ -109,10 +123,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@patient.nil?, 'Expected valid Patient resource to be present' - identifier_val = @patient&.identifier&.first&.value + identifier_val = resolve_element_from_path(@patient, 'identifier.value') search_params = { 'identifier': identifier_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) assert_response_ok(reply) end @@ -128,10 +144,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@patient.nil?, 'Expected valid Patient resource to be present' - name_val = @patient&.name&.first&.family + name_val = resolve_element_from_path(@patient, 'name.family') search_params = { 'name': name_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) assert_response_ok(reply) end @@ -147,11 +165,13 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@patient.nil?, 'Expected valid Patient resource to be present' - birthdate_val = @patient&.birthDate - name_val = @patient&.name&.first&.family + birthdate_val = resolve_element_from_path(@patient, 'birthDate') + name_val = resolve_element_from_path(@patient, 'name.family') search_params = { 'birthdate': birthdate_val, 'name': name_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) assert_response_ok(reply) end @@ -167,11 +187,13 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@patient.nil?, 'Expected valid Patient resource to be present' - gender_val = @patient&.gender - name_val = @patient&.name&.first&.family + gender_val = resolve_element_from_path(@patient, 'gender') + name_val = resolve_element_from_path(@patient, 'name.family') search_params = { 'gender': gender_val, 'name': name_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) assert_response_ok(reply) end @@ -187,11 +209,13 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@patient.nil?, 'Expected valid Patient resource to be present' - family_val = @patient&.name&.first&.family - gender_val = @patient&.gender + family_val = resolve_element_from_path(@patient, 'name.family') + gender_val = resolve_element_from_path(@patient, 'gender') search_params = { 'family': family_val, 'gender': gender_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) assert_response_ok(reply) end @@ -207,11 +231,13 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@patient.nil?, 'Expected valid Patient resource to be present' - birthdate_val = @patient&.birthDate - family_val = @patient&.name&.first&.family + birthdate_val = resolve_element_from_path(@patient, 'birthDate') + family_val = resolve_element_from_path(@patient, 'name.family') search_params = { 'birthdate': birthdate_val, 'family': family_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) + validate_search_reply(versioned_resource_class('Patient'), reply, search_params) assert_response_ok(reply) end @@ -260,7 +286,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@patient, versioned_resource_class('Patient')) end - test 'Patient resources associated with Patient conform to Argonaut profiles' do + test 'Patient resources associated with Patient conform to US Core R4 profiles' do metadata do id '12' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.json' @@ -273,9 +299,66 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Patient') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Patient for this patient.' do metadata do id '13' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @patient_ary&.any? + must_support_confirmed = {} + extensions_list = { + 'Patient.extension:race': 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-race', + 'Patient.extension:ethnicity': 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity', + 'Patient.extension:birthsex': 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex' + } + extensions_list.each do |id, url| + @patient_ary&.each do |resource| + must_support_confirmed[id] = true if resource.extension.any? { |extension| extension.url == url } + break if must_support_confirmed[id] + end + skip "Could not find #{id} in any of the #{@patient_ary.length} provided Patient resource(s)" unless must_support_confirmed[id] + end + + must_support_elements = [ + 'Patient.identifier', + 'Patient.identifier.system', + 'Patient.identifier.value', + 'Patient.name', + 'Patient.name.family', + 'Patient.name.given', + 'Patient.telecom', + 'Patient.telecom.system', + 'Patient.telecom.value', + 'Patient.gender', + 'Patient.birthDate', + 'Patient.address', + 'Patient.address.line', + 'Patient.address.city', + 'Patient.address.state', + 'Patient.address.postalCode', + 'Patient.communication', + 'Patient.communication.language' + ] + must_support_elements.each do |path| + @patient_ary&.each do |resource| + truncated_path = path.gsub('Patient.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @patient_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Patient resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '14' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_practitioner_sequence.rb b/lib/app/modules/us_core_r4/us_core_practitioner_sequence.rb index 5fb4e3a3a..cba4f623a 100644 --- a/lib/app/modules/us_core_r4/us_core_practitioner_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_practitioner_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4PractitionerSequence < SequenceBase + class USCoreR4PractitionerSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Practitioner Tests' @@ -18,16 +18,19 @@ def validate_resource_item(resource, property, value) case property when 'name' - found = resource&.name&.any? do |name| - name.text&.include?(value) || - name.family.include?(value) || - name.given.any { |given| given&.include?(value) } || - name.prefix.any { |prefix| prefix.include?(value) } || - name.suffix.any { |suffix| suffix.include?(value) } + value = value.downcase + value_found = can_resolve_path(resource, 'name') do |name| + name&.text&.start_with?(value) || + name&.family&.downcase&.include?(value) || + name&.given&.any? { |given| given.downcase.start_with?(value) } || + name&.prefix&.any? { |prefix| prefix.downcase.start_with?(value) } || + name&.suffix&.any? { |suffix| suffix.downcase.start_with?(value) } end - assert found, 'name on resource does not match name requested' + assert value_found, 'name on resource does not match name requested' + when 'identifier' - assert resource&.identifier&.any? { |identifier| identifier.value == value }, 'identifier on resource did not match identifier requested' + value_found = can_resolve_path(resource, 'identifier.value') { |value_in_resource| value_in_resource == value } + assert value_found, 'identifier on resource does not match identifier requested' end end @@ -53,7 +56,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Practitioner'), patient: @instance.patient_id) + name_val = @practitioner&.name&.first&.family + search_params = { 'name': name_val } + + reply = get_resource_by_params(versioned_resource_class('Practitioner'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -67,21 +73,23 @@ def validate_resource_item(resource, property, value) versions :r4 end - name_val = @practitioner&.name&.first&.family + name_val = resolve_element_from_path(@practitioner, 'name.family') search_params = { 'name': name_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Practitioner'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @practitioner = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Practitioner'), reply, search_params) + @practitioner_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Practitioner'), reply) + validate_search_reply(versioned_resource_class('Practitioner'), reply, search_params) end test 'Server returns expected results from Practitioner search by identifier' do @@ -96,10 +104,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@practitioner.nil?, 'Expected valid Practitioner resource to be present' - identifier_val = @practitioner&.identifier&.first&.value + identifier_val = resolve_element_from_path(@practitioner, 'identifier.value') search_params = { 'identifier': identifier_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Practitioner'), search_params) + validate_search_reply(versioned_resource_class('Practitioner'), reply, search_params) assert_response_ok(reply) end @@ -148,7 +158,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@practitioner, versioned_resource_class('Practitioner')) end - test 'Practitioner resources associated with Patient conform to Argonaut profiles' do + test 'Practitioner resources associated with Patient conform to US Core R4 profiles' do metadata do id '07' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-practitioner.json' @@ -161,9 +171,42 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Practitioner') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Practitioner for this patient.' do metadata do id '08' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @practitioner_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Practitioner.identifier', + 'Practitioner.identifier.system', + 'Practitioner.identifier.value', + 'Practitioner.identifier', + 'Practitioner.identifier.system', + 'Practitioner.name', + 'Practitioner.name.family' + ] + must_support_elements.each do |path| + @practitioner_ary&.each do |resource| + truncated_path = path.gsub('Practitioner.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @practitioner_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Practitioner resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '09' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_practitionerrole_sequence.rb b/lib/app/modules/us_core_r4/us_core_practitionerrole_sequence.rb index c9f925b8b..0d38b973f 100644 --- a/lib/app/modules/us_core_r4/us_core_practitionerrole_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_practitionerrole_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4PractitionerroleSequence < SequenceBase + class USCoreR4PractitionerroleSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Practitionerrole Tests' + title 'PractitionerRole Tests' description 'Verify that PractitionerRole resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,12 +18,12 @@ def validate_resource_item(resource, property, value) case property when 'specialty' - codings = resource&.specialty&.coding - assert !codings.nil?, 'specialty on resource did not match specialty requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'specialty on resource did not match specialty requested' + value_found = can_resolve_path(resource, 'specialty.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'specialty on resource does not match specialty requested' when 'practitioner' - assert resource&.practitioner&.reference&.include?(value), 'practitioner on resource does not match practitioner requested' + value_found = can_resolve_path(resource, 'practitioner.reference') { |value_in_resource| value_in_resource == value } + assert value_found, 'practitioner on resource does not match practitioner requested' end end @@ -49,7 +49,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('PractitionerRole'), patient: @instance.patient_id) + specialty_val = @practitionerrole&.specialty&.coding&.first&.code + search_params = { 'specialty': specialty_val } + + reply = get_resource_by_params(versioned_resource_class('PractitionerRole'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -63,21 +66,23 @@ def validate_resource_item(resource, property, value) versions :r4 end - specialty_val = @practitionerrole&.specialty&.coding&.first&.code + specialty_val = resolve_element_from_path(@practitionerrole, 'specialty.coding.code') search_params = { 'specialty': specialty_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('PractitionerRole'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @practitionerrole = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('PractitionerRole'), reply, search_params) + @practitionerrole_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('PractitionerRole'), reply) + validate_search_reply(versioned_resource_class('PractitionerRole'), reply, search_params) end test 'Server returns expected results from PractitionerRole search by practitioner' do @@ -92,10 +97,12 @@ def validate_resource_item(resource, property, value) skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found assert !@practitionerrole.nil?, 'Expected valid PractitionerRole resource to be present' - practitioner_val = @practitionerrole&.practitioner&.reference&.first + practitioner_val = resolve_element_from_path(@practitionerrole, 'practitioner.reference') search_params = { 'practitioner': practitioner_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('PractitionerRole'), search_params) + validate_search_reply(versioned_resource_class('PractitionerRole'), reply, search_params) assert_response_ok(reply) end @@ -144,7 +151,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@practitionerrole, versioned_resource_class('PractitionerRole')) end - test 'PractitionerRole resources associated with Patient conform to Argonaut profiles' do + test 'PractitionerRole resources associated with Patient conform to US Core R4 profiles' do metadata do id '07' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-practitionerrole.json' @@ -157,9 +164,44 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('PractitionerRole') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any PractitionerRole for this patient.' do metadata do id '08' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @practitionerrole_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'PractitionerRole.practitioner', + 'PractitionerRole.organization', + 'PractitionerRole.code', + 'PractitionerRole.specialty', + 'PractitionerRole.location', + 'PractitionerRole.telecom', + 'PractitionerRole.telecom.system', + 'PractitionerRole.telecom.value', + 'PractitionerRole.endpoint' + ] + must_support_elements.each do |path| + @practitionerrole_ary&.each do |resource| + truncated_path = path.gsub('PractitionerRole.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @practitionerrole_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided PractitionerRole resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '09' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_procedure_sequence.rb b/lib/app/modules/us_core_r4/us_core_procedure_sequence.rb index 2cc6d1472..732d469d7 100644 --- a/lib/app/modules/us_core_r4/us_core_procedure_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_procedure_sequence.rb @@ -2,7 +2,7 @@ module Inferno module Sequence - class UsCoreR4ProcedureSequence < SequenceBase + class USCoreR4ProcedureSequence < SequenceBase group 'US Core R4 Profile Conformance' title 'Procedure Tests' @@ -18,17 +18,22 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' when 'date' + value_found = can_resolve_path(resource, 'occurrenceDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' end end @@ -54,7 +59,10 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Procedure'), patient: @instance.patient_id) + patient_val = @instance.patient_id + search_params = { 'patient': patient_val } + + reply = get_resource_by_params(versioned_resource_class('Procedure'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -70,19 +78,21 @@ def validate_resource_item(resource, property, value) patient_val = @instance.patient_id search_params = { 'patient': patient_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Procedure'), search_params) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @procedure = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Procedure'), reply, search_params) + @procedure_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Procedure'), reply) + validate_search_reply(versioned_resource_class('Procedure'), reply, search_params) end test 'Server returns expected results from Procedure search by patient+date' do @@ -98,11 +108,21 @@ def validate_resource_item(resource, property, value) assert !@procedure.nil?, 'Expected valid Procedure resource to be present' patient_val = @instance.patient_id - date_val = @procedure&.occurrenceDateTime + date_val = resolve_element_from_path(@procedure, 'occurrenceDateTime') search_params = { 'patient': patient_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Procedure'), search_params) + validate_search_reply(versioned_resource_class('Procedure'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Procedure'), comparator_search_params) + validate_search_reply(versioned_resource_class('Procedure'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Procedure search by patient+code+date' do @@ -118,12 +138,22 @@ def validate_resource_item(resource, property, value) assert !@procedure.nil?, 'Expected valid Procedure resource to be present' patient_val = @instance.patient_id - code_val = @procedure&.code&.coding&.first&.code - date_val = @procedure&.occurrenceDateTime + code_val = resolve_element_from_path(@procedure, 'code.coding.code') + date_val = resolve_element_from_path(@procedure, 'occurrenceDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Procedure'), search_params) + validate_search_reply(versioned_resource_class('Procedure'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Procedure'), comparator_search_params) + validate_search_reply(versioned_resource_class('Procedure'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Procedure search by patient+status' do @@ -139,10 +169,12 @@ def validate_resource_item(resource, property, value) assert !@procedure.nil?, 'Expected valid Procedure resource to be present' patient_val = @instance.patient_id - status_val = @procedure&.status + status_val = resolve_element_from_path(@procedure, 'status') search_params = { 'patient': patient_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Procedure'), search_params) + validate_search_reply(versioned_resource_class('Procedure'), reply, search_params) assert_response_ok(reply) end @@ -191,7 +223,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@procedure, versioned_resource_class('Procedure')) end - test 'Procedure resources associated with Patient conform to Argonaut profiles' do + test 'Procedure resources associated with Patient conform to US Core R4 profiles' do metadata do id '09' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-procedure.json' @@ -204,9 +236,40 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Procedure') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Procedure for this patient.' do metadata do id '10' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @procedure_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Procedure.status', + 'Procedure.code', + 'Procedure.subject', + 'Procedure.performedDateTime', + 'Procedure.performedPeriod' + ] + must_support_elements.each do |path| + @procedure_ary&.each do |resource| + truncated_path = path.gsub('Procedure.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @procedure_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Procedure resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '11' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/modules/us_core_r4/us_core_r4_capability_statement_sequence.rb b/lib/app/modules/us_core_r4/us_core_r4_capability_statement_sequence.rb index 4c9482969..c9b100d80 100644 --- a/lib/app/modules/us_core_r4/us_core_r4_capability_statement_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_r4_capability_statement_sequence.rb @@ -59,7 +59,7 @@ class UsCoreR4CapabilityStatementSequence < CapabilityStatementSequence test 'FHIR server capability states JSON support' do metadata do - id '03' + id '04' link 'http://hl7.org/fhir/us/core/2019Jan/CapabilityStatement-us-core-server.html' desc %( @@ -93,7 +93,7 @@ class UsCoreR4CapabilityStatementSequence < CapabilityStatementSequence test 'Capability Statement describes SMART on FHIR core capabilities' do metadata do - id '04' + id '05' link 'http://www.hl7.org/fhir/smart-app-launch/conformance/' optional desc %( @@ -128,7 +128,7 @@ class UsCoreR4CapabilityStatementSequence < CapabilityStatementSequence test 'Capability Statement lists supported Argonaut profiles, operations and search parameters' do metadata do - id '05' + id '06' link 'http://hl7.org/fhir/us/core/2019Jan/CapabilityStatement-us-core-server.html' desc %( The Argonaut Data Query Implementation Guide states: diff --git a/lib/app/modules/us_core_r4/us_core_r4_patient_sequence.rb b/lib/app/modules/us_core_r4/us_core_r4_patient_sequence.rb deleted file mode 100644 index 4efd7e51d..000000000 --- a/lib/app/modules/us_core_r4/us_core_r4_patient_sequence.rb +++ /dev/null @@ -1,294 +0,0 @@ -# frozen_string_literal: true - -module Inferno - module Sequence - class USCoreR4PatientSequence < SequenceBase - title 'US Core R4 Patient Tests' - - description 'Verify that the Patient resources on the FHIR server follow the US Core R4 Implementation Guide' - - test_id_prefix 'R4P' - - requires :token, :patient_id - - # TODO: Should this change to capability_supports? CapabilityStatement is Normative after all - conformance_supports :Patient - - details %( - - Patient profile requirements from [US Core R4 Server Capability Statement](http://build.fhir.org/ig/HL7/US-Core-R4/CapabilityStatement-us-core-r4-server.html#patient). - - Search requirements (as of 1 May 19): - - | Conformance | Parameter | Type | - |-------------|-------------------|----------------| - | SHALL | name | string | - | SHALL | identifier | token | - | SHALL | family + gender | string + token | - | SHALL | given + gender | string + token | - | SHALL | name + gender | string + token | - | SHALL | name + birthdate | string + date | - - Note: Terminology validation currently disabled. - - ) - - def validate_resource_item(resource, property, value) - case property - when 'name' - names = resource&.name - assert !names.nil? && !names.empty?, 'No names found in patient resource' - assert names.any? { |name| name&.family&.include?(value) }, "Family name on resource did not match name search parameter (#{value})." - when 'identifier' - identifier = resource&.identifier&.first - - if value.include?('|') - # Using the | format - id_system = value.split('|').first - id_value = value.split('|')[1] - - assert identifier&.value == id_value, "Identifier value on resource (#{identifier&.value}) did not match search parameter (#{id_value})" - assert identifier&.system == id_system, "Identifier system on resource (#{identifier&.system}) did not match search parameter (#{id_system})" - - else - assert identifier&.value == value, "Identifier value on resource (#{identifier&.value}) did not match search parameter (#{value})" - end - - when 'family' - names = resource&.name - assert !names.nil? && !names.empty?, 'No names found in patient resource' - assert names.any? { |name| name.family.include?(value) }, "No family names in resource matched family name search parameter (#{value})." - when 'given' - names = resource&.name - assert !names.nil? && !names.empty?, 'No names found in patient resource' - assert names.any? { |name| name.given.include?(value) }, "Given name on resource did not match given name search parameter (#{value})." - when 'birthdate' - birthdate = resource&.birthDate - assert !birthdate.nil? && birthdate == value, "Birthdate on resource did not match birthdate search parameter (#{value})." - when 'gender' - gender = resource&.gender - assert !gender.nil? && gender == value, "Gender on resource did not match gender search parameter (#{value})." - end - end - - test 'Server supports fetching a patient using a read' do - metadata do - id '01' - link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - Servers return a patient resource - - ` GET [base]/Patient/[id]` - ) - versions :r4 - end - - @client.set_no_auth - @client.set_bearer_token(@instance.token) - reply = @client.read(versioned_resource_class('Patient'), @instance.patient_id) - assert_response_ok reply - @patient = reply.resource - assert !@patient.nil? - assert @patient.is_a?(versioned_resource_class('Patient')), 'Expected resource to be valid Patient' - assert @patient.is_a?(FHIR::Patient), 'Not the right fhir model type' - end - - # PROFILE CHECKING # - - test 'Patient validates against US Core R4 Profile' do - metadata do - id '02' - link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - Validating the returned Patient against the US Core R4 Patient Profile - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - assert @patient.is_a?(versioned_resource_class('Patient')), 'Expected resource to be valid Patient' - assert (@instance.fhir_version.to_sym == :r4), 'Expected Version to be R4' - profile = Inferno::ValidationUtil.guess_profile(@patient, @instance.fhir_version.to_sym) - assert (profile.title == '**UPDATED** US Core Patient Profile **UPDATED**'), 'Expected correct profile' - assert profile.is_a?(FHIR::StructureDefinition), 'Expecetd R4 Structure Defintion' - errors = profile.validate_resource(@patient) - assert errors.empty?, "Patient did not validate against profile: #{errors.join(', ')}" - end - - test 'All references within patient can be resolved' do - metadata do - id '03' - link 'https://www.hl7.org/fhir/DSTU2/references.html' - desc %( - All references in the Patient resource should be resolveable. - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - - validate_reference_resolutions(@patient) - end - - # SEARCHING - - test 'Server returns expected results from Patient search by name' do - metadata do - id '04' - link 'http://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - A server has exposed a FHIR Patient search endpoint supporting at a minimum the following search parameters: name. - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - family = @patient&.name&.first&.family - assert !family.nil?, 'Patient family name not returned' - search_params = { name: family } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - end - - test 'Server returns expected results from Patient search by identifier' do - metadata do - id '05' - link 'http://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - A server has exposed a FHIR Patient search endpoint supporting at a minimum the following search parameters: identifier. - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - identifier = @patient&.identifier&.first - assert !identifier.nil?, 'Patient identifier not returned' - assert !identifier.value.nil?, 'No value provided in Patient identifier' - search_params = { identifier: identifier.value } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - - assert !identifier.system.nil?, 'No system provided in Patient identifier' - search_params = { identifier: "#{identifier.system}|#{identifier.value}" } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - - search_params = { _id: @patient.id } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - end - - test 'Server returns expected results from Patient search by family + gender' do - metadata do - id '06' - link 'http://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - family = @patient&.name&.first&.family - assert !family.nil?, 'Patient family name not returned' - given = @patient&.name&.first&.given&.first - assert !given.nil?, 'Patient given name not returned' - gender = @patient&.gender - assert !gender.nil?, 'Patient gender not returned' - search_params = { family: family, gender: gender } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - end - - test 'Server returns expected results from Patient search by given + gender' do - metadata do - id '07' - link 'http://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - family = @patient&.name&.first&.family - assert !family.nil?, 'Patient family name not returned' - given = @patient&.name&.first&.given&.first - assert !given.nil?, 'Patient given name not returned' - gender = @patient&.gender - assert !gender.nil?, 'Patient gender not returned' - search_params = { given: given, gender: gender } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - end - - test 'Server returns expected results from Patient search by name + gender' do - metadata do - id '08' - link 'http://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - family = @patient&.name&.first&.family - assert !family.nil?, 'Patient family name not returned' - given = @patient&.name&.first&.given&.first - assert !given.nil?, 'Patient given name not returned' - gender = @patient&.gender - assert !gender.nil?, 'Patient gender not returned' - search_params = { name: family, gender: gender } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - end - - test 'Server returns expected results from Patient search by name + birthdate' do - metadata do - id '09' - link 'http://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-patient.html' - desc %( - A server has exposed a FHIR Patient search endpoint supporting at a minimum the following search parameters when at least 2 (example name and gender) are present: name, gender, birthdate. - ) - versions :r4 - end - - assert !@patient.nil?, 'Expected valid Patient resource to be present' - family = @patient&.name&.first&.family - assert !family.nil?, 'Patient family name not returned' - given = @patient&.name&.first&.given&.first - assert !given.nil?, 'Patient given name not returned' - birthdate = @patient&.birthDate - assert !birthdate.nil?, 'Patient birthDate not returned' - search_params = { name: family, birthdate: birthdate } - reply = get_resource_by_params(versioned_resource_class('Patient'), search_params) - validate_search_reply(versioned_resource_class('Patient'), reply, search_params) - end - - test 'Server returns expected results from Patient history resource' do - metadata do - id '10' - link 'http://www.fhir.org/guides/argonaut/r2/Conformance-server.html' - optional - desc %( - All servers SHOULD make available the vread and history-instance interactions for the Argonaut Profiles the server chooses to support. ) - versions :r4 - end - - validate_history_reply(@patient, versioned_resource_class('Patient')) - end - - test 'Server returns expected results from Patient vread resource' do - metadata do - id '11' - link 'http://www.fhir.org/guides/argonaut/r2/Conformance-server.html' - optional - desc %( - All servers SHOULD make available the vread and history-instance interactions for the Argonaut Profiles the server chooses to support. - ) - versions :r4 - end - - validate_vread_reply(@patient, versioned_resource_class('Patient')) - end - end - end -end diff --git a/lib/app/modules/us_core_r4/us_core_smokingstatus_sequence.rb b/lib/app/modules/us_core_r4/us_core_smokingstatus_sequence.rb index 2016235cb..f629acdee 100644 --- a/lib/app/modules/us_core_r4/us_core_smokingstatus_sequence.rb +++ b/lib/app/modules/us_core_r4/us_core_smokingstatus_sequence.rb @@ -2,10 +2,10 @@ module Inferno module Sequence - class UsCoreR4SmokingstatusSequence < SequenceBase + class USCoreR4SmokingstatusSequence < SequenceBase group 'US Core R4 Profile Conformance' - title 'Smokingstatus Tests' + title 'Smoking Status Observation Tests' description 'Verify that Observation resources on the FHIR server follow the Argonaut Data Query Implementation Guide' @@ -18,22 +18,26 @@ def validate_resource_item(resource, property, value) case property when 'status' - assert resource&.status == value, 'status on resource did not match status requested' + value_found = can_resolve_path(resource, 'status') { |value_in_resource| value_in_resource == value } + assert value_found, 'status on resource does not match status requested' when 'category' - codings = resource&.category&.first&.coding - assert !codings.nil?, 'category on resource did not match category requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'category on resource did not match category requested' + value_found = can_resolve_path(resource, 'category.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'category on resource does not match category requested' when 'code' - codings = resource&.code&.coding - assert !codings.nil?, 'code on resource did not match code requested' - assert codings.any? { |coding| !coding.try(:code).nil? && coding.try(:code) == value }, 'code on resource did not match code requested' + value_found = can_resolve_path(resource, 'code.coding.code') { |value_in_resource| value_in_resource == value } + assert value_found, 'code on resource does not match code requested' when 'date' + value_found = can_resolve_path(resource, 'effectiveDateTime') do |date| + validate_date_search(value, date) + end + assert value_found, 'date on resource does not match date requested' when 'patient' - assert resource&.subject&.reference&.include?(value), 'patient on resource does not match patient requested' + value_found = can_resolve_path(resource, 'subject.reference') { |reference| [value, 'Patient/' + value].include? reference } + assert value_found, 'patient on resource does not match patient requested' end end @@ -59,7 +63,9 @@ def validate_resource_item(resource, property, value) @client.set_no_auth skip 'Could not verify this functionality when bearer token is not set' if @instance.token.blank? - reply = get_resource_by_params(versioned_resource_class('Observation'), patient: @instance.patient_id) + search_params = { patient: @instance.patient_id, code: '72166-2' } + + reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) @client.set_bearer_token(@instance.token) assert_response_unauthorized reply end @@ -79,14 +85,15 @@ def validate_resource_item(resource, property, value) assert_response_ok(reply) assert_bundle_response(reply) - resource_count = reply.try(:resource).try(:entry).try(:length) || 0 + resource_count = reply&.resource&.entry&.length || 0 @resources_found = true if resource_count.positive? skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found @observation = reply.try(:resource).try(:entry).try(:first).try(:resource) - validate_search_reply(versioned_resource_class('Observation'), reply, search_params) + @observation_ary = reply&.resource&.entry&.map { |entry| entry&.resource } save_resource_ids_in_bundle(versioned_resource_class('Observation'), reply) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) end test 'Server returns expected results from Observation search by patient+category' do @@ -102,10 +109,12 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code + category_val = resolve_element_from_path(@observation, 'category.coding.code') search_params = { 'patient': patient_val, 'category': category_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -122,12 +131,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - date_val = @observation&.effectiveDateTime + category_val = resolve_element_from_path(@observation, 'category.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'category': category_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'category': category_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+code+date' do @@ -143,12 +162,22 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - code_val = @observation&.code&.coding&.first&.code - date_val = @observation&.effectiveDateTime + code_val = resolve_element_from_path(@observation, 'code.coding.code') + date_val = resolve_element_from_path(@observation, 'effectiveDateTime') search_params = { 'patient': patient_val, 'code': code_val, 'date': date_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) + + ['gt', 'lt', 'le'].each do |comparator| + comparator_val = date_comparator_value(comparator, date_val) + comparator_search_params = { 'patient': patient_val, 'code': code_val, 'date': comparator_val } + reply = get_resource_by_params(versioned_resource_class('Observation'), comparator_search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, comparator_search_params) + assert_response_ok(reply) + end end test 'Server returns expected results from Observation search by patient+category+status' do @@ -164,11 +193,13 @@ def validate_resource_item(resource, property, value) assert !@observation.nil?, 'Expected valid Observation resource to be present' patient_val = @instance.patient_id - category_val = @observation&.category&.first&.coding&.first&.code - status_val = @observation&.status + category_val = resolve_element_from_path(@observation, 'category.coding.code') + status_val = resolve_element_from_path(@observation, 'status') search_params = { 'patient': patient_val, 'category': category_val, 'status': status_val } + search_params.each { |param, value| skip "Could not resolve #{param} in given resource" if value.nil? } reply = get_resource_by_params(versioned_resource_class('Observation'), search_params) + validate_search_reply(versioned_resource_class('Observation'), reply, search_params) assert_response_ok(reply) end @@ -217,7 +248,7 @@ def validate_resource_item(resource, property, value) validate_history_reply(@observation, versioned_resource_class('Observation')) end - test 'Observation resources associated with Patient conform to Argonaut profiles' do + test 'Observation resources associated with Patient conform to US Core R4 profiles' do metadata do id '10' link 'https://build.fhir.org/ig/HL7/US-Core-R4/StructureDefinition-us-core-smokingstatus.json' @@ -230,9 +261,40 @@ def validate_resource_item(resource, property, value) test_resources_against_profile('Observation') end - test 'All references can be resolved' do + test 'At least one of every must support element is provided in any Observation for this patient.' do metadata do id '11' + link 'https://build.fhir.org/ig/HL7/US-Core-R4/general-guidance.html/#must-support' + desc %( + ) + versions :r4 + end + + skip 'No resources appear to be available for this patient. Please use patients with more information' unless @observation_ary&.any? + must_support_confirmed = {} + must_support_elements = [ + 'Observation.status', + 'Observation.code', + 'Observation.subject', + 'Observation.issued', + 'Observation.valueCodeableConcept' + ] + must_support_elements.each do |path| + @observation_ary&.each do |resource| + truncated_path = path.gsub('Observation.', '') + must_support_confirmed[path] = true if can_resolve_path(resource, truncated_path) + break if must_support_confirmed[path] + end + resource_count = @observation_ary.length + + skip "Could not find #{path} in any of the #{resource_count} provided Observation resource(s)" unless must_support_confirmed[path] + end + @instance.save! + end + + test 'All references can be resolved' do + metadata do + id '12' link 'https://www.hl7.org/fhir/DSTU2/references.html' desc %( ) diff --git a/lib/app/sequence_base.rb b/lib/app/sequence_base.rb index dc84c269f..d15bf9bb0 100644 --- a/lib/app/sequence_base.rb +++ b/lib/app/sequence_base.rb @@ -9,6 +9,8 @@ require_relative 'utils/walk' require_relative 'utils/web_driver' require_relative 'utils/terminology' +require_relative 'utils/result_statuses' +require_relative 'utils/search_validation' require 'bloomer' require 'bloomer/msgpackable' @@ -21,17 +23,9 @@ module Sequence class SequenceBase include Assertions include SkipHelpers + include SearchValidationUtil include Inferno::WebDriver - STATUS = { - pass: 'pass', - fail: 'fail', - error: 'error', - todo: 'todo', - wait: 'wait', - skip: 'skip' - }.freeze - @@test_index = 0 @@group = {} @@ -43,13 +37,21 @@ class SequenceBase @@conformance_supports = {} @@defines = {} @@versions = {} - @@test_metadata = {} + @@test_metadata = Hash.new { |hash, key| hash[key] = [] } @@optional = [] @@show_uris = [] @@test_id_prefixes = {} + attr_accessor :profiles_encountered + attr_accessor :profiles_failed + attr_accessor :sequence_result + + delegate :versioned_resource_class, to: :@client + delegate :versioned_conformance_class, to: :@instance + delegate :save_resource_ids_in_bundle, to: :@instance + def initialize(instance, client, disable_tls_tests = false, sequence_result = nil, metadata_only = false) @client = client @instance = instance @@ -64,15 +66,15 @@ def initialize(instance, client, disable_tls_tests = false, sequence_result = ni def resume(request = nil, headers = nil, params = nil, fail_message = nil, &block) @params = params unless params.nil? - @sequence_result.test_results.last.pass! + sequence_result.test_results.last.pass! if fail_message.present? - @sequence_result.test_results.last.result = STATUS[:fail] - @sequence_result.test_results.last.message = fail_message + sequence_result.test_results.last.fail! + sequence_result.test_results.last.message = fail_message end unless request.nil? - @sequence_result.test_results.last.request_responses << Models::RequestResponse.new( + sequence_result.test_results.last.request_responses << Models::RequestResponse.new( direction: 'inbound', request_method: request.request_method.downcase, request_url: request.url, @@ -82,30 +84,30 @@ def resume(request = nil, headers = nil, params = nil, fail_message = nil, &bloc ) end - @sequence_result.pass! - @sequence_result.wait_at_endpoint = nil - @sequence_result.redirect_to_url = nil + sequence_result.pass! + sequence_result.wait_at_endpoint = nil + sequence_result.redirect_to_url = nil - @sequence_result.save! + sequence_result.save! start(&block) end def start(test_set_id = nil, test_case_id = nil, &block) - if @sequence_result.nil? - @sequence_result = Models::SequenceResult.new( + if sequence_result.nil? + self.sequence_result = Models::SequenceResult.new( name: sequence_name, - result: STATUS[:pass], + result: ResultStatuses::PASS, testing_instance: @instance, required: !optional?, test_set_id: test_set_id, test_case_id: test_case_id, app_version: VERSION ) - @sequence_result.save! + sequence_result.save! end - start_at = @sequence_result.test_results.length + start_at = sequence_result.result_count load_input_params(sequence_name) @@ -116,14 +118,15 @@ def start(test_set_id = nil, test_case_id = nil, &block) run_tests(methods, &block) update_output(sequence_name, output_results) - @sequence_result.output_results = output_results.to_json if output_results.present? - @sequence_result.reset! - @sequence_result.pass! + sequence_result.tap do |result| + result.output_results = output_results.to_json if output_results.present? - update_result_counts + result.reset! + result.pass! - @sequence_result + result.update_result_counts + end end def load_input_params(sequence_name) @@ -135,7 +138,7 @@ def load_input_params(sequence_name) input_value = 'none' if input_value.empty? input_parameters[requirement.to_sym] = input_value end - @sequence_result.input_params = input_parameters.to_json + sequence_result.input_params = input_parameters.to_json end def save_output(sequence_name) @@ -196,52 +199,16 @@ def run_tests(methods) yield result if block_given? - @sequence_result.test_results << result + sequence_result.test_results << result next unless result.wait? - @sequence_result.redirect_to_url = result.redirect_to_url - @sequence_result.wait_at_endpoint = result.wait_at_endpoint + sequence_result.redirect_to_url = result.redirect_to_url + sequence_result.wait_at_endpoint = result.wait_at_endpoint break end end - def update_result_counts - @sequence_result.test_results.each do |result| - if result.required - @sequence_result.required_total += 1 - else - @sequence_result.optional_total += 1 - end - case result.result - when STATUS[:pass] - if result.required - @sequence_result.required_passed += 1 - else - @sequence_result.optional_passed += 1 - end - when STATUS[:todo] - @sequence_result.todo_count += 1 - when STATUS[:fail] - if result.required - @sequence_result.result = result.result unless @sequence_result.error? - end - when STATUS[:error] - if result.required - @sequence_result.error_count += 1 - @sequence_result.result = result.result - end - when STATUS[:skip] - if result.required - @sequence_result.skip_count += 1 - @sequence_result.result = result.result if @sequence_result.pass? - end - when STATUS[:wait] - @sequence_result.result = result.result - end - end - end - def self.test_count new(nil, nil).test_count end @@ -263,8 +230,6 @@ def self.sequence_name name.demodulize end - attr_reader :sequence_result - def self.title(title = nil) @@titles[sequence_name] = title unless title.nil? @@titles[sequence_name] || sequence_name @@ -336,7 +301,7 @@ def self.test_id_prefix(test_id_prefix = nil) end def self.tests - @@test_metadata[sequence_name] || [] + @@test_metadata[sequence_name] end def optional? @@ -380,7 +345,6 @@ def self.preconditions_met_for?(instance) # this must be called to ensure that the child class is referenced in self.sequence_name def self.extends_sequence(klass) @@test_metadata[klass.sequence_name].each do |metadata| - @@test_metadata[sequence_name] ||= [] @@test_metadata[sequence_name] << metadata @@test_metadata[sequence_name].last[:test_index] = @@test_metadata[sequence_name].length - 1 define_method metadata[:method_name], metadata[:method] @@ -397,13 +361,12 @@ def self.test(name, &block) test_index = @@test_index test_method = "#{@@test_index.to_s.rjust(4, '0')} #{name} test".downcase.tr(' ', '_').to_sym - @@test_metadata[sequence_name] ||= [] @@test_metadata[sequence_name] << { name: name, test_index: test_index, required: true, versions: FHIR::VERSIONS } - test_index_in_sequence = @@test_metadata[sequence_name].length - 1 + current_test = @@test_metadata[sequence_name].last wrapped = lambda do instance_eval(&block) if @metadata_only # just run the test to hit the metadata block @@ -412,19 +375,23 @@ def self.test(name, &block) @links = [] @requires = [] @validates = [] - result = Models::TestResult.new(test_id: @@test_metadata[sequence_name][test_index_in_sequence][:test_id], - name: name, - ref: @@test_metadata[sequence_name][test_index_in_sequence][:ref], - required: @@test_metadata[sequence_name][test_index_in_sequence][:required], - description: @@test_metadata[sequence_name][test_index_in_sequence][:description], - url: @@test_metadata[sequence_name][test_index_in_sequence][:url], - versions: @@test_metadata[sequence_name][test_index_in_sequence][:versions].join(','), - result: STATUS[:pass], - test_index: test_index) + test_id = current_test[:test_id] + versions = current_test[:versions] + result = Models::TestResult.new( + test_id: test_id, + name: name, + ref: current_test[:ref], + required: current_test[:required], + description: current_test[:description], + url: current_test[:url], + versions: versions.join(','), + result: ResultStatuses::PASS, + test_index: test_index + ) begin - fhir_version_included = @@test_metadata[sequence_name][test_index_in_sequence][:versions].include? @instance.fhir_version&.to_sym - skip_unless(fhir_version_included, 'This test does not run with this FHIR version') unless @instance.fhir_version.nil? - Inferno.logger.info "Starting Test: #{@@test_metadata[sequence_name][test_index_in_sequence][:test_id]} [#{name}]" + fhir_version_included = @instance.fhir_version.present? && versions.include?(@instance.fhir_version&.to_sym) + skip_unless(fhir_version_included, 'This test does not run with this FHIR version') + Inferno.logger.info "Starting Test: #{test_id} [#{name}]" instance_eval(&block) rescue AssertionException, ClientException => e result.fail! @@ -453,15 +420,15 @@ def self.test(name, &block) result.error! result.message = "Fatal Error: #{e.message}" end - result.test_warnings = @test_warnings.map { |w| Models::TestWarning.new(message: w) } unless @test_warnings.empty? - Inferno.logger.info "Finished Test: #{@@test_metadata[sequence_name][test_index_in_sequence][:test_id]} [#{result.result}]" + result.test_warnings = @test_warnings.map { |w| Models::TestWarning.new(message: w) } + Inferno.logger.info "Finished Test: #{test_id} [#{result.result}]" result end define_method test_method, wrapped - @@test_metadata[sequence_name][test_index_in_sequence][:method] = wrapped - @@test_metadata[sequence_name][test_index_in_sequence][:method_name] = test_method + current_test[:method] = wrapped + current_test[:method_name] = test_method instance = new(nil, nil, nil, nil, true) begin @@ -518,6 +485,10 @@ def skip_unless(test, message = '', details = nil) raise SkipException.new message, details unless test end + def skip_if(test, message = '', details = nil) + raise SkipException.new message, details if test + end + def wait_at_endpoint(endpoint) raise WaitException, endpoint end @@ -544,12 +515,8 @@ def get_resource_by_params(klass, params = {}) @client.search(klass, options) end - def versioned_resource_class(klass) - @client.versioned_resource_class klass - end - - def check_sort_order(entries) - relevant_entries = entries.reject { |x| x.request.try(:local_method) == 'DELETE' } + def validate_sort_order(entries) + relevant_entries = entries.reject { |entry| entry.request&.local_method == 'DELETE' } begin relevant_entries.map!(&:resource).map!(&:meta).compact rescue StandardError @@ -557,13 +524,16 @@ def check_sort_order(entries) end relevant_entries.each_cons(2) do |left, right| - if !left.versionId.nil? && !right.versionId.nil? - assert (left.versionId > right.versionId), 'Result contains entries in the wrong order.' - elsif !left.lastUpdated.nil? && !right.lastUpdated.nil? - assert (left.lastUpdated >= right.lastUpdated), 'Result contains entries in the wrong order.' - else - raise AssertionException, 'Unable to determine if entries are in the correct order -- no meta.versionId or meta.lastUpdated' - end + left_version, right_version = + if left.versionId.present? && right.versionId.present? + [left.versionId, right.versionId] + elsif left.lastUpdated.present? && right.lastUpdated.present? + [left.lastUpdated, right.lastUpdated] + else + raise AssertionException, 'Unable to determine if entries are in the correct order -- no meta.versionId or meta.lastUpdated' + end + + assert (left_version > right_version), 'Result contains entries in the wrong order.' end end @@ -576,10 +546,10 @@ def validate_search_reply(klass, reply, search_params) assert_bundle_response(reply) entries = reply.resource.entry.select { |entry| entry.resource.class == klass } - assert !entries.empty?, 'No resources of this type were returned' + assert entries.present?, 'No resources of this type were returned' if klass == versioned_resource_class('Patient') - assert !reply.resource.get_by_id(@instance.patient_id).nil?, 'Server returned nil patient' + assert reply.resource.get_by_id(@instance.patient_id).present?, 'Server returned nil patient' assert reply.resource.get_by_id(@instance.patient_id).equals?(@patient, ['_id', 'text', 'meta', 'lastUpdated']), 'Server returned wrong patient' end @@ -595,19 +565,8 @@ def validate_search_reply(klass, reply, search_params) end end - def save_resource_ids_in_bundle(klass, reply) - return if reply.try(:resource).try(:entry).nil? - - entries = reply.resource.entry.select { |entry| entry.resource.class == klass } - - entries.each do |entry| - @instance.post_resource_references(resource_type: klass.name.split(':').last, - resource_id: entry.resource.id) - end - end - def validate_read_reply(resource, klass) - assert !resource.nil?, "No #{klass.name.split(':').last} resources available from search." + assert !resource.nil?, "No #{klass.name.demodulize} resources available from search." if resource.is_a? FHIR::DSTU2::Reference read_response = resource.read else @@ -622,7 +581,7 @@ def validate_read_reply(resource, klass) end def validate_history_reply(resource, klass) - assert !resource.nil?, "No #{klass.name.split(':').last} resources available from search." + assert !resource.nil?, "No #{klass.name.demodulize} resources available from search." id = resource.try(:id) assert !id.nil?, "#{klass} id not returned" history_response = @client.resource_instance_history(klass, id) @@ -632,11 +591,11 @@ def validate_history_reply(resource, klass) entries = history_response.try(:resource).try(:entry) assert entries, 'No bundle entries returned' assert entries.try(:length).positive?, 'No resources of this type were returned' - check_sort_order entries + validate_sort_order entries end def validate_vread_reply(resource, klass) - assert !resource.nil?, "No #{klass.name.split(':').last} resources available from search." + assert !resource.nil?, "No #{klass.name.demodulize} resources available from search." id = resource.try(:id) assert !id.nil?, "#{klass} id not returned" version_id = resource.try(:meta).try(:versionId) @@ -647,49 +606,83 @@ def validate_vread_reply(resource, klass) assert vread_response.resource.is_a?(klass), "Expected resource to be valid #{klass}" end - attr_accessor :profiles_encountered - attr_accessor :profiles_failed - - def test_resources_against_profile(resource_type, specified_profile = nil) - @profiles_encountered ||= [] - @profiles_failed ||= {} - - all_errors = [] + def validate_resource(resource_type, resource, profile) + errors = profile.validate_resource(resource) + @test_warnings.concat(profile.warnings.reject(&:empty?)) + errors.map! { |e| "#{resource_type}/#{resource.id}: #{e}" } + @profiles_failed[profile.url].concat(errors) unless errors.empty? + errors + end - resources = @instance.resource_references.select { |r| r.resource_type == resource_type } - skip("Skip profile validation since no #{resource_type} resources found for Patient.") if resources.empty? + def fetch_resource(resource_type, resource_id) + response = @client.read(versioned_resource_class(resource_type), resource_id) + assert_response_ok response + resource = response.resource + assert resource.is_a?(versioned_resource_class(resource_type)), "Expected resource to be of type #{resource_type}" + resource + end - @instance.resource_references.select { |r| r.resource_type == resource_type }.map(&:resource_id).each do |resource_id| - resource_response = @client.read(versioned_resource_class(resource_type), resource_id) - assert_response_ok resource_response - resource = resource_response.resource - assert resource.is_a?(versioned_resource_class(resource_type)), "Expected resource to be of type #{resource_type}" + def test_resources(resource_type) + references = @instance.resource_references.all(resource_type: resource_type) + skip_if( + references.empty?, + "Skip profile validation since no #{resource_type} resources found for Patient." + ) + errors = references.map(&:resource_id).flat_map do |resource_id| + resource = fetch_resource(resource_type, resource_id) p = Inferno::ValidationUtil.guess_profile(resource, @instance.fhir_version.to_sym) - if specified_profile - warn { assert false, "No #{specified_profile} found for this Resource" } - next unless p.url == specified_profile - end if p @profiles_encountered << p.url - @profiles_encountered.uniq! - errors = p.validate_resource(resource) - @test_warnings.concat(p.warnings.reject(&:empty?)) - unless errors.empty? - errors.map! { |e| "#{resource_type}/#{resource_id}: #{e}" } - @profiles_failed[p.url] = [] unless @profiles_failed[p.url] - @profiles_failed[p.url].concat(errors) - end - all_errors.concat(errors) + validate_resource(resource_type, resource, p) else warn { assert false, 'No profiles found for this Resource' } - errors = resource.validate - all_errors.concat(errors.values) + resource.validate + end + end + # TODO + # bundle = client.next_bundle + assert(errors.empty?, errors.join("
\n")) + end + + def test_resources_against_profile(resource_type, specified_profile = nil) + @profiles_encountered ||= Set.new + @profiles_failed ||= Hash.new { |hash, key| hash[key] = [] } + + return test_resources(resource_type) if specified_profile.blank? + + profile = Inferno::ValidationUtil::DEFINITIONS[specified_profile] + skip_if( + profile.blank?, + "Skip profile validation since profile #{specified_profile} is unknown." + ) + + references = @instance.resource_references.all(profile: specified_profile) + resources = + if references.present? + references.map(&:resource_id).map do |resource_id| + fetch_resource(resource_type, resource_id) + end + else + @instance.resource_references + .all(resource_type: resource_type) + .map { |reference| fetch_resource(resource_type, reference.resource_id) } + .select { |resource| resource.meta&.profile&.include? specified_profile } end + + skip_if( + resources.blank?, + "Skip profile validation since no #{resource_type} resources conforming to the #{specified_profile} profile found for Patient." + ) + + @profiles_encountered << profile.url + + errors = resources.flat_map do |resource| + validate_resource(resource_type, resource, profile) end # TODO # bundle = client.next_bundle - assert(all_errors.empty?, all_errors.join("
\n")) + assert(errors.empty?, errors.join("
\n")) end def validate_reference_resolutions(resource) @@ -719,16 +712,6 @@ def validate_reference_resolutions(resource) assert(problems.empty?, problems.join("
\n")) end - def versioned_conformance_class - if @instance.fhir_version == 'dstu2' - FHIR::DSTU2::Conformance - elsif @instance.fhir_version == 'stu3' - FHIR::STU3::CapabilityStatement - else - FHIR::CapabilityStatement - end - end - def check_resource_against_profile(resource, resource_type, specified_profile = nil) assert resource.is_a?("FHIR::DSTU2::#{resource_type}".constantize), "Expected resource to be of type #{resource_type}" @@ -739,11 +722,9 @@ def check_resource_against_profile(resource, resource_type, specified_profile = end if p @profiles_encountered << p.url - @profiles_encountered.uniq! errors = p.validate_resource(resource) unless errors.empty? errors.map! { |e| "#{resource_type}/#{resource.id}: #{e}" } - @profiles_failed[p.url] = [] unless @profiles_failed[p.url] @profiles_failed[p.url].concat(errors) end else @@ -751,6 +732,55 @@ def check_resource_against_profile(resource, resource_type, specified_profile = end assert(errors.empty?, errors.join("
\n")) end + + def can_resolve_path(element, path) + if path.empty? + return false if element.nil? + + return Array.wrap(element).any? { |el| yield(el) } if block_given? + + return true + end + + path_ary = path.split('.') + el_as_array = Array.wrap(element) + cur_path_part = path_ary.shift.to_sym + return false if el_as_array.none? { |el| el.try(cur_path_part).present? } + + if block_given? + el_as_array.any? { |el| can_resolve_path(el.send(cur_path_part), path_ary.join('.')) { |value_found| yield(value_found) } } + else + el_as_array.any? { |el| can_resolve_path(el.send(cur_path_part), path_ary.join('.')) } + end + end + + def resolve_element_from_path(element, path) + el_as_array = Array.wrap(element) + return el_as_array&.first if path.empty? + + path_ary = path.split('.') + cur_path_part = path_ary.shift.to_sym + + found_subset = el_as_array.select { |el| el.try(cur_path_part).present? } + return nil if found_subset.empty? + + found_subset.each do |el| + el_found = resolve_element_from_path(el.send(cur_path_part), path_ary.join('.')) + return el_found unless el_found.nil? + end + nil + end + + def date_comparator_value(comparator, date) + case comparator + when 'lt', 'le' + comparator + (DateTime.xmlschema(date) + 1).xmlschema + when 'gt', 'ge' + comparator + (DateTime.xmlschema(date) - 1).xmlschema + else + '' + end + end end Dir.glob(File.join(__dir__, 'modules', '**', '*_sequence.rb')).each { |file| require file } diff --git a/lib/app/utils/assertions.rb b/lib/app/utils/assertions.rb index 75cf6a1ff..dcd67cea7 100644 --- a/lib/app/utils/assertions.rb +++ b/lib/app/utils/assertions.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require_relative 'assertions.rb' +require 'uri' + module Inferno module Assertions def assert(test, message = 'assertion failed, no message', data = '') @@ -260,5 +262,10 @@ def assert_deny_previous_tls(uri) ) end end + + def assert_valid_http_uri(uri, message = nil) + error_message = message || "\"#{uri}\" is not a valid URI" + assert (uri =~ /\A#{URI.regexp(['http', 'https'])}\z/), error_message + end end end diff --git a/lib/app/utils/exceptions.rb b/lib/app/utils/exceptions.rb index 2f6f63983..79c02a92c 100644 --- a/lib/app/utils/exceptions.rb +++ b/lib/app/utils/exceptions.rb @@ -5,7 +5,7 @@ class AssertionException < RuntimeError attr_accessor :details def initialize(message, details = nil) super(message) - FHIR.logger.error "AssertionException: #{message}" + Inferno.logger.error "AssertionException: #{message}" @details = details end end @@ -14,7 +14,7 @@ class SkipException < RuntimeError attr_accessor :details def initialize(message = '', details = nil) super(message) - FHIR.logger.info "SkipException: #{message}" + Inferno.logger.info "SkipException: #{message}" @details = details end end @@ -22,14 +22,14 @@ def initialize(message = '', details = nil) class TodoException < RuntimeError def initialize(message = '') super(message) - FHIR.logger.info "TodoException: #{message}" + Inferno.logger.info "TodoException: #{message}" end end class PassException < RuntimeError def initialize(message = '') super(message) - FHIR.logger.info "PassException: #{message}" + Inferno.logger.info "PassException: #{message}" end end diff --git a/lib/app/utils/search_validation.rb b/lib/app/utils/search_validation.rb new file mode 100644 index 000000000..de6b95ac6 --- /dev/null +++ b/lib/app/utils/search_validation.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Inferno + module SearchValidationUtil + 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) + # 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] + 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] + when 'ge' + fhir_date_comparer(search_range, target_range, 'gt') || fhir_date_comparer(search_range, target_range, 'eq') + when 'le' + fhir_date_comparer(search_range, target_range, 'lt') || 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) + 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_range = get_fhir_datetime_range(search_value) + target_range = get_fhir_datetime_range(target_value) + fhir_date_comparer(search_range, target_range, comparator) + 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_range = get_fhir_datetime_range(search_value) + target_range = get_fhir_period_range(target_value) + fhir_date_comparer(search_range, target_range, comparator) + end + end +end diff --git a/lib/app/utils/terminology.rb b/lib/app/utils/terminology.rb index 00816ee3e..8b6b1076f 100644 --- a/lib/app/utils/terminology.rb +++ b/lib/app/utils/terminology.rb @@ -49,7 +49,7 @@ def self.load_terminology @@top_lab_code_descriptions[row[0]] = row[1] unless row[1].nil? end rescue StandardError => e - FHIR.logger.error e + Inferno.logger.error e end begin @@ -70,7 +70,7 @@ def self.load_terminology code_system_hash[code] = description end rescue StandardError => error - FHIR.logger.error error + Inferno.logger.error error end begin @@ -87,7 +87,7 @@ def self.load_terminology @@core_snomed[code] = description end rescue StandardError => error - FHIR.logger.error error + Inferno.logger.error error end begin @@ -99,7 +99,7 @@ def self.load_terminology end @@common_ucum.uniq! rescue StandardError => error - FHIR.logger.error error + Inferno.logger.error error end @@loaded = true @@ -132,14 +132,14 @@ def self.create_validators(type) @known_valuesets.each do |k, vs| next if (k == 'http://fhir.org/guides/argonaut/ValueSet/argo-codesystem') || (k == 'http://fhir.org/guides/argonaut/ValueSet/languages') - puts "Processing #{k}" + Inferno.logger.debug "Processing #{k}" filename = "#{root_dir}/#{(URI(vs.url).host + URI(vs.url).path).gsub(%r{[./]}, '_')}.msgpack" save_bloom_to_file(vs.valueset, filename) validators << { url: k, file: File.basename(filename), count: vs.count, type: 'bloom' } end vs = Inferno::Terminology::Valueset.new(@db) Inferno::Terminology::Valueset::SAB.each do |k, _v| - puts "Processing #{k}" + Inferno.logger.debug "Processing #{k}" cs = vs.code_system_set(k) filename = "#{root_dir}/#{(URI(k).host + URI(k).path).gsub(%r{[./]}, '_')}.msgpack" save_bloom_to_file(cs, filename) @@ -153,14 +153,14 @@ def self.create_validators(type) @known_valuesets.each do |k, vs| next if (k == 'http://fhir.org/guides/argonaut/ValueSet/argo-codesystem') || (k == 'http://fhir.org/guides/argonaut/ValueSet/languages') - puts "Processing #{k}" + Inferno.logger.debug "Processing #{k}" filename = "#{root_dir}/#{(URI(vs.url).host + URI(vs.url).path).gsub(%r{[./]}, '_')}.csv" save_csv_to_file(vs.valueset, filename) validators << { url: k, file: File.basename(filename), count: vs.count, type: 'csv' } end vs = Inferno::Terminology::Valueset.new(@db) Inferno::Terminology::Valueset::SAB.each do |k, _v| - puts "Processing #{k}" + Inferno.logger.debug "Processing #{k}" cs = vs.code_system_set(k) filename = "#{root_dir}/#{(URI(k).host + URI(k).path).gsub(%r{[./]}, '_')}.csv" save_csv_to_file(cs, filename) diff --git a/lib/app/utils/validation.rb b/lib/app/utils/validation.rb index 7be1205f5..ee58a3fae 100644 --- a/lib/app/utils/validation.rb +++ b/lib/app/utils/validation.rb @@ -28,7 +28,7 @@ def self.get_resource(json, version) DEFINITIONS[resource.url] = resource if resource.resourceType == 'StructureDefinition' profiled_type = resource.snapshot.element.first.path # will this always be the first? - RESOURCES[version][profiled_type] = [] unless RESOURCES[version][profiled_type] + RESOURCES[version][profiled_type] ||= [] RESOURCES[version][profiled_type] << resource elsif resource.resourceType == 'ValueSet' VALUESETS[resource.url] = resource @@ -56,11 +56,12 @@ def self.get_resource(json, version) def self.guess_profile(resource, version) # if the profile is given, we don't need to guess - if resource&.meta&.profile && !resource&.meta&.profile&.empty? + if resource&.meta&.profile&.present? resource.meta.profile.each do |uri| return DEFINITIONS[uri] if DEFINITIONS[uri] end end + if version == :dstu2 guess_dstu2_profile(resource) elsif version == :stu3 @@ -71,69 +72,68 @@ def self.guess_profile(resource, version) end def self.guess_dstu2_profile(resource) - if resource - candidates = RESOURCES[:dstu2][resource.resourceType] - if candidates && !candidates.empty? - # Special cases where there are multiple profiles per Resource type - if resource.resourceType == 'Observation' - if resource.code&.coding && resource.code.coding.any? { |coding| coding.code == '72166-2' } - return DEFINITIONS[ARGONAUT_URIS[:smoking_status]] - elsif resource.category&.coding && resource.category.coding.any? { |coding| coding.code == 'laboratory' } - return DEFINITIONS[ARGONAUT_URIS[:observation_results]] - elsif resource.category&.coding && resource.category.coding.any? { |coding| coding.code == 'vital-signs' } - return DEFINITIONS[ARGONAUT_URIS[:vital_signs]] - end - elsif resource.resourceType == 'CarePlan' - if resource.category.any? { |category| category.coding.any? { |coding| coding.code == 'careteam' } } - return DEFINITIONS[ARGONAUT_URIS[:care_team]] - else - return DEFINITIONS[ARGONAUT_URIS[:care_plan]] - end - end - # Otherwise, guess the first profile that matches on resource type - return candidates.first + return if resource.blank? + + candidates = RESOURCES[:dstu2][resource.resourceType] + return if candidates.blank? + + # Special cases where there are multiple profiles per Resource type + if resource.resourceType == 'Observation' + if resource&.code&.coding&.any? { |coding| coding&.code == '72166-2' } + return DEFINITIONS[ARGONAUT_URIS[:smoking_status]] + elsif resource&.category&.coding&.any? { |coding| coding&.code == 'laboratory' } + return DEFINITIONS[ARGONAUT_URIS[:observation_results]] + elsif resource&.category&.coding&.any? { |coding| coding&.code == 'vital-signs' } + return DEFINITIONS[ARGONAUT_URIS[:vital_signs]] + end + elsif resource.resourceType == 'CarePlan' + if resource&.category&.any? { |category| category&.coding&.any? { |coding| coding&.code == 'careteam' } } + return DEFINITIONS[ARGONAUT_URIS[:care_team]] + else + return DEFINITIONS[ARGONAUT_URIS[:care_plan]] end end - nil + + # Otherwise, guess the first profile that matches on resource type + candidates.first end def self.guess_stu3_profile(resource) - if resource - candidates = RESOURCES[:stu3][resource.resourceType] - if candidates && !candidates.empty? - # Special cases where there are multiple profiles per Resource type - if resource.resourceType == 'ExplanationOfBenefit' - if resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'CARRIER' } - return DEFINITIONS[BLUEBUTTON_URIS[:carrier]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'DME' } - return DEFINITIONS[BLUEBUTTON_URIS[:dme]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'HHA' } - return DEFINITIONS[BLUEBUTTON_URIS[:hha]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'HOSPICE' } - return DEFINITIONS[BLUEBUTTON_URIS[:hospice]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'INPATIENT' } - return DEFINITIONS[BLUEBUTTON_URIS[:inpatient]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'OUTPATIENT' } - return DEFINITIONS[BLUEBUTTON_URIS[:outpatient]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'PDE' } - return DEFINITIONS[BLUEBUTTON_URIS[:pde]] - elsif resource.type&.coding && resource.type.coding.any? { |coding| coding.code == 'SNF' } - return DEFINITIONS[BLUEBUTTON_URIS[:snf]] - end - end - # Otherwise, guess the first profile that matches on resource type - return candidates.first + return if resource.blank? + + candidates = RESOURCES[:stu3][resource.resourceType] + return if candidates.blank? + + # Special cases where there are multiple profiles per Resource type + if resource.resourceType == 'ExplanationOfBenefit' + if resource&.type&.coding&.any? { |coding| coding.code == 'CARRIER' } + return DEFINITIONS[BLUEBUTTON_URIS[:carrier]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'DME' } + return DEFINITIONS[BLUEBUTTON_URIS[:dme]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'HHA' } + return DEFINITIONS[BLUEBUTTON_URIS[:hha]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'HOSPICE' } + return DEFINITIONS[BLUEBUTTON_URIS[:hospice]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'INPATIENT' } + return DEFINITIONS[BLUEBUTTON_URIS[:inpatient]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'OUTPATIENT' } + return DEFINITIONS[BLUEBUTTON_URIS[:outpatient]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'PDE' } + return DEFINITIONS[BLUEBUTTON_URIS[:pde]] + elsif resource&.type&.coding&.any? { |coding| coding.code == 'SNF' } + return DEFINITIONS[BLUEBUTTON_URIS[:snf]] end end - nil + + # Otherwise, guess the first profile that matches on resource type + candidates.first end def self.guess_r4_profile(resource) - if resource - candidates = RESOURCES[:r4][resource.resourceType] - return candidates.first if candidates && !candidates.empty? - end - nil + return if resource.blank? + + candidates = RESOURCES[:r4][resource.resourceType] + return candidates.first if candidates.present? end end end diff --git a/lib/app/views/default.erb b/lib/app/views/default.erb index 1d9a38b20..be834ea43 100644 --- a/lib/app/views/default.erb +++ b/lib/app/views/default.erb @@ -309,7 +309,7 @@ @@ -434,7 +434,7 @@ Incoming HTTP(S) This test contains incoming HTTP(S) requests -

For more information, see the Inferno README and the Inferno wiki.

+

For more information, see the Inferno README and the Inferno wiki.

diff --git a/lib/app/views/guided.erb b/lib/app/views/guided.erb index 2dd916010..1ab37188a 100644 --- a/lib/app/views/guided.erb +++ b/lib/app/views/guided.erb @@ -80,9 +80,10 @@ <% end %>
diff --git a/lib/app/views/index.erb b/lib/app/views/index.erb index 3648973ab..4aceecb79 100644 --- a/lib/app/views/index.erb +++ b/lib/app/views/index.erb @@ -56,7 +56,7 @@
- This software is under active development. Please report bugs and submit feature requests as GitHub issues. + This software is under active development. Please report bugs and submit feature requests as GitHub issues.
diff --git a/lib/app/views/layout.erb b/lib/app/views/layout.erb index 3f421faf9..e37fd6097 100644 --- a/lib/app/views/layout.erb +++ b/lib/app/views/layout.erb @@ -28,7 +28,7 @@ Home %> @@ -42,10 +42,10 @@