diff --git a/.travis.yml b/.travis.yml
index e9432c0bb..09f39ae1e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,8 +3,6 @@ cache: bundler
services:
- docker
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
diff --git a/Gemfile b/Gemfile
index 704d2e0a1..435658736 100644
--- a/Gemfile
+++ b/Gemfile
@@ -10,7 +10,7 @@ gem 'colorize'
gem 'data_mapper'
gem 'dm-sqlite-adapter'
gem 'fhir_client'
-gem 'json-jwt'
+gem 'jwt'
gem 'kramdown'
gem 'parser'
gem 'pry'
@@ -18,6 +18,7 @@ gem 'pry-byebug'
gem 'rake'
gem 'rb-readline'
gem 'rest-client'
+gem 'rubyXL'
gem 'selenium-webdriver'
gem 'sinatra'
gem 'sinatra-contrib'
diff --git a/Gemfile.lock b/Gemfile.lock
index 1af06dc04..32b3e9399 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -8,7 +8,6 @@ GEM
tzinfo (~> 1.1)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
- aes_key_wrap (1.0.1)
ast (2.4.0)
backports (3.15.0)
base62-rb (0.3.1)
@@ -17,7 +16,6 @@ GEM
bcrypt (3.1.13)
bcrypt-ruby (3.1.5)
bcrypt (>= 3.1.3)
- bindata (2.4.4)
bitarray (1.2.0)
bloomer (1.0.0)
bitarray
@@ -121,10 +119,6 @@ GEM
concurrent-ruby (~> 1.0)
jaro_winkler (1.5.3)
json (1.8.6)
- json-jwt (1.11.0)
- activesupport (>= 4.2)
- aes_key_wrap
- bindata
json_pure (1.8.6)
jwt (2.2.1)
kramdown (2.1.0)
@@ -158,7 +152,7 @@ GEM
byebug (~> 11.0)
pry (~> 0.10)
public_suffix (4.0.1)
- rack (2.0.7)
+ rack (2.0.8)
rack-protection (2.0.7)
rack
rack-test (1.1.0)
@@ -179,6 +173,9 @@ GEM
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7)
ruby-progressbar (1.10.1)
+ rubyXL (3.4.9)
+ nokogiri (>= 1.4.4)
+ rubyzip (>= 1.3.0)
rubyzip (1.3.0)
safe_yaml (1.0.5)
selenium-webdriver (3.142.4)
@@ -235,7 +232,7 @@ DEPENDENCIES
data_mapper
dm-sqlite-adapter
fhir_client
- json-jwt
+ jwt
kramdown
minitest
parser
@@ -246,6 +243,7 @@ DEPENDENCIES
rb-readline
rest-client
rubocop
+ rubyXL
selenium-webdriver
simplecov
sinatra
diff --git a/config.yml b/config.yml
index d53c75156..eb4f3e803 100644
--- a/config.yml
+++ b/config.yml
@@ -37,6 +37,10 @@ include_extras: true
badge_text: Community
+# Resource validator options: must be one of "internal" or "external". external_resource_validator_url is only used if resource_validator is set to external.
+resource_validator: internal
+external_resource_validator_url: http://validator_service:4567
+
# module options: one or more must be set. The first option in the list will be checked by default
modules:
- onc
@@ -44,7 +48,6 @@ modules:
- bdt
- argonaut
- uscore_v3.1.0
- - bulk_data
# preset fhir servers: optional. Minimally requires name, uri, module, optional inferno_uri, client_id, client_secret, scopes, instructions link
presets:
diff --git a/docker-compose.yml b/docker-compose.yml
index 322cf7924..00dbd2662 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,6 +9,8 @@ services:
- bdt_service
bdt_service:
image: infernocommunity/inferno-bdt-service
+ validator_service:
+ image: infernocommunity/fhir-validator-wrapper
nginx_server:
image: nginx
volumes:
diff --git a/generator/uscore/metadata_extractor.rb b/generator/uscore/metadata_extractor.rb
index 320c08811..77fe88e9c 100644
--- a/generator/uscore/metadata_extractor.rb
+++ b/generator/uscore/metadata_extractor.rb
@@ -5,10 +5,6 @@ module Generator
module USCoreMetadataExtractor
PROFILE_URIS = Inferno::ValidationUtil::US_CORE_R4_URIS
- def profile_uri(profile)
- "http://hl7.org/fhir/us/core/StructureDefinition/#{profile}"
- end
-
def search_param_path(resource, param)
param = 'id' if param == '_id'
"SearchParameter/us-core-#{resource.downcase}-#{param}"
@@ -27,6 +23,7 @@ def extract_metadata
capability_statement_json = capability_statement('server')
add_metadata_from_ig(metadata, ig_resource)
add_metadata_from_resources(metadata, capability_statement_json['rest'][0]['resource'])
+ fix_metadata_errors(metadata)
add_special_cases(metadata)
end
@@ -53,8 +50,16 @@ def generate_unique_test_id_prefix(title)
test_id_prefix
end
+ def get_base_path(profile)
+ if profile.include? 'us/core/'
+ profile.split('us/core/').last
+ else
+ profile.split('fhir/').last
+ end
+ end
+
def build_new_sequence(resource, profile)
- base_path = profile.split('us/core/').last
+ base_path = get_base_path(profile)
base_name = profile.split('StructureDefinition/').last
profile_json = @resource_by_path[base_path]
reformatted_version = ig_resource['version'].delete('.')
@@ -64,15 +69,15 @@ def build_new_sequence(resource, profile)
# In case the profile doesn't start with US Core
class_name = "USCore#{reformatted_version}#{class_name}" unless class_name.start_with? 'USCore'
-
{
name: base_name.tr('-', '_'),
class_name: class_name,
test_id_prefix: test_id_prefix,
resource: resource['type'],
- profile: profile_uri(base_name), # link in capability statement is incorrect,
+ profile: profile,
title: profile_title,
interactions: [],
+ operations: [],
searches: [],
search_param_descriptions: {},
element_descriptions: {},
@@ -94,10 +99,11 @@ def add_metadata_from_resources(metadata, resources)
add_basic_searches(resource, new_sequence)
add_combo_searches(resource, new_sequence)
add_interactions(resource, new_sequence)
+ add_operations(resource, new_sequence)
add_include_search(resource, new_sequence)
add_revinclude_targets(resource, new_sequence)
- base_path = new_sequence[:profile].split('us/core/').last
+ base_path = get_base_path(supported_profile)
profile_definition = @resource_by_path[base_path]
add_must_support_elements(profile_definition, new_sequence)
add_search_param_descriptions(profile_definition, new_sequence)
@@ -152,6 +158,17 @@ def add_interactions(resource, sequence)
end
end
+ def add_operations(resource, sequence)
+ operations = resource['operation']
+ operations&.each do |operation|
+ new_operation = {
+ operation: operation['name'],
+ expectation: operation['extension'][0]['valueCode']
+ }
+ sequence[:operations] << new_operation
+ end
+ end
+
def add_include_search(resource, sequence)
sequence[:include_params] = resource['searchInclude'] || []
end
@@ -180,7 +197,7 @@ def add_must_support_elements(profile_definition, sequence)
sequence[:must_supports] <<
{
type: 'element',
- path: path.gsub('[x]', type['code'].slice(0).capitalize + type['code'].slice(1..-1))
+ path: path.gsub('[x]', capitalize_first_letter(type['code']))
}
end
else
@@ -196,13 +213,10 @@ def add_must_support_elements(profile_definition, sequence)
def add_search_param_descriptions(profile_definition, sequence)
sequence[:search_param_descriptions].each_key do |param|
search_param_definition = @resource_by_path[search_param_path(sequence[:resource], param.to_s)]
- path_parts = search_param_definition['xpath'].split('/f:')
- if param.to_s != '_id'
- path_parts[0] = sequence[:resource]
- path = path_parts.join('.')
- else
- path = path_parts[0]
- end
+ path = search_param_definition['expression']
+ path = path.gsub(/.where\((.*)/, '')
+ as_type = path.scan(/.as\((.*?)\)/).flatten.first
+ path = path.gsub(/.as\((.*?)\)/, capitalize_first_letter(as_type)) if as_type.present?
profile_element = profile_definition['snapshot']['element'].select { |el| el['id'] == path }.first
param_metadata = {
path: path,
@@ -224,6 +238,8 @@ def add_search_param_descriptions(profile_definition, sequence)
expectation = expectation_extension[index]['extension'].first['valueCode'] unless expectation_extension.nil?
param_metadata[:comparators][comparator.to_sym] = expectation
end
+ multiple_or_expectation = search_param_definition['_multipleOr']['extension'].first['valueCode']
+ param_metadata[:multiple_or] = multiple_or_expectation
sequence[:search_param_descriptions][param] = param_metadata
end
end
@@ -285,6 +301,24 @@ def add_element_definitions(profile_definition, sequence)
end
end
+ def fix_metadata_errors(metadata)
+ # Procedure's date search param definition says Procedure.occurenceDateTime even though Procedure doesn't have an occurenceDateTime
+ procedure_sequence = metadata[:sequences].find { |sequence| sequence[:resource] == 'Procedure' }
+ procedure_sequence[:search_param_descriptions][:date][:path] = 'Procedure.performed'
+
+ goal_sequence = metadata[:sequences].find { |sequence| sequence[:resource] == 'Goal' }
+ goal_sequence[:search_param_descriptions][:'target-date'][:path] = 'Goal.target.dueDate'
+ goal_sequence[:search_param_descriptions][:'target-date'][:type] = 'date'
+
+ # add the ge comparator - the metadata is missing it for some reason
+ metadata[:sequences].each do |sequence|
+ sequence[:search_param_descriptions].each do |_param, description|
+ param_comparators = description[:comparators]
+ param_comparators[:ge] = param_comparators[:le] if param_comparators.key? :le
+ end
+ end
+ end
+
def add_special_cases(metadata)
category_first_profiles = [
PROFILE_URIS[:lab_results]
@@ -320,6 +354,10 @@ def set_first_search(sequence, params)
sequence[:searches].delete(search)
sequence[:searches].unshift(search)
end
+
+ def capitalize_first_letter(str)
+ str.slice(0).capitalize + str.slice(1..-1)
+ end
end
end
end
diff --git a/generator/uscore/templates/module.yml.erb b/generator/uscore/templates/module.yml.erb
index 3fce5e556..dbacf83dc 100644
--- a/generator/uscore/templates/module.yml.erb
+++ b/generator/uscore/templates/module.yml.erb
@@ -7,21 +7,18 @@ test_sets:
ad_hoc_testing:
view: default
tests:
- - name: Discovery
+ - name: SMART App Launch
sequences:
- - UsCoreR4CapabilityStatementSequence
- SMARTDiscoverySequence
- run_all: true
- - name: Authorization and Authentication
- sequences:
- DynamicRegistrationSequence
- ManualRegistrationSequence
- StandaloneLaunchSequence
- EHRLaunchSequence
- - name: US Core R4 Patient Based Profiles
+ - name: US Core v3.1.0 Profiles
run_all: true
- sequences:<% non_delayed_sequences.each do |sequence| %>
+ sequences:
+ - UsCoreR4CapabilityStatementSequence
+ - USCore310PatientSequence<% non_delayed_sequences.each do |sequence| %>
- <%=sequence[:class_name]%><% end %>
- - R4ProvenanceSequence
- USCoreR4ClinicalNotesSequence<% delayed_sequences.each do |sequence| %>
- <%=sequence[:class_name]%><% end %>
diff --git a/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb b/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb
index f7535d032..c85f334d4 100644
--- a/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb
+++ b/generator/uscore/templates/unit_tests/authorization_unit_test.rb.erb
@@ -15,6 +15,10 @@ describe 'unauthorized search test' do
it 'skips if the <%= resource_type %> search interaction is not supported' do
@instance.server_capabilities.destroy
+ Inferno::Models::ServerCapabilities.create(
+ testing_instance_id: @instance.id,
+ capabilities: FHIR::CapabilityStatement.new.to_json
+ )
@instance.reload
exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) }
diff --git a/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb b/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb
index 69453d056..47b86dada 100644
--- a/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb
+++ b/generator/uscore/templates/unit_tests/resource_read_unit_test.rb.erb
@@ -10,6 +10,10 @@ describe '<%= resource_type %> read test' do
it 'skips if the <%= resource_type %> read interaction is not supported' do
@instance.server_capabilities.destroy
+ Inferno::Models::ServerCapabilities.create(
+ testing_instance_id: @instance.id,
+ capabilities: FHIR::CapabilityStatement.new.to_json
+ )
@instance.reload
exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) }
diff --git a/generator/uscore/templates/unit_tests/search_unit_test.rb.erb b/generator/uscore/templates/unit_tests/search_unit_test.rb.erb
new file mode 100644
index 000000000..e90691fee
--- /dev/null
+++ b/generator/uscore/templates/unit_tests/search_unit_test.rb.erb
@@ -0,0 +1,154 @@
+describe '<%= resource_type %> search by <%= search_params.keys.join('+') %> test' do
+ before do
+ @test = @sequence_class[:<%= test_key %>]
+ @sequence = @sequence_class.new(@instance, @client)
+ @<%= resource_var_name %> = FHIR.from_contents(load_fixture(:<%= sequence_name %>))
+ @<%= resource_var_name %>_ary = [@<%= resource_var_name %>]
+ @sequence.instance_variable_set(:'@<%= resource_var_name %>', @<%= resource_var_name %>)
+ @sequence.instance_variable_set(:'@<%= resource_var_name %>_ary', @<%= resource_var_name %>_ary)
+<% unless is_first_search %>
+ @sequence.instance_variable_set(:'@resources_found', true)
+<% end %>
+ @query = {
+ <%= search_param_string %>
+ }
+ end
+
+<% unless is_first_search %>
+ it 'skips if no <%= resource_type %> resources have been found' do
+ @sequence.instance_variable_set(:'@resources_found', false)
+
+ exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) }
+
+ assert_equal 'No <%= resource_type %> resources appear to be available.<%=' Please use patients with more information.' unless delayed_sequence%>', exception.message
+ end
+
+ <% if has_dynamic_search_params %>
+ it 'skips if a value for one of the search parameters cannot be found' do
+ @sequence.instance_variable_set(:'@<%= resource_var_name %>_ary', [FHIR::<%= resource_type %>.new])
+
+ exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) }
+
+ assert_match(/Could not resolve [\w-]+ in given resource/, exception.message)
+ end
+ <% end %>
+<% end %>
+
+ it 'fails if a non-success response code is received' do
+<% if is_first_search && is_fixed_value_search %>
+ [<%= fixed_value_search_string %>].each do |value|
+ query_params = {
+ 'patient': @instance.patient_id,
+ '<%= fixed_value_search_param %>': value
+ }
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: query_params, headers: @auth_header)
+ .to_return(status: 401)
+ end
+<% else %>
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: @query, headers: @auth_header)
+ .to_return(status: 401)
+<% end %>
+
+ exception = assert_raises(Inferno::AssertionException) { @sequence.run_test(@test) }
+
+ assert_equal 'Bad response code: expected 200, 201, but found 401. ', exception.message
+ end
+
+ it 'fails if a Bundle is not received' do
+<% if is_first_search && is_fixed_value_search %>
+ [<%= fixed_value_search_string %>].each do |value|
+ query_params = {
+ 'patient': @instance.patient_id,
+ '<%= fixed_value_search_param %>': value
+ }
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: query_params, headers: @auth_header)
+ .to_return(status: 200, body: FHIR::<%= resource_type %>.new.to_json)
+ end
+<% else %>
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: @query, headers: @auth_header)
+ .to_return(status: 200, body: FHIR::<%= resource_type %>.new.to_json)
+<% end %>
+
+ exception = assert_raises(Inferno::AssertionException) { @sequence.run_test(@test) }
+
+ assert_equal 'Expected FHIR Bundle but found: <%= resource_type %>', exception.message
+ end
+
+<% if is_first_search %>
+ it 'skips if an empty Bundle is received' do
+ <% if is_fixed_value_search %>
+ [<%= fixed_value_search_string %>].each do |value|
+ query_params = {
+ 'patient': @instance.patient_id,
+ '<%= fixed_value_search_param %>': value
+ }
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: query_params, headers: @auth_header)
+ .to_return(status: 200, body: FHIR::Bundle.new.to_json)
+ end
+ <% else %>
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: @query, headers: @auth_header)
+ .to_return(status: 200, body: FHIR::Bundle.new.to_json)
+ <% end %>
+
+ exception = assert_raises(Inferno::SkipException) { @sequence.run_test(@test) }
+
+ assert_equal 'No <%= resource_type %> resources appear to be available.<%=' Please use patients with more information.' unless delayed_sequence%>', exception.message
+ end
+<% end %>
+
+ it 'fails if the bundle contains a resource which does not conform to the base FHIR spec' do
+<% if is_first_search && is_fixed_value_search %>
+ [<%= fixed_value_search_string %>].each do |value|
+ query_params = {
+ 'patient': @instance.patient_id,
+ '<%= fixed_value_search_param %>': value
+ }
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: query_params, headers: @auth_header)
+ .to_return(status: 200, body: wrap_resources_in_bundle(FHIR::<%= resource_type %>.new(id: '!@#$%')).to_json)
+ end
+<% else %>
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: @query, headers: @auth_header)
+ .to_return(status: 200, body: wrap_resources_in_bundle(FHIR::<%= resource_type %>.new(id: '!@#$%')).to_json)
+<% end %>
+
+ exception = assert_raises(Inferno::AssertionException) { @sequence.run_test(@test) }
+
+ assert_match(/Invalid \w+:/, exception.message)
+ end
+
+<% unless has_comparator_tests %>
+ it 'succeeds when a bundle containing a valid resource matching the search parameters is returned' do
+ <% if is_first_search && is_fixed_value_search %>
+ [<%= fixed_value_search_string %>].each do |value|
+ query_params = {
+ 'patient': @instance.patient_id,
+ '<%= fixed_value_search_param %>': value
+ }
+ body =
+ if @sequence.resolve_element_from_path(@<%= resource_var_name %>, '<%= fixed_value_search_path %>') == value
+ wrap_resources_in_bundle(@<%= resource_var_name %>_ary).to_json
+ else
+ FHIR::Bundle.new.to_json
+ end
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: query_params, headers: @auth_header)
+ .to_return(status: 200, body: body)
+ end
+ <% else %>
+ stub_request(:get, "#{@base_url}/<%= resource_type %>")
+ .with(query: @query, headers: @auth_header)
+ .to_return(status: 200, body: wrap_resources_in_bundle(@<%= resource_var_name %>_ary).to_json)
+ <% end %>
+
+ @sequence.run_test(@test)
+ end
+<% end %>
+end
diff --git a/generator/uscore/templates/unit_tests/unit_test.rb.erb b/generator/uscore/templates/unit_tests/unit_test.rb.erb
index 944efe2be..001c1cc8f 100644
--- a/generator/uscore/templates/unit_tests/unit_test.rb.erb
+++ b/generator/uscore/templates/unit_tests/unit_test.rb.erb
@@ -12,7 +12,7 @@ describe Inferno::Sequence::<%= class_name %> do
@client = FHIR::Client.new(@base_url)
@token = 'ABC'
@instance = Inferno::Models::TestingInstance.create(token: @token, selected_module: '<%= module_name %>')
- @patient_id = '123'
+ @patient_id = 'example'
@instance.patient_id = @patient_id
set_resource_support(@instance, '<%= resource_type %>')
@auth_header = { 'Authorization' => "Bearer #{@token}" }
diff --git a/generator/uscore/us_core_unit_test_generator.rb b/generator/uscore/us_core_unit_test_generator.rb
index 4070b4ff2..2260722b0 100644
--- a/generator/uscore/us_core_unit_test_generator.rb
+++ b/generator/uscore/us_core_unit_test_generator.rb
@@ -26,8 +26,45 @@ def generate(sequence, path, module_name)
File.write(file_name, unit_tests)
end
+ def generate_search_test(
+ test_key:,
+ resource_type:,
+ search_params:,
+ is_first_search:,
+ is_fixed_value_search:,
+ has_comparator_tests:,
+ fixed_value_search_param:,
+ class_name:,
+ sequence_name:,
+ delayed_sequence:
+ )
+
+ template = ERB.new(File.read(File.join(__dir__, 'templates', 'unit_tests', 'search_unit_test.rb.erb')))
+
+ resource_var_name = resource_type.underscore
+
+ test = template.result_with_hash(
+ test_key: test_key,
+ resource_type: resource_type,
+ resource_var_name: resource_var_name,
+ search_params: search_params,
+ search_param_string: search_params_to_string(search_params),
+ sequence_name: sequence_name,
+ is_first_search: is_first_search,
+ is_fixed_value_search: is_fixed_value_search,
+ has_comparator_tests: has_comparator_tests,
+ has_dynamic_search_params: dynamic_search_params(search_params).present?,
+ fixed_value_search_param: fixed_value_search_param&.dig(:name),
+ fixed_value_search_string: fixed_value_search_param&.dig(:values)&.map { |value| "'#{value}'" }&.join(', '),
+ fixed_value_search_path: fixed_value_search_param&.dig(:path),
+ delayed_sequence: delayed_sequence
+ )
+ tests[class_name] << test
+ end
+
def generate_authorization_test(test_key:, resource_type:, search_params:, class_name:, sequence_name:)
template = ERB.new(File.read(File.join(__dir__, 'templates', 'unit_tests', 'authorization_unit_test.rb.erb')))
+
test = template.result_with_hash(
test_key: test_key,
resource_type: resource_type,
@@ -35,6 +72,7 @@ def generate_authorization_test(test_key:, resource_type:, search_params:, class
dynamic_search_params: dynamic_search_params(search_params),
sequence_name: sequence_name
)
+
tests[class_name] << test
end
diff --git a/generator/uscore/uscore_generator.rb b/generator/uscore/uscore_generator.rb
index fa307b888..6cc4a09ac 100644
--- a/generator/uscore/uscore_generator.rb
+++ b/generator/uscore/uscore_generator.rb
@@ -72,10 +72,17 @@ def generate_tests(metadata)
create_interaction_test(sequence, interaction)
end
+ sequence[:operations]
+ .select { |operation| operation[:expectation] == 'SHALL' }
+ .each do |operation|
+ create_docref_test(sequence) if operation[:operation] == 'docref'
+ end
+
create_include_test(sequence) if sequence[:include_params].any?
create_revinclude_test(sequence) if sequence[:revincludes].any?
create_resource_profile_test(sequence)
create_must_support_test(sequence)
+ create_multiple_or_test(sequence)
create_references_resolved_test(sequence)
end
end
@@ -85,7 +92,7 @@ def mark_delayed_sequences(metadata)
sequence[:delayed_sequence] = sequence[:resource] != 'Patient' && sequence[:searches].none? { |search| search[:names].include? 'patient' }
end
metadata[:delayed_sequences] = metadata[:sequences].select { |seq| seq[:delayed_sequence] }
- metadata[:non_delayed_sequences] = metadata[:sequences].reject { |seq| seq[:delayed_sequence] }
+ metadata[:non_delayed_sequences] = metadata[:sequences].reject { |seq| seq[:resource] == 'Patient' || seq[:delayed_sequence] }
end
def find_first_search(sequence)
@@ -106,7 +113,7 @@ def generate_sequence(sequence)
def create_read_test(sequence)
test_key = :resource_read
read_test = {
- tests_that: "Can read #{sequence[:resource]} from the server",
+ tests_that: "Server returns correct #{sequence[:resource]} resource from the #{sequence[:resource]} read interaction",
key: test_key,
index: sequence[:tests].length + 1,
link: 'https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html',
@@ -114,16 +121,18 @@ def create_read_test(sequence)
}
read_test[:test_code] = %(
- skip_if_not_supported(:#{sequence[:resource]}, [:read])
+ skip_if_known_not_supported(:#{sequence[:resource]}, [:read])
- #{sequence[:resource].underscore}_id = @instance.resource_references.find { |reference| reference.resource_type == '#{sequence[:resource]}' }&.resource_id
- skip 'No #{sequence[:resource]} references found from the prior searches' if #{sequence[:resource].underscore}_id.nil?
+ #{sequence[:resource].underscore}_references = @instance.resource_references.select { |reference| reference.resource_type == '#{sequence[:resource]}' }
+ skip 'No #{sequence[:resource]} references found from the prior searches' if #{sequence[:resource].underscore}_references.blank?
- @#{sequence[:resource].underscore} = validate_read_reply(
- FHIR::#{sequence[:resource]}.new(id: #{sequence[:resource].underscore}_id),
- FHIR::#{sequence[:resource]}
- )
- @#{sequence[:resource].underscore}_ary = Array.wrap(@#{sequence[:resource].underscore}).compact
+ @#{sequence[:resource].underscore}_ary = #{sequence[:resource].underscore}_references.map do |reference|
+ validate_read_reply(
+ FHIR::#{sequence[:resource]}.new(id: reference.resource_id),
+ FHIR::#{sequence[:resource]}
+ )
+ end
+ @#{sequence[:resource].underscore} = @#{sequence[:resource].underscore}_ary.first
@resources_found = @#{sequence[:resource].underscore}.present?)
sequence[:tests] << read_test
@@ -152,7 +161,7 @@ def create_authorization_test(sequence)
unit_test_params = get_search_param_hash(search_parameters, sequence, true)
authorization_test[:test_code] = %(
- skip_if_not_supported(:#{sequence[:resource]}, [:search])
+ skip_if_known_not_supported(:#{sequence[:resource]}, [:search])
@client.set_no_auth
omit 'Do not test if no bearer token set' if @instance.token.blank?
@@ -172,6 +181,24 @@ def create_authorization_test(sequence)
)
end
+ def create_docref_test(sequence)
+ docref_test = {
+ tests_that: 'The server is capable of returning a reference to a generated CDA document in response to the $docref operation',
+ index: sequence[:tests].length + 1,
+ link: 'http://hl7.org/fhir/us/core/2019Sep/CapabilityStatement-us-core-server.html#documentreference',
+ description: 'A server SHALL be capable of responding to a $docref operation and capable of returning at least a reference to a generated CCD document, if available.'
+ }
+
+ docref_test[:test_code] = %(
+ skip_if_known_not_supported(:#{sequence[:resource]}, [], [:docref])
+ search_string = "/DocumentReference/$docref?patient=\#{@instance.patient_id}"
+ reply = @client.get(search_string, @client.fhir_headers)
+ assert_response_ok(reply)
+ )
+
+ sequence[:tests] << docref_test
+ end
+
def create_include_test(sequence)
include_test = {
tests_that: "Server returns the appropriate resource from the following _includes: #{sequence[:include_params].join(', ')}",
@@ -199,14 +226,16 @@ def create_include_test(sequence)
end
def create_revinclude_test(sequence)
+ first_search = find_first_search(sequence)
+ return if first_search.blank?
+
revinclude_test = {
- tests_that: "Server returns the appropriate resources from the following _revincludes: #{sequence[:revincludes].join(',')}",
+ tests_that: "Server returns Provenance resources from #{sequence[:resource]} search by #{first_search[:names].join(' + ')} + _revIncludes: Provenance:target",
index: sequence[:tests].length + 1,
link: 'https://www.hl7.org/fhir/search.html#revinclude',
description: "A Server SHALL be capable of supporting the following _revincludes: #{sequence[:revincludes].join(', ')}"
}
- first_search = find_first_search(sequence)
- search_params = first_search.nil? ? "\nsearch_params = {}" : get_search_params(first_search[:names], sequence)
+ search_params = get_search_params(first_search[:names], sequence)
revinclude_test[:test_code] = search_params
sequence[:revincludes].each do |revinclude|
resource_name = revinclude.split(':').first
@@ -216,16 +245,19 @@ def create_revinclude_test(sequence)
reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params)
assert_response_ok(reply)
assert_bundle_response(reply)
- #{resource_variable} = reply&.resource&.entry&.map(&:resource)&.any? { |resource| resource.resourceType == '#{resource_name}' }
- assert #{resource_variable}, 'No #{resource_name} resources were returned from this search'
+ #{resource_variable} = fetch_all_bundled_resources(reply.resource).select { |resource| resource.resourceType == '#{resource_name}'}
+ skip 'No #{resource_name} resources were returned from this search' unless #{resource_variable}.present?
+ #{resource_variable}.each { |reference| @instance.save_resource_reference('#{resource_name}', reference.id) }
)
end
sequence[:tests] << revinclude_test
end
def create_search_test(sequence, search_param)
+ test_key = :"search_by_#{search_param[:names].map(&:underscore).join('_')}"
search_test = {
tests_that: "Server returns expected results from #{sequence[:resource]} search by #{search_param[:names].join('+')}",
+ key: test_key,
index: sequence[:tests].length + 1,
link: 'https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html',
optional: search_param[:expectation] != 'SHALL',
@@ -246,15 +278,32 @@ def create_search_test(sequence, search_param)
get_first_search(search_param[:names], sequence)
else
%(
- skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found
- assert !@#{sequence[:resource].underscore}.nil?, 'Expected valid #{sequence[:resource]} resource to be present'
- #{get_search_params(search_param[:names], sequence)}
+ #{skip_if_not_found(sequence)}
+ #{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))
+ assert_response_ok(reply)
+ )
end
- search_test[:test_code] += get_comparator_searches(search_param[:names], sequence)
+ comparator_search_code = get_comparator_searches(search_param[:names], sequence)
+ search_test[:test_code] += comparator_search_code
sequence[:tests] << search_test
+
+ is_fixed_value_search = fixed_value_search?(search_param[:names], sequence)
+ fixed_value_search_param = is_fixed_value_search ? fixed_value_search_param(search_param[:names], sequence) : nil
+
+ unit_test_generator.generate_search_test(
+ test_key: test_key,
+ resource_type: sequence[:resource],
+ search_params: get_search_param_hash(search_param[:names], sequence),
+ is_first_search: is_first_search,
+ is_fixed_value_search: is_fixed_value_search,
+ has_comparator_tests: comparator_search_code.present?,
+ fixed_value_search_param: fixed_value_search_param,
+ class_name: sequence[:class_name],
+ sequence_name: sequence[:name],
+ delayed_sequence: sequence[:delayed_sequence]
+ )
end
def create_interaction_test(sequence, interaction)
@@ -262,15 +311,16 @@ def create_interaction_test(sequence, interaction)
test_key = :"#{interaction[:code]}_interaction"
interaction_test = {
- tests_that: "#{sequence[:resource]} #{interaction[:code]} interaction supported",
+ tests_that: "Server returns correct #{sequence[:resource]} resource from #{sequence[:resource]} #{interaction[:code]} interaction",
key: test_key,
index: sequence[:tests].length + 1,
link: 'https://www.hl7.org/fhir/us/core/CapabilityStatement-us-core-server.html',
- description: "A server #{interaction[:expectation]} support the #{sequence[:resource]} #{interaction[:code]} interaction."
+ description: "A server #{interaction[:expectation]} support the #{sequence[:resource]} #{interaction[:code]} interaction.",
+ optional: interaction[:expectation] != 'SHALL'
}
interaction_test[:test_code] = %(
- skip_if_not_supported(:#{sequence[:resource]}, [:#{interaction[:code]}])
+ skip_if_known_not_supported(:#{sequence[:resource]}, [:#{interaction[:code]}])
skip 'No #{sequence[:resource]} resources could be found for this patient. Please use patients with more information.' unless @resources_found
validate_#{interaction[:code]}_reply(@#{sequence[:resource].underscore}, versioned_resource_class('#{sequence[:resource]}')))
@@ -289,7 +339,7 @@ def create_interaction_test(sequence, interaction)
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.",
+ tests_that: "All must support elements are provided in the #{sequence[:resource]} resources returned.",
index: sequence[:tests].length + 1,
link: 'http://www.hl7.org/fhir/us/core/general-guidance.html#must-support',
test_code: '',
@@ -299,68 +349,80 @@ def create_must_support_test(sequence)
)
}
- sequence[:must_supports].select { |must_support| must_support[:type] == 'element' }.each do |element|
+ must_support_elements = sequence[:must_supports].select { |must_support| must_support[:type] == 'element' }
+ must_support_elements.each do |element|
test[:description] += %(
#{element[:path]}
)
+ # class is mapped to local_class in fhir_models. Update this after it
+ # has been added to the description so that the description contains
+ # the original path
+ element[:path] = element[:path].gsub('.class', '.local_class')
+ end
+
+ must_support_extensions = sequence[:must_supports].select { |must_support| must_support[:type] == 'extension' }
+ must_support_extensions.each do |extension|
+ test[:description] += %(
+ #{extension[:id]}
+ )
end
- test[:test_code] += %(
- skip 'No resources appear to be available for this patient. Please use patients with more information' unless @#{sequence[:resource].underscore}_ary&.any?)
test[:test_code] += %(
- must_support_confirmed = {})
+ #{skip_if_not_found(sequence)}
+ )
+
+ if must_support_extensions.present?
+ extensions_list = must_support_extensions.map { |extension| "'#{extension[:id]}': '#{extension[:url]}'" }
- 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].underscore}_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_notification = "Could not find \#{id} in any of the \#{@#{sequence[:resource].underscore}_ary.length} provided #{sequence[:resource]} resource(s)"
- skip skip_notification unless must_support_confirmed[id]
+ must_support_extensions = {
+ #{extensions_list.join(",\n ")}
+ }
+ missing_must_support_extensions = must_support_extensions.reject do |_id, url|
+ @#{sequence[:resource].underscore}_ary&.any? do |resource|
+ resource.extension.any? { |extension| extension.url == url }
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
+ )
end
- if elements_list.any?
+ if must_support_elements.present?
+ elements_list = must_support_elements.map { |element| "'#{element[:path]}'" }
+
test[:test_code] += %(
- must_support_elements = [
- #{elements_list.join(",\n ")}
- ]
- must_support_elements.each do |path|
- @#{sequence[:resource].underscore}_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].underscore}_ary.length
+ must_support_elements = [
+ #{elements_list.join(",\n ")}
+ ]
+
+ missing_must_support_elements = must_support_elements.reject do |path|
+ truncated_path = path.gsub('#{sequence[:resource]}.', '')
+ @#{sequence[:resource].underscore}_ary&.any? do |resource|
+ resolve_element_from_path(resource, truncated_path).present?
+ end
+ end
+ )
- skip "Could not find \#{path} in any of the \#{resource_count} provided #{sequence[:resource]} resource(s)" unless must_support_confirmed[path]
- end)
+ if must_support_extensions.present?
+ test[:test_code] += %(
+ missing_must_support_elements += missing_must_support_extensions.keys
+ )
+ end
+
+ test[:test_code] += %(
+ skip_if missing_must_support_elements.present?,
+ "Could not find \#{missing_must_support_elements.join(', ')} in the \#{@#{sequence[:resource].underscore}_ary&.length} provided #{sequence[:resource]} resource(s)"
+ )
end
test[:test_code] += %(
- @instance.save!)
+ @instance.save!)
sequence[:tests] << test
end
def create_resource_profile_test(sequence)
test = {
- tests_that: "#{sequence[:resource]} resources associated with Patient conform to US Core R4 profiles",
+ tests_that: "#{sequence[:resource]} resources returned conform to US Core R4 profiles",
index: sequence[:tests].length + 1,
link: sequence[:profile],
description: %(
@@ -369,33 +431,68 @@ def create_resource_profile_test(sequence)
)
}
test[:test_code] = %(
- skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found
+ #{skip_if_not_found(sequence)}
test_resources_against_profile('#{sequence[:resource]}'#{', ' + validation_profile_uri(sequence) if validation_profile_uri(sequence)}))
sequence[:tests] << test
end
+ def create_multiple_or_test(sequence)
+ test = {
+ tests_that: 'The server returns expected results when parameters use composite-or',
+ index: sequence[:tests].length + 1,
+ link: sequence[:profile],
+ test_code: ''
+ }
+
+ multiple_or_params = get_multiple_or_params(sequence)
+
+ multiple_or_params.each do |param|
+ multiple_or_search = sequence[:searches].find { |search| (search[:names].include? param) && search[:expectation] == 'SHALL' }
+ next if multiple_or_search.blank?
+
+ second_val_var = "second_#{param}_val"
+ resolve_el_str = "#{resolve_element_path(sequence[:search_param_descriptions][param.to_sym])} { |el| get_value_for_search_param(el) != #{param_value_name(param)} }"
+ test[:test_code] += %(
+ #{get_search_params(multiple_or_search[:names], sequence)}
+ #{second_val_var} = #{resolve_el_str}
+ skip 'Cannot find second value for #{param} to perform a multipleOr search' if #{second_val_var}.nil?
+ #{param_value_name(param)} += ',' + get_value_for_search_param(#{second_val_var})
+ 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
+ sequence[:tests] << test if test[:test_code].present?
+ end
+
+ def get_multiple_or_params(sequence)
+ sequence[:search_param_descriptions]
+ .select { |_param, description| description[:multiple_or] == 'SHALL' }
+ .map { |param, _description| param.to_s }
+ end
+
def create_references_resolved_test(sequence)
test = {
- tests_that: 'All references can be resolved',
+ tests_that: "Every reference within #{sequence[:resource]} resource is valid and can be read.",
index: sequence[:tests].length + 1,
link: 'http://hl7.org/fhir/references.html',
description: 'This test checks if references found in resources from prior searches can be resolved.'
}
test[:test_code] = %(
- skip_if_not_supported(:#{sequence[:resource]}, [:search, :read])
- skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found
+ skip_if_known_not_supported(:#{sequence[:resource]}, [:search, :read])
+ #{skip_if_not_found(sequence)}
validate_reference_resolutions(@#{sequence[:resource].underscore}))
sequence[:tests] << test
end
- def resolve_element_path(search_param_description)
+ def resolve_element_path(search_param_description, resolve_block = '')
element_path = search_param_description[:path].gsub('.class', '.local_class') # match fhir_models because class is protected keyword in ruby
path_parts = element_path.split('.')
resource_val = "@#{path_parts.shift.underscore}_ary"
- "get_value_for_search_param(resolve_element_from_path(#{resource_val}, '#{path_parts.join('.')}'))"
+ "resolve_element_from_path(#{resource_val}, '#{path_parts.join('.')}') #{resolve_block}"
end
def get_value_path_by_type(type)
@@ -431,12 +528,17 @@ def get_first_search(search_parameters, sequence)
validation_profile_uri(sequence)
].compact.join(', ')
- search_code = if search_parameters == ['patient'] || sequence[:delayed_sequence] || search_param_constants(search_parameters, sequence)
- get_first_search_by_patient(sequence, search_parameters, save_resource_ids_in_bundle_arguments)
- else
- get_first_search_with_fixed_values(sequence, search_parameters, save_resource_ids_in_bundle_arguments)
- end
- search_code
+ if fixed_value_search?(search_parameters, sequence)
+ get_first_search_with_fixed_values(sequence, search_parameters, save_resource_ids_in_bundle_arguments)
+ else
+ get_first_search_by_patient(sequence, search_parameters, save_resource_ids_in_bundle_arguments)
+ end
+ end
+
+ def fixed_value_search?(search_parameters, sequence)
+ search_parameters != ['patient'] &&
+ !sequence[:delayed_sequence] &&
+ !search_param_constants(search_parameters, sequence)
end
def get_first_search_by_patient(sequence, search_parameters, save_resource_ids_in_bundle_arguments)
@@ -446,45 +548,69 @@ def get_first_search_by_patient(sequence, search_parameters, save_resource_ids_i
assert_response_ok(reply)
assert_bundle_response(reply)
- resource_count = reply&.resource&.entry&.length || 0
- @resources_found = true if resource_count.positive?
+ @resources_found = reply&.resource&.entry&.any? { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' }
- skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found
+ #{skip_if_not_found(sequence)}
- @#{sequence[:resource].underscore} = reply&.resource&.entry&.first&.resource
- @#{sequence[:resource].underscore}_ary = fetch_all_bundled_resources(reply&.resource)
+ @#{sequence[:resource].underscore} = reply.resource.entry
+ .find { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' }
+ .resource
+ @#{sequence[:resource].underscore}_ary = fetch_all_bundled_resources(reply.resource)
save_resource_ids_in_bundle(#{save_resource_ids_in_bundle_arguments})
save_delayed_sequence_references(@#{sequence[:resource].underscore}_ary)
validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params)
)
end
+ def fixed_value_search_param(search_parameters, sequence)
+ name = search_parameters.find { |param| param != 'patient' }
+ search_description = sequence[:search_param_descriptions][name.to_sym]
+ values = search_description[:values]
+ path =
+ search_description[:path]
+ .split('.')
+ .drop(1)
+ .map { |path_part| path_part == 'class' ? 'local_class' : path_part }
+ .join('.')
+ path += get_value_path_by_type(search_description[:type])
+
+ {
+ name: name,
+ path: path,
+ values: values
+ }
+ end
+
def get_first_search_with_fixed_values(sequence, search_parameters, save_resource_ids_in_bundle_arguments)
# assume only patient + one other parameter
- non_patient_search_param = search_parameters.find { |param| param != 'patient' }
- non_patient_values = sequence[:search_param_descriptions][non_patient_search_param.to_sym][:values]
- values_variable_name = "#{non_patient_search_param.tr('-', '_')}_val"
+ search_param = fixed_value_search_param(search_parameters, sequence)
+ find_two_values = get_multiple_or_params(sequence).include? search_param[:name]
+ values_variable_name = "#{search_param[:name].tr('-', '_')}_val"
%(
- #{values_variable_name} = [#{non_patient_values.map { |val| "'#{val}'" }.join(', ')}]
+ @#{sequence[:resource].underscore}_ary = []
+ #{'values_found = 0' if find_two_values}
+ #{values_variable_name} = [#{search_param[:values].map { |val| "'#{val}'" }.join(', ')}]
#{values_variable_name}.each do |val|
- search_params = { 'patient': @instance.patient_id, '#{non_patient_search_param}': val }
+ search_params = { 'patient': @instance.patient_id, '#{search_param[:name]}': val }
reply = get_resource_by_params(versioned_resource_class('#{sequence[:resource]}'), search_params)
assert_response_ok(reply)
assert_bundle_response(reply)
- resource_count = reply&.resource&.entry&.length || 0
- @resources_found = true if resource_count.positive?
- next unless @resources_found
+ next unless reply&.resource&.entry&.any? { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' }
- @#{sequence[:resource].underscore} = reply&.resource&.entry&.first&.resource
- @#{sequence[:resource].underscore}_ary = fetch_all_bundled_resources(reply&.resource)
+ @resources_found = true
+ @#{sequence[:resource].underscore} = reply.resource.entry
+ .find { |entry| entry&.resource&.resourceType == '#{sequence[:resource]}' }
+ .resource
+ @#{sequence[:resource].underscore}_ary += fetch_all_bundled_resources(reply.resource)
+ #{'values_found += 1' if find_two_values}
save_resource_ids_in_bundle(#{save_resource_ids_in_bundle_arguments})
save_delayed_sequence_references(@#{sequence[:resource].underscore}_ary)
validate_search_reply(versioned_resource_class('#{sequence[:resource]}'), reply, search_params)
- break
+ break#{' if values_found == 2' if find_two_values}
end
- skip 'No resources appear to be available for this patient. Please use patients with more information.' unless @resources_found)
+ #{skip_if_not_found(sequence)})
end
def get_search_params(search_parameters, sequence, grab_first_value = false)
@@ -520,7 +646,7 @@ def get_search_param_hash(search_parameters, sequence, grab_first_value = false)
elsif grab_first_value && !sequence[:delayed_sequence]
sequence[:search_param_descriptions][param.to_sym][:values].first
else
- resolve_element_path(sequence[:search_param_descriptions][param.to_sym])
+ "get_value_for_search_param(#{resolve_element_path(sequence[:search_param_descriptions][param.to_sym])})"
end
end
end
@@ -544,7 +670,6 @@ def get_comparator_searches(search_params, sequence)
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
@@ -559,6 +684,11 @@ def find_comparators(search_params, sequence)
end
end
+ def skip_if_not_found(sequence)
+ use_other_patient = ' Please use patients with more information.'
+ "skip 'No #{sequence[:resource]} resources appear to be available.#{use_other_patient unless sequence[:delayed_sequence]}' unless @resources_found"
+ end
+
def search_param_constants(search_parameters, sequence)
return { '_id': '@instance.patient_id' } if search_parameters == ['_id'] && sequence[:resource] == 'Patient'
end
@@ -573,19 +703,10 @@ def create_search_validation(sequence)
path_parts = path_parts.map { |part| part == 'class' ? 'local_class' : part }
path_parts.shift
case type
- when 'Period'
- search_validators += %(
- 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 'date'
+ when 'Period', 'date'
search_validators += %(
- 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'
+ value_found = resolve_element_from_path(resource, '#{path_parts.join('.')}') { |date| validate_date_search(value, date) }
+ assert value_found.present?, '#{element} on resource does not match #{element} requested'
)
when 'HumanName'
# When a string search parameter refers to the types HumanName and Address,
@@ -593,39 +714,40 @@ def create_search_validation(sequence)
# https://www.hl7.org/fhir/search.html#string
search_validators += %(
value = value.downcase
- value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |name|
+ value_found = resolve_element_from_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'
+ assert value_found.present?, '#{element} on resource does not match #{element} requested'
)
when 'Address'
search_validators += %(
- value_found = can_resolve_path(resource, '#{path_parts.join('.')}') do |address|
+ value_found = resolve_element_from_path(resource, '#{path_parts.join('.')}') do |address|
address&.text&.start_with?(value) ||
address&.city&.start_with?(value) ||
address&.state&.start_with?(value) ||
address&.postalCode&.start_with?(value) ||
address&.country&.start_with?(value)
end
- assert value_found, '#{element} on resource does not match #{element} requested'
+ assert value_found.present?, '#{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
+ path = path_parts.join('.') + get_value_path_by_type(type)
search_validators +=
if ['subject', 'patient'].include? element.to_s
%(
- 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'
+ value_found = resolve_element_from_path(resource, '#{path}') { |reference| [value, 'Patient/' + value].include? reference }
+ assert value_found.present?, '#{element} on resource does not match #{element} requested'
)
else
%(
- 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'
+ value_found = resolve_element_from_path(resource, '#{path}') { |value_in_resource| value.split(',').include? value_in_resource }
+ assert value_found.present?, '#{element} on resource does not match #{element} requested'
)
end
end
diff --git a/lib/app.rb b/lib/app.rb
index 073b8c1af..ee8a1ccb8 100644
--- a/lib/app.rb
+++ b/lib/app.rb
@@ -13,7 +13,6 @@
require 'dm-core'
require 'dm-migrations'
require 'jwt'
-require 'json/jwt'
require 'kramdown'
require 'rack'
diff --git a/lib/app/endpoint.rb b/lib/app/endpoint.rb
index 9c446e0fa..0cf1a0ada 100644
--- a/lib/app/endpoint.rb
+++ b/lib/app/endpoint.rb
@@ -5,12 +5,14 @@
require 'sinatra/cookies'
require_relative 'helpers/configuration'
require_relative 'helpers/browser_logic'
+require_relative 'utils/resource_validator_factory'
+
module Inferno
class App
class Endpoint < Sinatra::Base
register Sinatra::ConfigFile
- config_file '../../config.yml'
+ config_file File.join('..', '..', ENV['INFERNO_CONFIG_FILE'] || 'config.yml')
OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE if settings.disable_verify_peer
Inferno::BASE_PATH = "/#{settings.base_path.gsub(/[^0-9a-z_-]/i, '')}"
@@ -18,6 +20,7 @@ class Endpoint < Sinatra::Base
Inferno::ENVIRONMENT = settings.environment
Inferno::PURGE_ON_RELOAD = settings.purge_database_on_reload
Inferno::EXTRAS = settings.include_extras
+ Inferno::RESOURCE_VALIDATOR = Inferno::App::ResourceValidatorFactory.new_validator(settings.resource_validator, settings.external_resource_validator_url)
if settings.logging_enabled
$stdout.sync = true # output in Docker is heavily delayed without this
diff --git a/lib/app/endpoint/home.rb b/lib/app/endpoint/home.rb
index 8d6f05d70..934ce2f9c 100644
--- a/lib/app/endpoint/home.rb
+++ b/lib/app/endpoint/home.rb
@@ -74,12 +74,17 @@ class Home < Endpoint
# 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
- sequence = Inferno::Module.get(params[:module]).sequences.find do |x|
+ inferno_module = Inferno::Module.get(params[:module])
+ sequence = inferno_module.sequences.find do |x|
x.sequence_name == params[:sequence_name]
end
+
halt 404 unless sequence
- @test = sequence.tests[params[:test_index].to_i]
+
+ @test = sequence.tests(inferno_module)[params[:test_index].to_i]
+
halt 404 unless @test.present?
+
erb :test_details, layout: false
end
diff --git a/lib/app/endpoint/oauth2_endpoints.rb b/lib/app/endpoint/oauth2_endpoints.rb
index 7ef193423..686d4f99c 100644
--- a/lib/app/endpoint/oauth2_endpoints.rb
+++ b/lib/app/endpoint/oauth2_endpoints.rb
@@ -89,13 +89,20 @@ def resume_execution
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
+ sequence_test_count = test_set.test_case_by_id(set).sequence.test_count(@instance.module)
total + sequence_test_count
end
- sequence_result = sequence.resume(request, headers, request.params, @error_message) do |result|
+ sequence_result = sequence.resume(request, headers, request.params, @error_message) do
count += 1
- out << js_update_result(sequence, test_set, result, count, sequence.test_count, count, total_tests)
+ out << js_update_result(
+ instance: @instance,
+ sequence: sequence,
+ test_set: test_set,
+ set_count: count,
+ count: count,
+ total: total_tests
+ )
@instance.save!
end
all_test_cases << test_case.id
@@ -134,10 +141,17 @@ def resume_execution
@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|
+ sequence_result = sequence.start do
test_count += 1
count += 1
- out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests)
+ out << js_update_result(
+ instance: @instance,
+ sequence: sequence,
+ test_set: test_set,
+ set_count: count,
+ count: test_count,
+ total: total_tests
+ )
end
all_test_cases << test_case.id
failed_test_cases << test_case.id if sequence_result.fail?
diff --git a/lib/app/endpoint/test_set_endpoints.rb b/lib/app/endpoint/test_set_endpoints.rb
index 85e76023a..b7f6db22f 100644
--- a/lib/app/endpoint/test_set_endpoints.rb
+++ b/lib/app/endpoint/test_set_endpoints.rb
@@ -64,8 +64,9 @@ def self.included(klass)
# 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]
+ instance = sequence_result.testing_instance
+ halt 404 if instance.id != params[:id]
+ test_set = instance.module.test_sets[params[:test_set_id].to_sym]
halt 404 if test_set.nil?
sequence_result.result = 'cancel'
@@ -77,13 +78,13 @@ def self.included(klass)
last_result.message = cancel_message
end
- sequence = sequence_result.testing_instance.module.sequences.find do |x|
+ sequence = 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|
+ sequence.tests(instance.module).each_with_index do |test, index|
next if index < current_test_count
sequence_result.test_results << Inferno::Models::TestResult.new(test_id: test.id,
@@ -131,7 +132,7 @@ def self.included(klass)
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
+ sequence_test_count = test_set.test_case_by_id(set).sequence.test_count(instance.module)
total + sequence_test_count
end
@@ -178,10 +179,17 @@ def self.included(klass)
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|
+ sequence_result = sequence.start(test_set.id, test_case.id) do
count += 1
test_count += 1
- out << js_update_result(sequence, test_set, result, count, sequence.test_count, test_count, total_tests)
+ out << js_update_result(
+ instance: instance,
+ sequence: sequence,
+ test_set: test_set,
+ set_count: count,
+ count: test_count,
+ total: total_tests
+ )
end
sequence_result.next_test_cases = ([next_test_case] + submitted_test_cases).join(',')
diff --git a/lib/app/ext/fhir_client.rb b/lib/app/ext/fhir_client.rb
index 31f1c938a..7c1fd815c 100644
--- a/lib/app/ext/fhir_client.rb
+++ b/lib/app/ext/fhir_client.rb
@@ -4,9 +4,10 @@ module FHIR
VERSIONS = [:dstu2, :stu3, :r4].freeze
class Client
- attr_accessor :requests
+ attr_accessor :requests, :testing_instance
def record_requests(reply)
+ reply.response[:timestamp] = DateTime.now
@requests ||= []
@requests << reply
end
@@ -19,6 +20,7 @@ def monitor_requests
class_eval <<~RUBY, __FILE__, __LINE__ + 1
alias #{method}_original #{method}
def #{method}(*args, &block)
+ refresh_token_if_needed
reply = #{method}_original(*args, &block)
record_requests(reply)
return reply
@@ -27,8 +29,67 @@ def #{method}(*args, &block)
end
end
+ def refresh_token_if_needed
+ return if testing_instance&.refresh_token.blank?
+
+ perform_refresh if time_to_refresh?
+ end
+
+ def time_to_refresh?
+ return true if testing_instance.token_expires_in.blank?
+
+ testing_instance.token_expiration_time.to_i - DateTime.now.to_i < 60
+ end
+
+ def perform_refresh
+ oauth2_params = {
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => testing_instance.refresh_token
+ }
+ oauth2_headers = { 'Content-Type' => 'application/x-www-form-urlencoded' }
+
+ if testing_instance.confidential_client
+ oauth2_headers['Authorization'] = encoded_secret(testing_instance.client_id, testing_instance.client_secret)
+ else
+ oauth2_params['client_id'] = testing_instance.client_id
+ end
+
+ begin
+ token_response = Inferno::LoggedRestClient.post(
+ testing_instance.oauth_token_endpoint,
+ oauth2_params,
+ oauth2_headers
+ )
+
+ return if token_response.code != 200
+
+ token_response_body = JSON.parse(token_response.body)
+
+ expires_in = token_response_body['expires_in'].is_a?(Numeric) ? token_response_body['expires_in'] : nil
+
+ update_params = {
+ token: token_response_body['access_token'],
+ token_retrieved_at: DateTime.now,
+ token_expires_in: expires_in
+ }
+
+ update_params[:refresh_token] = token_response_body['refresh_token'] if token_response_body['refresh_token'].present?
+ testing_instance.save
+ testing_instance.update(update_params)
+
+ set_bearer_token(token_response_body['access_token'])
+ rescue StandardError => e
+ Inferno.logger.error "Unable to refresh token: #{e.message}"
+ end
+ end
+
+ def encoded_secret(client_id, client_secret)
+ "Basic #{Base64.strict_encode64(client_id + ':' + client_secret)}"
+ end
+
def self.for_testing_instance(instance)
new(instance.url).tap do |client|
+ client.testing_instance = instance
case instance.fhir_version
when 'stu3'
client.use_stu3
diff --git a/lib/app/helpers/browser_logic.rb b/lib/app/helpers/browser_logic.rb
index 053080902..20c3b9f73 100644
--- a/lib/app/helpers/browser_logic.rb
+++ b/lib/app/helpers/browser_logic.rb
@@ -16,17 +16,18 @@ def js_stayalive(time)
""
end
- def js_update_result(sequence, _test_set, _result, set_count, set_total, count, total)
+ def js_update_result(instance:, sequence:, test_set:, set_count:, count:, total:)
cancel_button =
if sequence.sequence_result
- "Cancel Sequence"
+ cancel_link = "#{instance.base_url}#{base_path}/#{instance.id}/test_sets/#{test_set.id}/sequence_result/#{sequence.sequence_result.id}/cancel"
+ "Cancel Sequence"
else
''
end
%(