davinci_crd_test_kit 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +201 -0
- data/lib/davinci_crd_test_kit/card_responses/companions_prerequisites.json +58 -0
- data/lib/davinci_crd_test_kit/card_responses/create_update_coverage_information.json +20 -0
- data/lib/davinci_crd_test_kit/card_responses/external_reference.json +21 -0
- data/lib/davinci_crd_test_kit/card_responses/instructions.json +14 -0
- data/lib/davinci_crd_test_kit/card_responses/launch_smart_app.json +21 -0
- data/lib/davinci_crd_test_kit/card_responses/propose_alternate_request.json +71 -0
- data/lib/davinci_crd_test_kit/card_responses/request_form_completion.json +227 -0
- data/lib/davinci_crd_test_kit/cards_validation.rb +234 -0
- data/lib/davinci_crd_test_kit/client_fhir_api_group.rb +762 -0
- data/lib/davinci_crd_test_kit/client_hook_request_validation.rb +15 -0
- data/lib/davinci_crd_test_kit/client_hooks_group.rb +706 -0
- data/lib/davinci_crd_test_kit/client_tests/appointment_book_receive_request_test.rb +71 -0
- data/lib/davinci_crd_test_kit/client_tests/client_display_cards_attest.rb +48 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_create_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_read_test.rb +39 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_search_test.rb +232 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_update_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/client_fhir_api_validation_test.rb +60 -0
- data/lib/davinci_crd_test_kit/client_tests/decode_auth_token_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/encounter_discharge_receive_request_test.rb +68 -0
- data/lib/davinci_crd_test_kit/client_tests/encounter_start_receive_request_test.rb +68 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_optional_fields_test.rb +41 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_required_fields_test.rb +40 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_valid_context_test.rb +63 -0
- data/lib/davinci_crd_test_kit/client_tests/hook_request_valid_prefetch_test.rb +151 -0
- data/lib/davinci_crd_test_kit/client_tests/order_dispatch_receive_request_test.rb +79 -0
- data/lib/davinci_crd_test_kit/client_tests/order_select_receive_request_test.rb +76 -0
- data/lib/davinci_crd_test_kit/client_tests/order_sign_receive_request_test.rb +79 -0
- data/lib/davinci_crd_test_kit/client_tests/retrieve_jwks_test.rb +65 -0
- data/lib/davinci_crd_test_kit/client_tests/token_header_test.rb +34 -0
- data/lib/davinci_crd_test_kit/client_tests/token_payload_test.rb +61 -0
- data/lib/davinci_crd_test_kit/crd_client_suite.rb +156 -0
- data/lib/davinci_crd_test_kit/crd_jwks.json +59 -0
- data/lib/davinci_crd_test_kit/crd_options.rb +9 -0
- data/lib/davinci_crd_test_kit/crd_server_suite.rb +115 -0
- data/lib/davinci_crd_test_kit/ext/inferno_core/runnable.rb +22 -0
- data/lib/davinci_crd_test_kit/hook_request_field_validation.rb +410 -0
- data/lib/davinci_crd_test_kit/jwks.rb +25 -0
- data/lib/davinci_crd_test_kit/jwt_helper.rb +74 -0
- data/lib/davinci_crd_test_kit/mock_service_response.rb +421 -0
- data/lib/davinci_crd_test_kit/routes/cds-services.json +74 -0
- data/lib/davinci_crd_test_kit/routes/cds_services_discovery_handler.rb +18 -0
- data/lib/davinci_crd_test_kit/routes/hook_request_endpoint.rb +93 -0
- data/lib/davinci_crd_test_kit/routes/jwk_set_endpoint_handler.rb +15 -0
- data/lib/davinci_crd_test_kit/server_appointment_book_group.rb +173 -0
- data/lib/davinci_crd_test_kit/server_discovery_group.rb +59 -0
- data/lib/davinci_crd_test_kit/server_encounter_discharge_group.rb +144 -0
- data/lib/davinci_crd_test_kit/server_encounter_start_group.rb +144 -0
- data/lib/davinci_crd_test_kit/server_hook_request_validation.rb +15 -0
- data/lib/davinci_crd_test_kit/server_hooks_group.rb +69 -0
- data/lib/davinci_crd_test_kit/server_order_dispatch_group.rb +173 -0
- data/lib/davinci_crd_test_kit/server_order_select_group.rb +169 -0
- data/lib/davinci_crd_test_kit/server_order_sign_group.rb +198 -0
- data/lib/davinci_crd_test_kit/server_required_card_response_validation_group.rb +23 -0
- data/lib/davinci_crd_test_kit/server_tests/additional_orders_validation_test.rb +70 -0
- data/lib/davinci_crd_test_kit/server_tests/card_optional_fields_validation_test.rb +47 -0
- data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_across_hooks_validation_test.rb +32 -0
- data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_received_test.rb +58 -0
- data/lib/davinci_crd_test_kit/server_tests/coverage_information_system_action_validation_test.rb +121 -0
- data/lib/davinci_crd_test_kit/server_tests/create_or_update_coverage_info_response_validation_test.rb +72 -0
- data/lib/davinci_crd_test_kit/server_tests/discovery_endpoint_test.rb +88 -0
- data/lib/davinci_crd_test_kit/server_tests/discovery_services_validation_test.rb +65 -0
- data/lib/davinci_crd_test_kit/server_tests/external_reference_card_across_hooks_validation_test.rb +28 -0
- data/lib/davinci_crd_test_kit/server_tests/external_reference_card_validation_test.rb +36 -0
- data/lib/davinci_crd_test_kit/server_tests/form_completion_response_validation_test.rb +79 -0
- data/lib/davinci_crd_test_kit/server_tests/instructions_card_received_across_hooks_test.rb +25 -0
- data/lib/davinci_crd_test_kit/server_tests/instructions_card_received_test.rb +28 -0
- data/lib/davinci_crd_test_kit/server_tests/launch_smart_app_card_validation_test.rb +38 -0
- data/lib/davinci_crd_test_kit/server_tests/propose_alternate_request_card_validation_test.rb +65 -0
- data/lib/davinci_crd_test_kit/server_tests/service_call_test.rb +86 -0
- data/lib/davinci_crd_test_kit/server_tests/service_request_context_validation_test.rb +30 -0
- data/lib/davinci_crd_test_kit/server_tests/service_request_optional_fields_validation_test.rb +41 -0
- data/lib/davinci_crd_test_kit/server_tests/service_request_required_fields_validation_test.rb +43 -0
- data/lib/davinci_crd_test_kit/server_tests/service_response_validation_test.rb +82 -0
- data/lib/davinci_crd_test_kit/suggestion_actions_validation.rb +123 -0
- data/lib/davinci_crd_test_kit/tags.rb +8 -0
- data/lib/davinci_crd_test_kit/test_helper.rb +23 -0
- data/lib/davinci_crd_test_kit/urls.rb +52 -0
- data/lib/davinci_crd_test_kit/version.rb +3 -0
- data/lib/davinci_crd_test_kit.rb +2 -0
- metadata +170 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require_relative '../test_helper'
|
|
2
|
+
|
|
3
|
+
module DaVinciCRDTestKit
|
|
4
|
+
class LaunchSmartAppCardValidationTest < Inferno::Test
|
|
5
|
+
include DaVinciCRDTestKit::TestHelper
|
|
6
|
+
|
|
7
|
+
title 'Valid Launch SMART Application cards received'
|
|
8
|
+
id :crd_launch_smart_app_card_validation
|
|
9
|
+
description %(
|
|
10
|
+
This test verifies the presence of valid Launch SMART Application cards within the list of valid cards
|
|
11
|
+
returned by the CRD service.
|
|
12
|
+
As per the [Da Vinci CRD Implementation Guide](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#launch-smart-application),
|
|
13
|
+
Launch SMART Application cards must contain links with the type set to `smart`.
|
|
14
|
+
This test checks for the presence of any Launch SMART Application cards by verifying:
|
|
15
|
+
- The existence of a `links` array within each card.
|
|
16
|
+
- That every link in the `links` array of a card is of type `smart`.
|
|
17
|
+
|
|
18
|
+
The test will be skipped if no Launch SMART Application cards are found within the returned valid cards.
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
optional
|
|
22
|
+
input :valid_cards_with_links
|
|
23
|
+
|
|
24
|
+
def hook_name
|
|
25
|
+
config.options[:hook_name]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
run do
|
|
29
|
+
parsed_cards = parse_json(valid_cards_with_links)
|
|
30
|
+
external_reference_cards = parsed_cards.select do |card|
|
|
31
|
+
links = card['links']
|
|
32
|
+
links.present? && links.all? { |link| link['type'] == 'smart' }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
skip_if external_reference_cards.blank?, "#{hook_name} hook response does not contain any Launch SMART App cards."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
require_relative '../test_helper'
|
|
2
|
+
require_relative '../suggestion_actions_validation'
|
|
3
|
+
|
|
4
|
+
module DaVinciCRDTestKit
|
|
5
|
+
class ProposeAlternateRequestCardValidationTest < Inferno::Test
|
|
6
|
+
include DaVinciCRDTestKit::TestHelper
|
|
7
|
+
include DaVinciCRDTestKit::SuggestionActionsValidation
|
|
8
|
+
|
|
9
|
+
title 'Valid Propose Alternate Request cards received'
|
|
10
|
+
id :crd_propose_alternate_request_card_validation
|
|
11
|
+
description %(
|
|
12
|
+
This test validates that all [Propose Alternate Request](https://hl7.org/fhir/us/davinci-crd/STU2/cards.html#propose-alternate-request)
|
|
13
|
+
cards received are valid. It checks for the presence of a card's suggestion
|
|
14
|
+
with a single action with `Action.type` of `update` or a card with at least
|
|
15
|
+
two actions, one with `Action.type` of `delete` and the other with
|
|
16
|
+
`Action.type` of `create`.
|
|
17
|
+
)
|
|
18
|
+
optional
|
|
19
|
+
input :valid_cards_with_suggestions, :contexts
|
|
20
|
+
|
|
21
|
+
EXPECTED_RESOURCE_TYPES = %w[
|
|
22
|
+
Device DeviceRequest Encounter Medication
|
|
23
|
+
MedicationRequest NutritionOrder ServiceRequest
|
|
24
|
+
VisionPrescription
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
def hook_name
|
|
28
|
+
config.options[:hook_name]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_action_type(actions, action_type)
|
|
32
|
+
actions&.any? do |action|
|
|
33
|
+
action['type'] == action_type && action_resource_type_check(action, EXPECTED_RESOURCE_TYPES)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def propose_alternate_request_card?(card)
|
|
38
|
+
card['suggestions'].any? do |suggestion|
|
|
39
|
+
actions = suggestion['actions']
|
|
40
|
+
has_update = check_action_type(actions, 'update')
|
|
41
|
+
has_delete = check_action_type(actions, 'delete')
|
|
42
|
+
has_create = check_action_type(actions, 'create')
|
|
43
|
+
|
|
44
|
+
has_update || (has_delete && has_create)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
run do
|
|
49
|
+
parsed_cards = parse_json(valid_cards_with_suggestions)
|
|
50
|
+
parsed_contexts = parse_json(contexts)
|
|
51
|
+
proposed_alternate_cards = parsed_cards.filter { |card| propose_alternate_request_card?(card) }
|
|
52
|
+
|
|
53
|
+
skip_if proposed_alternate_cards.blank?,
|
|
54
|
+
"#{hook_name} hook response does not contain a Propose Alternate Request card."
|
|
55
|
+
|
|
56
|
+
proposed_alternate_cards.each do |card|
|
|
57
|
+
card['suggestions'].each do |suggestion|
|
|
58
|
+
actions_check(suggestion['actions'], parsed_contexts)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
no_error_validation('Some Proposed Alternate Request cards are not valid.')
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module DaVinciCRDTestKit
|
|
2
|
+
class ServiceCallTest < Inferno::Test
|
|
3
|
+
title 'Submit user-defined service requests'
|
|
4
|
+
id :crd_service_call_test
|
|
5
|
+
description %(
|
|
6
|
+
This test initiates POST request(s) to a specified CDS Service using the JSON body list provided by the user.
|
|
7
|
+
As indicated in the [CDS Hooks specification section on Calling a CDS Service](https://cds-hooks.hl7.org/2.0/#calling-a-cds-service),
|
|
8
|
+
the service endpoint is constructed by appending the individual service id to the CDS Service base URL,
|
|
9
|
+
following the format `{baseUrl}/cds-services/{service.id}`.
|
|
10
|
+
|
|
11
|
+
If running this group only, the user will need to provide the `service.id` to call the specified service.
|
|
12
|
+
Otherwise, the `service.id` is derived from the CDS Services that are retrieved through a query to the
|
|
13
|
+
discovery endpoint.
|
|
14
|
+
|
|
15
|
+
The test will be skipped if the CRD server does not host a CDS Service corresponding to the hook that
|
|
16
|
+
is being tested.
|
|
17
|
+
|
|
18
|
+
The test is deemed successful if the CRD server returns a 200 HTTP response for all requests.
|
|
19
|
+
)
|
|
20
|
+
input_order :base_url, :encryption_method, :jwks_kid
|
|
21
|
+
input :base_url, :service_ids
|
|
22
|
+
input :service_request_bodies,
|
|
23
|
+
optional: true,
|
|
24
|
+
type: 'textarea',
|
|
25
|
+
description: 'Provide a list of request bodies send multiple requests. e.g. [json_body_1, json_body_2]'
|
|
26
|
+
input :encryption_method,
|
|
27
|
+
title: 'JWT Signing Algorithm',
|
|
28
|
+
description: <<~DESCRIPTION,
|
|
29
|
+
CDS Hooks recommends ES384 and RS384 for JWT signature verification.
|
|
30
|
+
Select which method to use.
|
|
31
|
+
DESCRIPTION
|
|
32
|
+
type: 'radio',
|
|
33
|
+
options: {
|
|
34
|
+
list_options: [
|
|
35
|
+
{
|
|
36
|
+
label: 'ES384',
|
|
37
|
+
value: 'ES384'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'RS384',
|
|
41
|
+
value: 'RS384'
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
input :jwks_kid,
|
|
46
|
+
title: 'CDS Services JWKS kid',
|
|
47
|
+
description: <<~DESCRIPTION,
|
|
48
|
+
The key ID of the JWKS private key to use for signing the JWTs when invoking a CDS service endpoint
|
|
49
|
+
requiring authentication.
|
|
50
|
+
Defaults to the first JWK in the list if no kid is supplied.
|
|
51
|
+
DESCRIPTION
|
|
52
|
+
optional: true
|
|
53
|
+
|
|
54
|
+
def hook_name
|
|
55
|
+
config.options[:hook_name]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
run do
|
|
59
|
+
discovery_url = "#{base_url.chomp('/')}/cds-services"
|
|
60
|
+
skip_if service_request_bodies.blank?,
|
|
61
|
+
'Request body not provided, skipping test.'
|
|
62
|
+
assert_valid_json(service_request_bodies)
|
|
63
|
+
|
|
64
|
+
payloads = [JSON.parse(service_request_bodies)].flatten
|
|
65
|
+
service_id = service_ids.split(', ').first.strip
|
|
66
|
+
service_endpoint = "#{discovery_url}/#{service_id}"
|
|
67
|
+
token = JwtHelper.build(
|
|
68
|
+
aud: service_endpoint,
|
|
69
|
+
iss: inferno_base_url,
|
|
70
|
+
jku: "#{inferno_base_url}/jwks.json",
|
|
71
|
+
kid: jwks_kid,
|
|
72
|
+
encryption_method:
|
|
73
|
+
)
|
|
74
|
+
headers = { 'Content-type' => 'application/json', 'Authorization' => "Bearer #{token}" }
|
|
75
|
+
|
|
76
|
+
payloads.each do |payload|
|
|
77
|
+
post(service_endpoint, body: payload.to_json, headers:, tags: [hook_name])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
requests.each do |request|
|
|
81
|
+
assert_response_status(200, request:)
|
|
82
|
+
assert_valid_json(request.response_body)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative '../server_hook_request_validation'
|
|
2
|
+
require_relative '../test_helper'
|
|
3
|
+
|
|
4
|
+
module DaVinciCRDTestKit
|
|
5
|
+
class ServiceRequestContextValidationTest < Inferno::Test
|
|
6
|
+
include DaVinciCRDTestKit::ServerHookRequestValidation
|
|
7
|
+
include DaVinciCRDTestKit::TestHelper
|
|
8
|
+
|
|
9
|
+
title 'All service requests contain valid context'
|
|
10
|
+
id :crd_service_request_context_validation
|
|
11
|
+
description %(
|
|
12
|
+
This test verifies that all service requests `context` field is valid and contains all the
|
|
13
|
+
required fields.
|
|
14
|
+
)
|
|
15
|
+
input :contexts
|
|
16
|
+
|
|
17
|
+
def hook_name
|
|
18
|
+
config.options[:hook_name]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
run do
|
|
22
|
+
parsed_contexts = parse_json(contexts)
|
|
23
|
+
parsed_contexts.each do |context|
|
|
24
|
+
hook_request_context_check(context, hook_name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
no_error_validation('Some contexts are not valid.')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative '../server_hook_request_validation'
|
|
2
|
+
|
|
3
|
+
module DaVinciCRDTestKit
|
|
4
|
+
class ServiceRequestOptionalFieldsValidationTest < Inferno::Test
|
|
5
|
+
include DaVinciCRDTestKit::ServerHookRequestValidation
|
|
6
|
+
|
|
7
|
+
title 'All service requests contain optional fields'
|
|
8
|
+
id :crd_service_request_optional_fields_validation
|
|
9
|
+
description %(
|
|
10
|
+
This optional test reviews the user-submitted CRD service requests for the presence of optional fields:
|
|
11
|
+
`fhirAuthorization` and `prefetch`.
|
|
12
|
+
|
|
13
|
+
The test will not fail if these optional fields are missing from a service request; instead, it generates an
|
|
14
|
+
informational message.
|
|
15
|
+
)
|
|
16
|
+
optional
|
|
17
|
+
|
|
18
|
+
def hook_name
|
|
19
|
+
config.options[:hook_name]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
run do
|
|
23
|
+
load_tagged_requests(hook_name)
|
|
24
|
+
skip_if requests.empty?, "No #{hook_name} request was made in a previous test as expected."
|
|
25
|
+
|
|
26
|
+
error_messages = []
|
|
27
|
+
requests.each_with_index do |request, index|
|
|
28
|
+
assert_valid_json(request.request_body)
|
|
29
|
+
request_body = JSON.parse(request.request_body)
|
|
30
|
+
hook_request_optional_fields_check(request_body)
|
|
31
|
+
rescue Inferno::Exceptions::AssertionException => e
|
|
32
|
+
error_messages << "Request #{index + 1}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
error_messages.each do |msg|
|
|
36
|
+
messages << { type: 'error', message: msg }
|
|
37
|
+
end
|
|
38
|
+
assert error_messages.empty?, 'Some service requests have invalid optional fields.'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require_relative '../server_hook_request_validation'
|
|
2
|
+
module DaVinciCRDTestKit
|
|
3
|
+
class ServiceRequestRequiredFieldsValidationTest < Inferno::Test
|
|
4
|
+
include DaVinciCRDTestKit::ServerHookRequestValidation
|
|
5
|
+
|
|
6
|
+
title 'All service requests contain required fields'
|
|
7
|
+
id :crd_service_request_required_fields_validation
|
|
8
|
+
description %(
|
|
9
|
+
This test validates all CRD service requests provided by the user, ensuring each includes all required fields
|
|
10
|
+
specified in the [CDS Hooks spec section on Calling a CDS Service](https://cds-hooks.hl7.org/2.0/#calling-a-cds-service):
|
|
11
|
+
`hook`, `hookInstance`, and `context`. Furthermore, the test checks for the conditional presence of the
|
|
12
|
+
`fhirServer` field if `fhirAuthorization` is included.
|
|
13
|
+
)
|
|
14
|
+
output :contexts
|
|
15
|
+
|
|
16
|
+
def hook_name
|
|
17
|
+
config.options[:hook_name]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
run do
|
|
21
|
+
load_tagged_requests(hook_name)
|
|
22
|
+
skip_if requests.empty?, "No #{hook_name} request was made in a previous test as expected."
|
|
23
|
+
|
|
24
|
+
error_messages = []
|
|
25
|
+
contexts = []
|
|
26
|
+
requests.each_with_index do |request, index|
|
|
27
|
+
assert_valid_json(request.request_body)
|
|
28
|
+
request_body = JSON.parse(request.request_body)
|
|
29
|
+
contexts << request_body['context'] if request_body['context'].is_a?(Hash)
|
|
30
|
+
hook_request_required_fields_check(request_body, hook_name)
|
|
31
|
+
rescue Inferno::Exceptions::AssertionException => e
|
|
32
|
+
error_messages << "Request #{index + 1}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
output contexts: contexts.to_json
|
|
36
|
+
|
|
37
|
+
error_messages.each do |msg|
|
|
38
|
+
messages << { type: 'error', message: msg }
|
|
39
|
+
end
|
|
40
|
+
assert error_messages.empty?, 'Some service requests made are not valid.'
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require_relative '../cards_validation'
|
|
2
|
+
|
|
3
|
+
module DaVinciCRDTestKit
|
|
4
|
+
class ServiceResponseValidationTest < Inferno::Test
|
|
5
|
+
include DaVinciCRDTestKit::CardsValidation
|
|
6
|
+
|
|
7
|
+
title 'All service responses contain valid cards and optional systemActions'
|
|
8
|
+
id :crd_service_response_validation
|
|
9
|
+
description %(
|
|
10
|
+
As per the [CDS Hooks spec section on CDS Service Response](https://cds-hooks.hl7.org/2.0/#cds-service-response),
|
|
11
|
+
a successful server's response to a service request must be a JSON object containing a `cards` array.
|
|
12
|
+
It must also contain a `systemActions` array for `appointment-book` and `order-sign` hook.
|
|
13
|
+
|
|
14
|
+
Each card must contain the following required fields: `summary`, `indicator`, and `source`.
|
|
15
|
+
The required fields must have a valid data structure.
|
|
16
|
+
)
|
|
17
|
+
output :valid_cards, :valid_system_actions
|
|
18
|
+
|
|
19
|
+
SYSTEM_ACTIONS_HOOK_NAMES = ['appointment-book', 'order-sign'].freeze
|
|
20
|
+
|
|
21
|
+
def hook_name
|
|
22
|
+
config.options[:hook_name]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def valid_cards
|
|
26
|
+
@valid_cards ||= []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def valid_system_actions
|
|
30
|
+
@valid_system_actions ||= []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def system_actions_check(system_actions)
|
|
34
|
+
system_actions.each do |action|
|
|
35
|
+
current_error_count = messages.count { |msg| msg[:type] == 'error' }
|
|
36
|
+
action_fields_validation(action)
|
|
37
|
+
valid_system_actions << action if current_error_count == messages.count { |msg| msg[:type] == 'error' }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def perform_system_actions_validation(system_actions, response_index)
|
|
42
|
+
if SYSTEM_ACTIONS_HOOK_NAMES.include?(hook_name) && system_actions.nil?
|
|
43
|
+
msg = "Server response #{response_index + 1} did not have `systemActions` field." \
|
|
44
|
+
"Must be present for #{hook_name}."
|
|
45
|
+
add_message('error', msg)
|
|
46
|
+
end
|
|
47
|
+
return if system_actions.nil?
|
|
48
|
+
|
|
49
|
+
unless system_actions.is_a?(Array)
|
|
50
|
+
add_message('error', "`systemActions` of server response #{response_index + 1} is not an array.")
|
|
51
|
+
return
|
|
52
|
+
end
|
|
53
|
+
system_actions_check(system_actions)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
run do
|
|
57
|
+
load_tagged_requests(hook_name)
|
|
58
|
+
skip_if requests.blank?, "No #{hook_name} request was made in a previous test as expected."
|
|
59
|
+
successful_requests = requests.select { |request| request.status == 200 }
|
|
60
|
+
skip_if successful_requests.empty?, 'All service requests were unsuccessful.'
|
|
61
|
+
|
|
62
|
+
info do
|
|
63
|
+
unsuncessful_count = (requests - successful_requests).length
|
|
64
|
+
assert unsuncessful_count.zero?, "#{unsuncessful_count} out of #{requests.length} requests were unsuccessful"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
successful_requests.each_with_index do |request, index|
|
|
68
|
+
service_response = JSON.parse(request.response_body)
|
|
69
|
+
perform_cards_validation(service_response['cards'], index)
|
|
70
|
+
|
|
71
|
+
perform_system_actions_validation(service_response['systemActions'], index)
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
add_message('error', "Invalid JSON: server response #{index + 1} is not a valid JSON.")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
output valid_system_actions: valid_system_actions.to_json
|
|
77
|
+
output valid_cards: valid_cards.to_json
|
|
78
|
+
|
|
79
|
+
no_error_validation('Some service responses are not valid. Check messages for issues found.')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require_relative 'server_hook_request_validation'
|
|
2
|
+
module DaVinciCRDTestKit
|
|
3
|
+
module SuggestionActionsValidation
|
|
4
|
+
include DaVinciCRDTestKit::ServerHookRequestValidation
|
|
5
|
+
|
|
6
|
+
def action_required_fields
|
|
7
|
+
{ 'type' => String, 'description' => String }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def action_fields_validation(action)
|
|
11
|
+
action_required_fields.each do |field, type|
|
|
12
|
+
validate_presence_and_type(action, field, type, 'Action')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
action_type_field_validation(action)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def action_type_field_validation(action)
|
|
19
|
+
return unless action['type']
|
|
20
|
+
|
|
21
|
+
allowed_types = ['create', 'update', 'delete']
|
|
22
|
+
type = action['type']
|
|
23
|
+
unless allowed_types.include?(type)
|
|
24
|
+
error_msg = "Action type value `#{type}` is not allowed. Allowed values: #{allowed_types.to_sentence}. " \
|
|
25
|
+
"In Action `#{action}`"
|
|
26
|
+
add_message('error', error_msg)
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if ['create', 'update'].include?(type)
|
|
31
|
+
action_resource_field_validation(action, type)
|
|
32
|
+
else
|
|
33
|
+
action_resource_id_field_validation(action)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def action_resource_field_validation(action, type)
|
|
38
|
+
unless action['resource']
|
|
39
|
+
add_message('error', "`Action.resource` must be present for `#{type}` actions: `#{action}`.")
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
resource = FHIR.from_contents(action['resource'].to_json)
|
|
44
|
+
return if resource
|
|
45
|
+
|
|
46
|
+
add_message('error', "`Action.resource` must be a FHIR resource: `#{action}`.")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def action_resource_id_field_validation(action)
|
|
50
|
+
validate_presence_and_type(action, 'resourceId', Array, '`delete` Action')
|
|
51
|
+
return unless action['resourceId'].is_a?(Array)
|
|
52
|
+
|
|
53
|
+
action['resourceId'].each do |ref|
|
|
54
|
+
resource_reference_check(ref, 'Action.resourceId item')
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def draft_orders_bundle_entry_refs(contexts)
|
|
59
|
+
@draft_orders_bundle_entry_refs ||= contexts.flat_map do |context|
|
|
60
|
+
draft_orders_bundle = parse_fhir_bundle_from_context('draftOrders', context)
|
|
61
|
+
draft_orders_bundle.entry.map { |entry| "#{entry.resource.resourceType}/#{entry.resource.id}" }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def action_resource_type_check(action, expected_resource_types)
|
|
66
|
+
resource_types = if ['create', 'update'].include?(action['type'])
|
|
67
|
+
[FHIR.from_contents(action['resource'].to_json).resourceType]
|
|
68
|
+
else
|
|
69
|
+
action['resourceId'].map { |ref| ref.split('/').first }
|
|
70
|
+
end
|
|
71
|
+
resource_types.all? { |resource_type| expected_resource_types.include?(resource_type) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_resource_types_by_action(actions, action_type)
|
|
75
|
+
actions.each_with_object([]) do |act, resource_types|
|
|
76
|
+
resource_types << act['resource']['resourceType'] if act['type'] == action_type
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def actions_check(actions, contexts = nil)
|
|
81
|
+
create_actions_resource_types = extract_resource_types_by_action(actions, 'create')
|
|
82
|
+
|
|
83
|
+
actions.each do |action|
|
|
84
|
+
case action['type']
|
|
85
|
+
when 'create', 'update'
|
|
86
|
+
create_or_update_action_check(action, contexts)
|
|
87
|
+
when 'delete'
|
|
88
|
+
delete_action_check(action, create_actions_resource_types, contexts)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def create_or_update_action_check(action, contexts)
|
|
94
|
+
resource = FHIR.from_contents(action['resource'].to_json)
|
|
95
|
+
resource_is_valid?(resource:, profile_url: structure_definition_map[resource.resourceType])
|
|
96
|
+
return unless action['type'] == 'update' && contexts
|
|
97
|
+
|
|
98
|
+
ref = "#{resource.resourceType}/#{resource.id}"
|
|
99
|
+
return if draft_orders_bundle_entry_refs(contexts).include?(ref)
|
|
100
|
+
|
|
101
|
+
error_msg = "Resource being updated must be from the `draftOrders` entry. #{ref} is not in the " \
|
|
102
|
+
"`context.drafOrders` of the submitted requests. In Action `#{action}`"
|
|
103
|
+
add_message('error', error_msg)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def delete_action_check(action, create_actions_resource_types, contexts)
|
|
107
|
+
action['resourceId'].each do |ref|
|
|
108
|
+
unless draft_orders_bundle_entry_refs(contexts).include?(ref)
|
|
109
|
+
error_msg = '`Action.resourceId` must reference FHIR resource from the `draftOrders` entry. ' \
|
|
110
|
+
"#{ref} is not in `draftOrders`. In Action `#{action}`"
|
|
111
|
+
add_message('error', error_msg)
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
resource_type = ref.split('/').first
|
|
116
|
+
next if create_actions_resource_types.include?(resource_type)
|
|
117
|
+
|
|
118
|
+
error_msg = "There's no `create` action for the proposed order being deleted: `#{ref}`. In Action `#{action}`"
|
|
119
|
+
add_message('error', error_msg)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
module DaVinciCRDTestKit
|
|
2
|
+
APPOINTMENT_BOOK_TAG = 'crd_appointment_book'.freeze
|
|
3
|
+
ENCOUNTER_START_TAG = 'crd_encounter_start'.freeze
|
|
4
|
+
ENCOUNTER_DISCHARGE_TAG = 'crd_encounter_discharge'.freeze
|
|
5
|
+
ORDER_DISPATCH_TAG = 'crd_order_dispatch'.freeze
|
|
6
|
+
ORDER_SELECT_TAG = 'crd_order_select'.freeze
|
|
7
|
+
ORDER_SIGN_TAG = 'crd_order_sign'.freeze
|
|
8
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module DaVinciCRDTestKit
|
|
2
|
+
module TestHelper
|
|
3
|
+
def parse_json(input)
|
|
4
|
+
assert_valid_json(input)
|
|
5
|
+
JSON.parse(input)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def verify_at_least_one_test_passes(test_groups, id_pattern, error_message, id_exclude_pattern = nil)
|
|
9
|
+
runnables = test_groups.map do |group|
|
|
10
|
+
group.tests.find do |test|
|
|
11
|
+
test.id.include?(id_pattern) && (!id_exclude_pattern || !test.id.include?(id_exclude_pattern))
|
|
12
|
+
end
|
|
13
|
+
end.compact
|
|
14
|
+
|
|
15
|
+
results_repo = Inferno::Repositories::Results.new
|
|
16
|
+
results = results_repo.current_results_for_test_session_and_runnables(test_session_id, runnables)
|
|
17
|
+
|
|
18
|
+
pass_if(results.any? { |result| result.result == 'pass' })
|
|
19
|
+
|
|
20
|
+
skip error_message
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module DaVinciCRDTestKit
|
|
2
|
+
APPOINTMENT_BOOK_PATH = '/cds-services/appointment-book-service'.freeze
|
|
3
|
+
ENCOUNTER_START_PATH = '/cds-services/encounter-start-service'.freeze
|
|
4
|
+
ENCOUNTER_DISCHARGE_PATH = '/cds-services/encounter-discharge-service'.freeze
|
|
5
|
+
ORDER_DISPATCH_PATH = '/cds-services/order-dispatch-service'.freeze
|
|
6
|
+
ORDER_SELECT_PATH = '/cds-services/order-select-service'.freeze
|
|
7
|
+
ORDER_SIGN_PATH = '/cds-services/order-sign-service'.freeze
|
|
8
|
+
RESUME_PASS_PATH = '/resume_pass'.freeze
|
|
9
|
+
RESUME_FAIL_PATH = '/resume_fail'.freeze
|
|
10
|
+
|
|
11
|
+
module URLs
|
|
12
|
+
def base_url
|
|
13
|
+
@base_url ||= "#{Inferno::Application['base_url']}/custom/#{suite_id}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def appointment_book_url
|
|
17
|
+
@appointment_book_url ||= base_url + APPOINTMENT_BOOK_PATH
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def encounter_start_url
|
|
21
|
+
@encounter_start_url ||= base_url + ENCOUNTER_START_PATH
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def encounter_discharge_url
|
|
25
|
+
@encounter_discharge_url ||= base_url + ENCOUNTER_DISCHARGE_PATH
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def order_dispatch_url
|
|
29
|
+
@order_dispatch_url ||= base_url + ORDER_DISPATCH_PATH
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def order_select_url
|
|
33
|
+
@order_select_url ||= base_url + ORDER_SELECT_PATH
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def order_sign_url
|
|
37
|
+
@order_sign_url ||= base_url + ORDER_SIGN_PATH
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resume_pass_url
|
|
41
|
+
@resume_pass_url ||= base_url + RESUME_PASS_PATH
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resume_fail_url
|
|
45
|
+
@resume_fail_url ||= base_url + RESUME_FAIL_PATH
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def suite_id
|
|
49
|
+
self.class.suite.id
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|